curieous commited on
Commit
b09ded8
·
verified ·
1 Parent(s): 0a2348b

Deploy Epic Errands V2 Space clean

Browse files
Files changed (35) hide show
  1. README.md +13 -57
  2. app.py +2 -0
  3. diy_lab/frontend/index.html +5 -5
  4. epic_errands_space/__init__.py +0 -19
  5. epic_errands_space/adapters.py +0 -42
  6. epic_errands_space/contracts.py +0 -76
  7. epic_errands_space/fixtures.py +0 -31
  8. epic_errands_space/quest_generation.py +0 -915
  9. epic_errands_space/render.py +0 -983
  10. frontend/assets/demo-placeholders/placeholder-boy-720.png +0 -3
  11. frontend/assets/demo-placeholders/placeholder-female-parent-720.png +0 -3
  12. frontend/assets/demo-placeholders/placeholder-girl-720.png +0 -3
  13. frontend/assets/demo-placeholders/placeholder-male-parent-720.png +0 -3
  14. frontend/assets/generated-audio-1-clean-up-my-room-before-dinner-56693c13.wav +0 -3
  15. frontend/assets/generated-audio-2-finish-my-class-project-outline-62a0674b.wav +0 -3
  16. frontend/assets/generated-audio-3-read-for-20-minutes-5e944b16.wav +0 -3
  17. frontend/assets/generated-hero-1-clean-up-my-room-before-dinner-07b300bc.png +0 -3
  18. frontend/assets/generated-hero-1-finish-class-project-8f64eeb3.png +0 -3
  19. frontend/assets/generated-hero-2-finish-my-class-project-outline-4a8a06bc.png +0 -3
  20. frontend/assets/generated-hero-2-read-for-20-minutes-895e7642.png +0 -3
  21. frontend/assets/generated-hero-3-put-away-laundry-083482db.png +0 -3
  22. frontend/assets/generated-hero-3-read-for-20-minutes-3781199a.png +0 -3
  23. frontend/assets/generated-hero-manifest.json +0 -0
  24. frontend/assets/sc1-crayon-green.png +0 -0
  25. frontend/assets/sc1-pencil-yellow.png +0 -0
  26. frontend/assets/sc1-photo-bridge-sketch.png +0 -0
  27. frontend/assets/sc1-photo-child-bridge.png +0 -0
  28. frontend/assets/sc1-photo-student-drawing.png +0 -0
  29. frontend/assets/sc2-hero-illustration.png +0 -3
  30. frontend/assets/sc2-hero-scene-only.png +0 -3
  31. frontend/assets/sc2-parent-strip.png +0 -0
  32. frontend/assets/sc2-reward-sticker.png +0 -0
  33. frontend/assets/source-sc1.png +0 -3
  34. frontend/assets/source-sc2.png +0 -3
  35. frontend/index.html +5 -5
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Epic Errands V1 Mobile Review
3
  emoji: 🧭
4
  colorFrom: blue
5
  colorTo: purple
@@ -10,70 +10,26 @@ pinned: false
10
  license: mit
11
  ---
12
 
13
- # Epic Errands V1 Mobile Review
14
 
15
- This Space package runs a hi-fi mobile review prototype for Epic Errands V1.
16
- The visible product surface is a fully-custom mobile UI served at `/` by
17
- `gradio.Server`. Gradio remains the HF Space-compatible server and API layer;
18
- the public app is not a `gr.HTML` island inside `gr.Blocks`.
19
 
20
- The prototype includes:
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.
29
- - Disabled V1.1 audio hook.
30
- - Session-only avatar note.
31
-
32
- ## Runtime Honesty
33
-
34
- Current review-prototype package mode:
35
 
36
  ```text
37
- lifecycle_stage=testing
38
- app_host=local or hf_space
39
  model_runtime=none
40
- model_backend=static_review_asset
41
- inference_engine=none
42
- model_artifact_format=png
43
- quantization=none
44
- model_id=none
45
  fallback_used=true
46
  ```
47
 
48
- The visible hero images are cached review assets. This Space does not claim live
49
- image generation, generated audio, sponsor-model proof, or judge-ready status.
50
-
51
- Future live image generation may use:
52
-
53
- ```text
54
- APP_MODAL_BASE_URL
55
- APP_MODAL_AUTH_TOKEN
56
- APP_MODAL_TIMEOUT_SECONDS
57
- ```
58
-
59
- Secret values must be configured as HF Space secrets/variables and must never
60
- be committed.
61
-
62
- ## Local Run
63
-
64
- ```bash
65
- python3 app.py
66
- ```
67
 
68
  Main routes:
69
 
70
- - `/`: custom mobile app shell.
71
- - `/frontend/...`: static CSS and JS.
72
- - `/assets/...`: static review images.
73
- - `/health`: hosted smoke endpoint.
74
- - `/api/bootstrap` and `/api/quest-pack`: lightweight JSON endpoints with
75
- inline image data stripped.
76
-
77
- Future FLUX, HF Inference, Modal, or other image-generation paths should be
78
- added behind `epic_errands_space/adapters.py` and must keep provenance visible
79
- in the UI.
 
1
  ---
2
+ title: Epic Errands V2 Mobile Review
3
  emoji: 🧭
4
  colorFrom: blue
5
  colorTo: purple
 
10
  license: mit
11
  ---
12
 
13
+ # Epic Errands V2 Mobile Review
14
 
15
+ Private hosted testing package for Epic Errands V2.
 
 
 
16
 
17
+ Runtime honesty for this Space package:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  ```text
20
+ lifecycle_stage=hosted testing
21
+ app_host=hf_space
22
  model_runtime=none
 
 
 
 
 
23
  fallback_used=true
24
  ```
25
 
26
+ Fresh live model proof is recorded outside the Space package under the V2
27
+ evidence folder. The hosted app currently uses deterministic/cached review
28
+ assets and does not claim hosted live model execution or judge-ready status.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  Main routes:
31
 
32
+ - `/`: custom mobile shell when the Gradio server route is exposed.
33
+ - `/epic/index.html`: explicit static hosted product shell.
34
+ - `/epic-diy/index.html`: explicit static DIY shell.
35
+ - `/health`: custom health endpoint when exposed by the Gradio server.
 
 
 
 
 
 
app.py CHANGED
@@ -32,8 +32,10 @@ def build_server() -> Server:
32
  )
33
 
34
  app.mount("/frontend", StaticFiles(directory=FRONTEND_ROOT), name="frontend")
 
35
  app.mount("/assets", StaticFiles(directory=ASSETS_ROOT), name="assets")
36
  app.mount("/diy-assets", StaticFiles(directory=DIY_FRONTEND_ROOT), name="diy_assets")
 
37
  workflow_app = build_workflow_canvas_app(DIY_WORKFLOW_GRAPH_PATH)
38
  if workflow_app is not None:
39
  gr.mount_gradio_app(
 
32
  )
33
 
34
  app.mount("/frontend", StaticFiles(directory=FRONTEND_ROOT), name="frontend")
35
+ app.mount("/epic", StaticFiles(directory=FRONTEND_ROOT), name="epic_frontend")
36
  app.mount("/assets", StaticFiles(directory=ASSETS_ROOT), name="assets")
37
  app.mount("/diy-assets", StaticFiles(directory=DIY_FRONTEND_ROOT), name="diy_assets")
38
+ app.mount("/epic-diy", StaticFiles(directory=DIY_FRONTEND_ROOT), name="epic_diy_assets")
39
  workflow_app = build_workflow_canvas_app(DIY_WORKFLOW_GRAPH_PATH)
40
  if workflow_app is not None:
41
  gr.mount_gradio_app(
diy_lab/frontend/index.html CHANGED
@@ -4,13 +4,13 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Epic Errands DIY Lab</title>
7
- <link rel="stylesheet" href="/frontend/styles/tokens.css">
8
- <link rel="stylesheet" href="/frontend/styles/components.css">
9
- <link rel="stylesheet" href="/frontend/styles/app.css">
10
- <link rel="stylesheet" href="/diy-assets/styles/diy.css">
11
  </head>
12
  <body>
13
  <main id="diy-app" class="phone-stage" aria-live="polite"></main>
14
- <script src="/diy-assets/scripts/diy.js"></script>
15
  </body>
16
  </html>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Epic Errands DIY Lab</title>
7
+ <link rel="stylesheet" href="/epic/styles/tokens.css">
8
+ <link rel="stylesheet" href="/epic/styles/components.css">
9
+ <link rel="stylesheet" href="/epic/styles/app.css">
10
+ <link rel="stylesheet" href="/epic-diy/styles/diy.css">
11
  </head>
12
  <body>
13
  <main id="diy-app" class="phone-stage" aria-live="polite"></main>
14
+ <script src="/epic-diy/scripts/diy.js"></script>
15
  </body>
16
  </html>
epic_errands_space/__init__.py DELETED
@@ -1,19 +0,0 @@
1
- """Epic Errands Cozy Classroom Adventure Space package."""
2
-
3
- from .render import (
4
- build_app_bootstrap_js,
5
- build_app_css,
6
- build_app_document,
7
- build_app_head,
8
- build_app_island,
9
- )
10
- from .quest_generation import build_quest_pack
11
-
12
- __all__ = [
13
- "build_app_bootstrap_js",
14
- "build_app_css",
15
- "build_app_document",
16
- "build_app_head",
17
- "build_app_island",
18
- "build_quest_pack",
19
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
epic_errands_space/adapters.py DELETED
@@ -1,42 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
-
5
- from .contracts import ImageRequest, ImageResult, RuntimeMetadata
6
-
7
-
8
- class ImageGenerationAdapter(ABC):
9
- """Boundary for future FLUX/HF/Modal image generation backends."""
10
-
11
- @abstractmethod
12
- def generate_image(self, request: ImageRequest) -> ImageResult:
13
- raise NotImplementedError
14
-
15
-
16
- class FixtureImageAdapter(ImageGenerationAdapter):
17
- """Returns the source-crop fixture used by the deterministic dry run."""
18
-
19
- def generate_image(self, request: ImageRequest) -> ImageResult:
20
- return ImageResult(
21
- asset_id="quest-hero-scene",
22
- asset_path="frontend/assets/sc2-hero-scene-clean.png",
23
- origin="source_crop",
24
- prompt_summary=(
25
- "Fixture art for a cozy classroom adventure bridge quest. "
26
- "This is not runtime FLUX output."
27
- ),
28
- runtime_metadata=RuntimeMetadata(),
29
- )
30
-
31
-
32
- class DeterministicFallbackAdapter(ImageGenerationAdapter):
33
- """Safe fallback adapter used when a future model adapter is unavailable."""
34
-
35
- def generate_image(self, request: ImageRequest) -> ImageResult:
36
- return ImageResult(
37
- asset_id="quest-hero-scene",
38
- asset_path="frontend/assets/sc2-hero-scene-clean.png",
39
- origin="deterministic_fallback",
40
- prompt_summary=f"Fallback storybook bridge image for goal: {request.ordinary_goal}",
41
- runtime_metadata=RuntimeMetadata(fallback_used=True),
42
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
epic_errands_space/contracts.py DELETED
@@ -1,76 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass, field
4
- from typing import Literal
5
-
6
-
7
- Tone = Literal["cozy", "brave", "magical", "silly", "epic"]
8
- Mode = Literal["parent", "kid"]
9
-
10
-
11
- @dataclass(frozen=True)
12
- class RuntimeMetadata:
13
- lifecycle_stage: str = "dev"
14
- app_host: str = "local"
15
- model_runtime: str = "none"
16
- model_backend: str = "template"
17
- inference_engine: str = "none"
18
- model_artifact_format: str = "none"
19
- quantization: str = "none"
20
- model_id: str = "none"
21
- latency_ms: int = 0
22
- fallback_used: bool = True
23
-
24
-
25
- @dataclass(frozen=True)
26
- class GoalInput:
27
- id: str
28
- text: str
29
- completed: bool = False
30
-
31
-
32
- @dataclass(frozen=True)
33
- class ImageRequest:
34
- goal_id: str
35
- ordinary_goal: str
36
- tone: Tone
37
- visual_direction: str = "cozy_classroom_adventure"
38
-
39
-
40
- @dataclass(frozen=True)
41
- class ImageResult:
42
- asset_id: str
43
- asset_path: str
44
- origin: str
45
- prompt_summary: str
46
- runtime_metadata: RuntimeMetadata = field(default_factory=RuntimeMetadata)
47
-
48
-
49
- @dataclass(frozen=True)
50
- class QuestStep:
51
- id: str
52
- label: str
53
- icon: str
54
-
55
-
56
- @dataclass(frozen=True)
57
- class QuestResult:
58
- id: str
59
- source_goal_id: str
60
- title: str
61
- subtitle: str
62
- hero_asset_id: str
63
- steps: list[QuestStep]
64
- reward_label: str
65
- reward_description: str
66
- ordinary_goal: str
67
- why_it_helps: str
68
- runtime_metadata: RuntimeMetadata = field(default_factory=RuntimeMetadata)
69
-
70
-
71
- @dataclass
72
- class AppState:
73
- mode: Mode = "parent"
74
- tone: Tone = "cozy"
75
- goals: list[GoalInput] = field(default_factory=list)
76
- result: QuestResult | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
epic_errands_space/fixtures.py DELETED
@@ -1,31 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from .contracts import GoalInput, QuestResult, QuestStep, RuntimeMetadata
4
-
5
-
6
- RUNTIME_METADATA = RuntimeMetadata()
7
-
8
- DEFAULT_GOALS = [
9
- GoalInput(id="goal-1", text="Finish class project"),
10
- GoalInput(id="goal-2", text="Read for 20 minutes"),
11
- GoalInput(id="goal-3", text="Put away laundry"),
12
- ]
13
-
14
- DEFAULT_QUEST = QuestResult(
15
- id="quest-bridge-bright-ideas",
16
- source_goal_id="goal-1",
17
- title="Build the Bridge of Bright Ideas",
18
- subtitle="Your quest today",
19
- hero_asset_id="sc2-hero-scene-clean",
20
- steps=[
21
- QuestStep(id="step-1", label="Plan your ideas", icon="edit_note"),
22
- QuestStep(id="step-2", label="Build your project", icon="construction"),
23
- QuestStep(id="step-3", label="Check your work", icon="search"),
24
- QuestStep(id="step-4", label="Share your masterpiece", icon="chat_bubble"),
25
- ],
26
- reward_label="Idea Builder",
27
- reward_description="Reward earned after all steps are complete.",
28
- ordinary_goal="Finish class project",
29
- why_it_helps="Turns a school project into a clear sequence with a visible finish line.",
30
- runtime_metadata=RUNTIME_METADATA,
31
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
epic_errands_space/quest_generation.py DELETED
@@ -1,915 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- from datetime import datetime, timezone
5
- import hashlib
6
- import json
7
- import mimetypes
8
- import os
9
- from pathlib import Path
10
- import re
11
- import time
12
- from typing import Any
13
- import urllib.error
14
- import urllib.request
15
-
16
- from .fixtures import DEFAULT_GOALS
17
-
18
-
19
- FRONTEND_ROOT = Path(__file__).resolve().parents[1] / "frontend"
20
- ASSETS_DIR = FRONTEND_ROOT / "assets"
21
- CACHE_MANIFEST = ASSETS_DIR / "generated-hero-manifest.json"
22
- FALLBACK_HERO = "sc2-hero-scene-clean.png"
23
- MODEL_ID = "black-forest-labs/FLUX.2-klein-9B"
24
- MINICPM_MODEL_ID = "openbmb/MiniCPM4.1-8B"
25
- NEMOTRON3_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
26
- VOXCPM2_MODEL_ID = "openbmb/VoxCPM2"
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",
36
- }
37
-
38
- STEP_LIBRARY = {
39
- "project": [
40
- ("Plan your ideas", "edit_note"),
41
- ("Build your project", "construction"),
42
- ("Check your work", "search"),
43
- ("Share your masterpiece", "chat_bubble"),
44
- ],
45
- "read": [
46
- ("Find a cozy reading spot", "chair"),
47
- ("Read the next pages", "menu_book"),
48
- ("Collect three bright clues", "travel_explore"),
49
- ("Tell someone the best part", "forum"),
50
- ],
51
- "laundry": [
52
- ("Sort the tiny treasure piles", "inventory_2"),
53
- ("Fold each cloth banner", "featured_seasonal_and_gifts"),
54
- ("Deliver clothes to their homes", "home"),
55
- ("Celebrate the tidy kingdom", "workspace_premium"),
56
- ],
57
- "default": [
58
- ("Name the mission", "flag"),
59
- ("Do the first brave bit", "bolt"),
60
- ("Check what changed", "search"),
61
- ("Mark the win", "workspace_premium"),
62
- ],
63
- }
64
-
65
-
66
- def default_goal_payload() -> list[dict[str, str]]:
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
-
74
-
75
- def normalize_goals(raw_goals: Any) -> list[dict[str, str]]:
76
- goals: list[dict[str, str]] = []
77
- if isinstance(raw_goals, list):
78
- for index, item in enumerate(raw_goals):
79
- if not isinstance(item, dict):
80
- continue
81
- text = str(item.get("text") or "").strip()
82
- if not text:
83
- continue
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
96
- if not any(goal["text"].casefold() == fallback["text"].casefold() for goal in goals):
97
- goals.append({**fallback, "theme": fallback_themes[fallback_index % len(fallback_themes)]})
98
-
99
- return goals[:3]
100
-
101
-
102
- def generated_file_name(goal: dict[str, str], index: int) -> str:
103
- slug = re.sub(r"[^a-z0-9]+", "-", goal["text"].lower()).strip("-")[:36] or f"goal-{index + 1}"
104
- digest = hashlib.sha1(f"{goal['id']}:{goal['text']}".encode("utf-8")).hexdigest()[:8]
105
- return f"generated-hero-{index + 1}-{slug}-{digest}.png"
106
-
107
-
108
- def generated_audio_file_name(goal: dict[str, str], index: int) -> str:
109
- slug = re.sub(r"[^a-z0-9]+", "-", goal["text"].lower()).strip("-")[:36] or f"goal-{index + 1}"
110
- digest = hashlib.sha1(f"audio:{goal['id']}:{goal['text']}".encode("utf-8")).hexdigest()[:8]
111
- return f"generated-audio-{index + 1}-{slug}-{digest}.wav"
112
-
113
-
114
- def _asset_to_data_uri(file_name: str) -> str:
115
- path = ASSETS_DIR / file_name
116
- mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
117
- return f"data:{mime_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
118
-
119
-
120
- def _runtime_metadata(**overrides: Any) -> dict[str, Any]:
121
- metadata = {
122
- "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
123
- "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
124
- "model_runtime": "none",
125
- "model_backend": "template",
126
- "inference_engine": "none",
127
- "model_artifact_format": "none",
128
- "quantization": "none",
129
- "model_id": "none",
130
- "latency_ms": 0,
131
- "fallback_used": True,
132
- }
133
- metadata.update(overrides)
134
- return metadata
135
-
136
-
137
- def _step_key(goal_text: str) -> str:
138
- lowered = goal_text.lower()
139
- if "project" in lowered or "class" in lowered:
140
- return "project"
141
- if "read" in lowered or "book" in lowered:
142
- return "read"
143
- if "laundry" in lowered or "clothes" in lowered:
144
- return "laundry"
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"
152
- elif theme == "comic":
153
- noun = "Mission"
154
- else:
155
- noun = "Quest"
156
- tone_word = {
157
- "cozy": "Cozy",
158
- "brave": "Brave",
159
- "questbook": "Quest",
160
- "silly": "Silly",
161
- "epic": "Epic",
162
- }.get(tone, "Cozy")
163
- if "project" in lowered or "class" in lowered:
164
- return f"{noun}: Bright Idea Bridge"
165
- if "read" in lowered:
166
- return f"{noun}: Reading Lantern"
167
- if "laundry" in lowered or "clothes" in lowered:
168
- return f"{noun}: Laundry Launch"
169
- if "room" in lowered or "clean" in lowered:
170
- return f"{noun}: Tidy Room Rescue"
171
- return f"{tone_word} {noun} for {goal_text[:28]}"
172
-
173
-
174
- def _why_it_helps(goal_text: str) -> str:
175
- return (
176
- "Keeps the ordinary goal visible for parents while giving the child a "
177
- "small, story-shaped path to finish it."
178
- )
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
- ),
188
- "classroom": (
189
- "cozy classroom adventure, stickers, notebooks, pencils, sunny desk, "
190
- "teacher-approved kid-safe energy"
191
- ),
192
- "comic": (
193
- "bright comic mission panel, bold shapes, action burst composition, "
194
- "playful but calm, no readable text"
195
- ),
196
- }[theme]
197
- return (
198
- "A delightful family questbook hero image for a children's "
199
- "goal-planning app. Warm handcrafted illustration, clear everyday adventure, no readable text, no UI, "
200
- "kid-safe, expressive, polished. "
201
- f"Theme: {theme}. Tone: {tone}. Visual direction: {visual_direction}. "
202
- f"Ordinary goal transformed visually: {goal['text']}."
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"],
320
- "ordinary_goal": goal["text"],
321
- "asset_name": FALLBACK_HERO,
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,
329
- ),
330
- }
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
- }
350
-
351
-
352
- def _fallback_audio_result(goal: dict[str, str], index: int, reason: str) -> dict[str, Any]:
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,
364
- fallback_used=True,
365
- fallback_reason=reason,
366
- ),
367
- }
368
-
369
-
370
- def _load_manifest() -> dict[str, Any]:
371
- try:
372
- return json.loads(CACHE_MANIFEST.read_text(encoding="utf-8"))
373
- except (OSError, json.JSONDecodeError):
374
- return {"items": []}
375
-
376
-
377
- def _write_manifest(items: list[dict[str, Any]], metadata: dict[str, Any]) -> None:
378
- CACHE_MANIFEST.write_text(
379
- json.dumps(
380
- {
381
- "generated_at": datetime.now(timezone.utc).isoformat(),
382
- "model_id": MODEL_ID,
383
- "items": items,
384
- "runtime_metadata": metadata,
385
- },
386
- indent=2,
387
- sort_keys=True,
388
- )
389
- + "\n",
390
- encoding="utf-8",
391
- )
392
-
393
-
394
- def _cached_images_for(goals: list[dict[str, str]]) -> list[dict[str, Any]] | None:
395
- manifest = _load_manifest()
396
- items = manifest.get("items")
397
- if not isinstance(items, list) or len(items) < len(goals):
398
- return None
399
-
400
- by_goal = {
401
- str(item.get("goal_id")): item
402
- for item in items
403
- if isinstance(item, dict) and item.get("goal_id") and item.get("asset_name")
404
- }
405
- results: list[dict[str, Any]] = []
406
- for index, goal in enumerate(goals):
407
- item = by_goal.get(goal["id"])
408
- if not item:
409
- return None
410
- asset_name = str(item["asset_name"])
411
- if not (ASSETS_DIR / asset_name).exists():
412
- return None
413
- results.append({
414
- "goal_id": goal["id"],
415
- "ordinary_goal": goal["text"],
416
- "asset_name": asset_name,
417
- "hero_src": _asset_to_data_uri(asset_name),
418
- "origin": item.get("origin", "modal_flux_cached"),
419
- "prompt_summary": item.get("prompt_summary", _prompt_for_goal(goal, "cozy")),
420
- "runtime_metadata": item.get("runtime_metadata", manifest.get("runtime_metadata", {})),
421
- })
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"))
433
- if not base_url or not token:
434
- raise RuntimeError("APP_MODAL_BASE_URL and APP_MODAL_AUTH_TOKEN are required for Modal FLUX generation.")
435
-
436
- payload = {
437
- "items": [
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)
447
- ],
448
- "tone": tone,
449
- "height": 768,
450
- "width": 768,
451
- "steps": 4,
452
- "guidance": 1.0,
453
- }
454
- request = urllib.request.Request(
455
- base_url,
456
- data=json.dumps(payload).encode("utf-8"),
457
- headers={
458
- "Authorization": f"Bearer {token}",
459
- "Content-Type": "application/json",
460
- },
461
- method="POST",
462
- )
463
- started = time.monotonic()
464
- with urllib.request.urlopen(request, timeout=timeout) as response:
465
- body = json.loads(response.read().decode("utf-8"))
466
- latency_ms = int((time.monotonic() - started) * 1000)
467
- outputs = body.get("outputs")
468
- if not isinstance(outputs, list) or len(outputs) < len(goals):
469
- raise RuntimeError("Modal response missing generated image outputs.")
470
-
471
- metadata = {
472
- "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
473
- "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
474
- "model_runtime": "modal",
475
- "model_backend": body.get("model_backend", "modal_http"),
476
- "inference_engine": body.get("inference_engine", "diffusers"),
477
- "model_artifact_format": body.get("model_artifact_format", "safetensors"),
478
- "quantization": body.get("quantization", "bf16"),
479
- "model_id": body.get("model_id", MODEL_ID),
480
- "latency_ms": body.get("latency_ms", latency_ms),
481
- "fallback_used": bool(body.get("fallback_used", False)),
482
- }
483
-
484
- saved: list[dict[str, Any]] = []
485
- for index, goal in enumerate(goals):
486
- output = outputs[index]
487
- image_b64 = output.get("image_png_base64")
488
- if not image_b64:
489
- raise RuntimeError("Modal image output missing image_png_base64.")
490
- image_bytes = base64.b64decode(image_b64)
491
- asset_name = generated_file_name(goal, index)
492
- (ASSETS_DIR / asset_name).write_bytes(image_bytes)
493
- saved.append({
494
- "goal_id": goal["id"],
495
- "ordinary_goal": goal["text"],
496
- "asset_name": asset_name,
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
- })
504
- _write_manifest(saved, metadata)
505
- return saved, metadata
506
-
507
-
508
- def _modal_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
509
- token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
510
- if not url or not token:
511
- raise RuntimeError("Modal URL and APP_MODAL_AUTH_TOKEN are required.")
512
- request = urllib.request.Request(
513
- url,
514
- data=json.dumps(payload).encode("utf-8"),
515
- headers={
516
- "Authorization": f"Bearer {token}",
517
- "Content-Type": "application/json",
518
- },
519
- method="POST",
520
- )
521
- with urllib.request.urlopen(request, timeout=timeout) as response:
522
- return json.loads(response.read().decode("utf-8"))
523
-
524
-
525
- def _call_text_model(goals: list[dict[str, str]], model_key: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
526
- base_url = os.environ.get("EPIC_MODAL_TEXT_URL", "").strip()
527
- timeout = float(os.environ.get("EPIC_MODAL_TEXT_TIMEOUT_SECONDS", os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "300")))
528
- if not base_url:
529
- raise RuntimeError("EPIC_MODAL_TEXT_URL is required for Modal text generation.")
530
- started = time.monotonic()
531
- body = _modal_json(
532
- base_url,
533
- {
534
- "model": model_key,
535
- "items": [
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
- ],
544
- "max_new_tokens": 260,
545
- },
546
- timeout,
547
- )
548
- latency_ms = int((time.monotonic() - started) * 1000)
549
- outputs = body.get("outputs")
550
- if not isinstance(outputs, list) or len(outputs) < len(goals):
551
- raise RuntimeError(f"Modal text response missing outputs for {model_key}.")
552
- metadata = {
553
- "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
554
- "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
555
- "model_runtime": "modal",
556
- "model_backend": body.get("model_backend", "modal_http"),
557
- "inference_engine": body.get("inference_engine", "transformers"),
558
- "model_artifact_format": body.get("model_artifact_format", "safetensors"),
559
- "quantization": body.get("quantization", "bf16"),
560
- "model_id": body.get("model_id", MINICPM_MODEL_ID if model_key == "minicpm" else NEMOTRON3_MODEL_ID),
561
- "latency_ms": body.get("latency_ms", latency_ms),
562
- "fallback_used": bool(body.get("fallback_used", False)),
563
- }
564
- return outputs[: len(goals)], metadata
565
-
566
-
567
- def _call_voxcpm2(goals: list[dict[str, str]], card_texts: dict[str, dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
568
- base_url = os.environ.get("EPIC_MODAL_VOXCPM2_URL", "").strip()
569
- timeout = float(os.environ.get("EPIC_MODAL_SPEECH_TIMEOUT_SECONDS", os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "300")))
570
- if not base_url:
571
- raise RuntimeError("EPIC_MODAL_VOXCPM2_URL is required for Modal VoxCPM2 generation.")
572
- started = time.monotonic()
573
- body = _modal_json(
574
- base_url,
575
- {
576
- "items": [
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
- ],
587
- "cfg_value": 2.0,
588
- "inference_timesteps": 10,
589
- },
590
- timeout,
591
- )
592
- latency_ms = int((time.monotonic() - started) * 1000)
593
- outputs = body.get("outputs")
594
- if not isinstance(outputs, list) or len(outputs) < len(goals):
595
- raise RuntimeError("Modal VoxCPM2 response missing outputs.")
596
-
597
- saved: list[dict[str, Any]] = []
598
- metadata = {
599
- "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
600
- "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
601
- "model_runtime": "modal",
602
- "model_backend": body.get("model_backend", "modal_http"),
603
- "inference_engine": body.get("inference_engine", "voxcpm"),
604
- "model_artifact_format": body.get("model_artifact_format", "safetensors"),
605
- "quantization": body.get("quantization", "bf16"),
606
- "model_id": body.get("model_id", VOXCPM2_MODEL_ID),
607
- "latency_ms": body.get("latency_ms", latency_ms),
608
- "fallback_used": bool(body.get("fallback_used", False)),
609
- }
610
- for index, goal in enumerate(goals):
611
- output = outputs[index]
612
- audio_b64 = output.get("audio_wav_base64")
613
- if not audio_b64:
614
- raise RuntimeError("Modal VoxCPM2 output missing audio_wav_base64.")
615
- audio_bytes = base64.b64decode(audio_b64)
616
- asset_name = generated_audio_file_name(goal, index)
617
- (ASSETS_DIR / asset_name).write_bytes(audio_bytes)
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
- })
630
- return saved, metadata
631
-
632
-
633
- def _call_nemotron_speech(audio_results: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
634
- base_url = os.environ.get("EPIC_MODAL_NEMOTRON_SPEECH_URL", "").strip()
635
- timeout = float(os.environ.get("EPIC_MODAL_ASR_TIMEOUT_SECONDS", os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "300")))
636
- if not base_url:
637
- raise RuntimeError("EPIC_MODAL_NEMOTRON_SPEECH_URL is required for Modal Nemotron Speech ASR.")
638
- started = time.monotonic()
639
- body = _modal_json(
640
- base_url,
641
- {
642
- "items": [
643
- {
644
- "goal_id": item["goal_id"],
645
- "ordinary_goal": item["ordinary_goal"],
646
- "audio_wav_base64": item.get("audio_wav_base64", ""),
647
- }
648
- for item in audio_results
649
- ],
650
- },
651
- timeout,
652
- )
653
- latency_ms = int((time.monotonic() - started) * 1000)
654
- outputs = body.get("outputs")
655
- if not isinstance(outputs, list):
656
- raise RuntimeError("Modal Nemotron Speech response missing outputs.")
657
- metadata = {
658
- "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
659
- "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
660
- "model_runtime": "modal",
661
- "model_backend": body.get("model_backend", "modal_http"),
662
- "inference_engine": body.get("inference_engine", "nemo"),
663
- "model_artifact_format": body.get("model_artifact_format", "nemo_archive"),
664
- "quantization": body.get("quantization", "bf16"),
665
- "model_id": body.get("model_id", NEMOTRON_SPEECH_MODEL_ID),
666
- "latency_ms": body.get("latency_ms", latency_ms),
667
- "fallback_used": bool(body.get("fallback_used", False)),
668
- }
669
- return outputs, metadata
670
-
671
-
672
- def _quest_for_goal(
673
- goal: dict[str, str],
674
- image_result: dict[str, Any],
675
- index: int,
676
- tone: str,
677
- text_results: dict[str, dict[str, Any]] | None = None,
678
- secondary_text_results: dict[str, dict[str, Any]] | None = None,
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 {
690
- "id": f"quest-{goal['id']}",
691
- "source_goal_id": goal["id"],
692
- "ordinary_goal": goal["text"],
693
- "theme": theme,
694
- "title": str(card_text.get("title") or _quest_title(goal["text"], tone, theme)),
695
- "subtitle": f"Quest {index + 1} of 3",
696
- "hero_asset_name": image_result["asset_name"],
697
- "hero_src": image_result.get("hero_src", ""),
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,
720
- "secondary": secondary_text,
721
- },
722
- "audio_asset_name": audio.get("audio_asset_name", ""),
723
- "audio_src": audio.get("audio_src", ""),
724
- "spoken_text": audio.get("spoken_text", ""),
725
- "speech_runtime_metadata": audio.get("runtime_metadata", _runtime_metadata()),
726
- "asr_transcript": asr.get("transcript", ""),
727
- "asr_runtime_metadata": asr.get("runtime_metadata", _runtime_metadata()),
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]
785
- text_metadata: dict[str, Any] = {}
786
- if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1":
787
- try:
788
- minicpm_results, text_metadata["minicpm"] = _call_text_model(goals, "minicpm")
789
- except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
790
- text_blocker = f"MiniCPM {type(exc).__name__}: {exc}"
791
- minicpm_results = [_fallback_card_text(goal, tone, text_blocker) for goal in goals]
792
- try:
793
- nemotron3_results, text_metadata["nemotron3"] = _call_text_model(goals, "nemotron3")
794
- except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
795
- extra = f"Nemotron3 {type(exc).__name__}: {exc}"
796
- text_blocker = f"{text_blocker}; {extra}" if text_blocker else extra
797
- nemotron3_results = [_fallback_card_text(goal, tone, extra) 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:
834
- audio_blocker = f"VoxCPM2 {type(exc).__name__}: {exc}"
835
- audio_results = [_fallback_audio_result(goal, index, audio_blocker) for index, goal in enumerate(goals)]
836
-
837
- asr_results: list[dict[str, Any]] = []
838
- asr_blocker = ""
839
- if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1" and any(item.get("audio_wav_base64") for item in audio_results):
840
- try:
841
- asr_results, speech_metadata["nemotron_speech"] = _call_nemotron_speech(audio_results)
842
- except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
843
- asr_blocker = f"Nemotron Speech {type(exc).__name__}: {exc}"
844
-
845
- audio_by_id = {item["goal_id"]: item for item in audio_results}
846
- asr_by_id = {
847
- item["goal_id"]: {
848
- **item,
849
- "runtime_metadata": speech_metadata.get("nemotron_speech", _runtime_metadata(
850
- model_backend="modal_http",
851
- model_id=NEMOTRON_SPEECH_MODEL_ID,
852
- fallback_used=True,
853
- fallback_reason=asr_blocker or "Nemotron Speech ASR was not run.",
854
- )),
855
- }
856
- for item in asr_results
857
- if isinstance(item, dict) and item.get("goal_id")
858
- }
859
-
860
- quests = [
861
- _quest_for_goal(
862
- goal,
863
- images[index],
864
- index,
865
- tone,
866
- text_results=minicpm_by_id,
867
- secondary_text_results=nemotron3_by_id,
868
- audio_results=audio_by_id,
869
- asr_results=asr_by_id,
870
- )
871
- for index, goal in enumerate(goals)
872
- ]
873
- any_text_fallback = any(
874
- bool(item.get("runtime_metadata", {}).get("fallback_used", False))
875
- or bool(item.get("validation_error"))
876
- for item in [*minicpm_results, *nemotron3_results]
877
- if isinstance(item, dict)
878
- )
879
- any_audio_fallback = any(
880
- bool(item.get("runtime_metadata", {}).get("fallback_used", False))
881
- for item in audio_results
882
- if isinstance(item, dict)
883
- )
884
- any_asr_fallback = bool(asr_blocker) or (
885
- os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1"
886
- and bool(audio_results)
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,
900
- "text": text_metadata,
901
- "speech": speech_metadata,
902
- },
903
- "fallback_used": (
904
- any(bool(quest["runtime_metadata"].get("fallback_used", True)) for quest in quests)
905
- or any_text_fallback
906
- or any_audio_fallback
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
-
914
- def build_initial_quest_pack() -> dict[str, Any]:
915
- return build_quest_pack({"goals": default_goal_payload(), "tone": "cozy"}, use_cache=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
epic_errands_space/render.py DELETED
@@ -1,983 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import json
5
- import mimetypes
6
- from pathlib import Path
7
- from typing import Any
8
-
9
-
10
- FRONTEND_ROOT = Path(__file__).resolve().parents[1] / "frontend"
11
- ASSETS_DIR = FRONTEND_ROOT / "assets"
12
-
13
- STYLE_MODES: dict[str, dict[str, str]] = {
14
- "classroom": {
15
- "label": "Classroom",
16
- "mark": "CL",
17
- "goal_noun": "Quest",
18
- "parent_title": "Classroom Captain",
19
- "kid_title": "Star Student",
20
- "reward_noun": "badge",
21
- "reward": "Helper badge",
22
- "waiting": "Waiting for Classroom Captain approval.",
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": {
39
- "label": "Comic",
40
- "mark": "CM",
41
- "goal_noun": "Mission",
42
- "parent_title": "Comic Editor",
43
- "kid_title": "Panel Hero",
44
- "reward_noun": "starburst",
45
- "reward": "Pow starburst",
46
- "waiting": "Waiting for Comic Editor approval.",
47
- "copy": "Bright panels, short action copy, and punchy kid-safe momentum.",
48
- "image": "generated-hero-3-put-away-laundry-083482db.png",
49
- },
50
- }
51
-
52
- INITIAL_GOALS: list[dict[str, str]] = [
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
- },
61
- {
62
- "id": "goal-2",
63
- "ordinary_goal": "Read for 20 minutes",
64
- "title": "Quest: Reading Lantern",
65
- "style_id": "classroom",
66
- "kid_completion_state": "in_progress",
67
- "parent_reward_state": "not_reviewed",
68
- },
69
- {
70
- "id": "goal-3",
71
- "ordinary_goal": "Put away laundry",
72
- "title": "Mission: Laundry Launch",
73
- "style_id": "comic",
74
- "kid_completion_state": "not_started",
75
- "parent_reward_state": "not_reviewed",
76
- },
77
- ]
78
-
79
-
80
- def _asset_data_uri(file_name: str) -> str:
81
- path = ASSETS_DIR / file_name
82
- if not path.exists():
83
- return ""
84
- mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
85
- encoded = base64.b64encode(path.read_bytes()).decode("ascii")
86
- return f"data:{mime_type};base64,{encoded}"
87
-
88
-
89
- def _initial_state() -> dict[str, Any]:
90
- images = {
91
- style_id: _asset_data_uri(style["image"])
92
- for style_id, style in STYLE_MODES.items()
93
- }
94
- return {
95
- "activeTab": "home",
96
- "defaultStyleId": "questbook",
97
- "selectedGoalId": "goal-1",
98
- "parentName": "Maya",
99
- "kidName": "Ari",
100
- "avatarPreviews": {"parent": "", "kid": ""},
101
- "styles": STYLE_MODES,
102
- "images": images,
103
- "goals": INITIAL_GOALS,
104
- }
105
-
106
-
107
- def build_app_island() -> str:
108
- return """
109
- <main class="ee-root" data-epic-root aria-label="Epic Errands V1 mobile review prototype">
110
- <section class="ee-shell" data-design-id="mobile-app-shell">
111
- <p class="ee-loading">Loading Epic Errands...</p>
112
- </section>
113
- </main>
114
- """
115
-
116
-
117
- def build_app_css() -> str:
118
- return """
119
- .ee-root {
120
- --ink: #172033;
121
- --muted: #5b6678;
122
- --line: rgba(23, 32, 51, 0.15);
123
- --paper: #fffdf8;
124
- --surface: #ffffff;
125
- --soft: #f2f6fb;
126
- --theme: #7652c7;
127
- --theme-2: #0f877e;
128
- --theme-3: #efb84a;
129
- --theme-soft: #f2eaff;
130
- --reward: #9b6500;
131
- width: 100%;
132
- max-width: 448px;
133
- margin: 0 auto;
134
- padding: 12px;
135
- color: var(--ink);
136
- background: #e9f1fb;
137
- font-family: ui-rounded, "Arial Rounded MT Bold", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
138
- }
139
-
140
- .ee-root,
141
- .ee-root * {
142
- box-sizing: border-box;
143
- }
144
-
145
- .ee-root button,
146
- .ee-root input {
147
- font: inherit;
148
- }
149
-
150
- .ee-root button {
151
- min-height: 44px;
152
- border: 0;
153
- cursor: pointer;
154
- }
155
-
156
- .ee-root button:focus-visible,
157
- .ee-root input:focus-visible {
158
- outline: 3px solid rgba(28, 101, 242, 0.45);
159
- outline-offset: 3px;
160
- }
161
-
162
- .ee-shell {
163
- overflow: clip;
164
- border: 1px solid var(--line);
165
- border-radius: 22px;
166
- background: var(--paper);
167
- box-shadow: 0 14px 36px rgba(23, 32, 51, 0.14);
168
- }
169
-
170
- .ee-shell[data-style="classroom"] {
171
- --theme: #c8332d;
172
- --theme-2: #135f9d;
173
- --theme-3: #f7c948;
174
- --theme-soft: #fff2cf;
175
- --reward: #11784f;
176
- }
177
-
178
- .ee-shell[data-style="questbook"] {
179
- --theme: #7652c7;
180
- --theme-2: #0f877e;
181
- --theme-3: #efb84a;
182
- --theme-soft: #f2eaff;
183
- --reward: #9b6500;
184
- }
185
-
186
- .ee-shell[data-style="comic"] {
187
- --theme: #136bd9;
188
- --theme-2: #c92754;
189
- --theme-3: #ffd33d;
190
- --theme-soft: #fff3a8;
191
- --reward: #c34220;
192
- }
193
-
194
- .ee-top {
195
- display: grid;
196
- gap: 12px;
197
- padding: 18px 16px 12px;
198
- background: linear-gradient(135deg, var(--theme-soft), #ffffff 72%);
199
- border-bottom: 1px solid var(--line);
200
- }
201
-
202
- .ee-brand-row,
203
- .ee-title-row,
204
- .ee-chip-row,
205
- .ee-name-row,
206
- .ee-card-action-row {
207
- display: flex;
208
- align-items: center;
209
- gap: 10px;
210
- }
211
-
212
- .ee-brand-row,
213
- .ee-title-row {
214
- justify-content: space-between;
215
- }
216
-
217
- .ee-kicker,
218
- .ee-section-label {
219
- margin: 0;
220
- color: var(--theme-2);
221
- font-size: 12px;
222
- font-weight: 900;
223
- letter-spacing: 0;
224
- text-transform: uppercase;
225
- }
226
-
227
- .ee-brand {
228
- margin: 0;
229
- font-size: 30px;
230
- line-height: 0.95;
231
- font-weight: 950;
232
- }
233
-
234
- .ee-pill,
235
- .ee-chip {
236
- display: inline-flex;
237
- align-items: center;
238
- justify-content: center;
239
- min-height: 28px;
240
- padding: 0 9px;
241
- border-radius: 999px;
242
- background: #ffffff;
243
- color: var(--theme);
244
- border: 1px solid var(--line);
245
- font-size: 12px;
246
- font-weight: 900;
247
- white-space: nowrap;
248
- }
249
-
250
- .ee-chip {
251
- min-height: 24px;
252
- background: var(--theme-soft);
253
- font-size: 11px;
254
- }
255
-
256
- .ee-chip[data-tone="waiting"] {
257
- background: #fff3d0;
258
- color: #805100;
259
- }
260
-
261
- .ee-chip[data-tone="success"] {
262
- background: #e6f7ee;
263
- color: #0b7048;
264
- }
265
-
266
- .ee-tabs {
267
- display: grid;
268
- grid-template-columns: repeat(4, 1fr);
269
- gap: 6px;
270
- }
271
-
272
- .ee-tab {
273
- display: grid;
274
- align-content: center;
275
- gap: 2px;
276
- min-width: 0;
277
- padding: 8px 4px;
278
- border-radius: 12px;
279
- background: rgba(255, 255, 255, 0.72);
280
- color: var(--muted);
281
- font-size: 12px;
282
- font-weight: 900;
283
- }
284
-
285
- .ee-tab[aria-current="page"] {
286
- background: var(--theme);
287
- color: #ffffff;
288
- }
289
-
290
- .ee-tab[aria-disabled="true"] {
291
- opacity: 0.48;
292
- cursor: not-allowed;
293
- }
294
-
295
- .ee-tab-mark {
296
- font-size: 13px;
297
- }
298
-
299
- .ee-body {
300
- display: grid;
301
- gap: 14px;
302
- padding: 16px;
303
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0)), #f7f9fc;
304
- }
305
-
306
- .ee-screen,
307
- .ee-stack {
308
- display: grid;
309
- gap: 14px;
310
- }
311
-
312
- .ee-screen-title {
313
- margin: 0;
314
- font-size: 27px;
315
- line-height: 1;
316
- font-weight: 950;
317
- }
318
-
319
- .ee-copy {
320
- margin: 4px 0 0;
321
- color: var(--muted);
322
- font-size: 14px;
323
- font-weight: 750;
324
- line-height: 1.36;
325
- }
326
-
327
- .ee-panel,
328
- .ee-goal-row,
329
- .ee-goal-card,
330
- .ee-kid-tile,
331
- .ee-avatar {
332
- border: 1px solid var(--line);
333
- border-radius: 16px;
334
- background: rgba(255, 255, 255, 0.94);
335
- box-shadow: 0 7px 18px rgba(23, 32, 51, 0.08);
336
- }
337
-
338
- .ee-panel {
339
- display: grid;
340
- gap: 10px;
341
- padding: 13px;
342
- }
343
-
344
- .ee-style-list {
345
- display: grid;
346
- gap: 8px;
347
- }
348
-
349
- .ee-style-button {
350
- display: grid;
351
- grid-template-columns: 42px 1fr auto;
352
- align-items: center;
353
- gap: 10px;
354
- width: 100%;
355
- padding: 10px;
356
- border: 1px solid var(--line);
357
- border-radius: 14px;
358
- background: #ffffff;
359
- color: var(--ink);
360
- text-align: left;
361
- }
362
-
363
- .ee-style-button[aria-pressed="true"] {
364
- border-color: var(--theme);
365
- background: linear-gradient(135deg, var(--theme-soft), #ffffff);
366
- }
367
-
368
- .ee-mark,
369
- .ee-avatar-mark {
370
- display: grid;
371
- width: 42px;
372
- height: 42px;
373
- place-items: center;
374
- border-radius: 13px;
375
- background: var(--theme-soft);
376
- color: var(--theme);
377
- font-size: 13px;
378
- font-weight: 950;
379
- }
380
-
381
- .ee-style-name,
382
- .ee-goal-name,
383
- .ee-tile-title {
384
- display: block;
385
- font-size: 16px;
386
- line-height: 1.15;
387
- font-weight: 950;
388
- }
389
-
390
- .ee-style-meta,
391
- .ee-goal-meta,
392
- .ee-small {
393
- display: block;
394
- color: var(--muted);
395
- font-size: 12px;
396
- font-weight: 750;
397
- line-height: 1.3;
398
- }
399
-
400
- .ee-dot {
401
- width: 17px;
402
- height: 17px;
403
- border: 2px solid var(--theme);
404
- border-radius: 50%;
405
- }
406
-
407
- .ee-style-button[aria-pressed="true"] .ee-dot {
408
- background: radial-gradient(circle, var(--theme) 45%, transparent 49%);
409
- }
410
-
411
- .ee-avatar-grid,
412
- .ee-counter-grid,
413
- .ee-kid-grid {
414
- display: grid;
415
- grid-template-columns: 1fr 1fr;
416
- gap: 10px;
417
- }
418
-
419
- .ee-avatar {
420
- display: grid;
421
- gap: 10px;
422
- padding: 10px;
423
- background: linear-gradient(135deg, var(--theme-soft), #ffffff);
424
- }
425
-
426
- .ee-avatar-photo {
427
- width: 54px;
428
- height: 54px;
429
- object-fit: cover;
430
- border-radius: 18px;
431
- border: 3px solid #ffffff;
432
- box-shadow: 0 5px 15px rgba(23, 32, 51, 0.12);
433
- }
434
-
435
- .ee-name-input {
436
- width: 100%;
437
- min-height: 40px;
438
- border: 1px solid var(--line);
439
- border-radius: 12px;
440
- padding: 0 10px;
441
- color: var(--ink);
442
- background: #ffffff;
443
- font-weight: 850;
444
- }
445
-
446
- .ee-file {
447
- width: 100%;
448
- min-height: 38px;
449
- font-size: 12px;
450
- }
451
-
452
- .ee-privacy {
453
- margin: 0;
454
- color: #536273;
455
- font-size: 12px;
456
- font-weight: 750;
457
- line-height: 1.35;
458
- }
459
-
460
- .ee-counter {
461
- display: grid;
462
- align-content: center;
463
- min-height: 88px;
464
- padding: 12px;
465
- border: 1px solid var(--line);
466
- border-radius: 16px;
467
- background: #ffffff;
468
- }
469
-
470
- .ee-counter strong {
471
- color: var(--theme);
472
- font-size: 34px;
473
- line-height: 0.9;
474
- }
475
-
476
- .ee-counter span {
477
- color: var(--muted);
478
- font-size: 12px;
479
- font-weight: 850;
480
- }
481
-
482
- .ee-goal-list {
483
- display: grid;
484
- gap: 10px;
485
- }
486
-
487
- .ee-goal-row {
488
- display: grid;
489
- gap: 10px;
490
- padding: 12px;
491
- }
492
-
493
- .ee-approve-button,
494
- .ee-complete-button,
495
- .ee-audio-button {
496
- justify-self: start;
497
- min-height: 44px;
498
- padding: 0 14px;
499
- border-radius: 999px;
500
- background: var(--theme);
501
- color: #ffffff;
502
- font-weight: 950;
503
- }
504
-
505
- .ee-approve-button:disabled,
506
- .ee-audio-button:disabled,
507
- .ee-complete-button:disabled {
508
- background: #e9eef5;
509
- color: #657185;
510
- border: 1px solid var(--line);
511
- cursor: not-allowed;
512
- }
513
-
514
- .ee-kid-tile {
515
- overflow: clip;
516
- padding: 0;
517
- color: var(--ink);
518
- text-align: left;
519
- }
520
-
521
- .ee-kid-tile[aria-pressed="true"] {
522
- border-color: var(--theme);
523
- }
524
-
525
- .ee-kid-tile img,
526
- .ee-card-image {
527
- display: block;
528
- width: 100%;
529
- background: var(--theme-soft);
530
- object-fit: cover;
531
- }
532
-
533
- .ee-kid-tile img {
534
- height: 96px;
535
- }
536
-
537
- .ee-tile-body {
538
- display: grid;
539
- gap: 6px;
540
- padding: 10px;
541
- }
542
-
543
- .ee-goal-card {
544
- overflow: clip;
545
- }
546
-
547
- .ee-card-image {
548
- height: 205px;
549
- }
550
-
551
- .ee-card-body {
552
- display: grid;
553
- gap: 11px;
554
- padding: 13px;
555
- }
556
-
557
- .ee-card-body h2 {
558
- margin: 0;
559
- font-size: 26px;
560
- line-height: 1;
561
- }
562
-
563
- .ee-step-list {
564
- display: grid;
565
- gap: 8px;
566
- margin: 0;
567
- padding: 0;
568
- list-style: none;
569
- }
570
-
571
- .ee-step-list li {
572
- display: grid;
573
- grid-template-columns: 30px 1fr;
574
- gap: 8px;
575
- align-items: center;
576
- min-height: 40px;
577
- padding: 7px 9px;
578
- border-radius: 13px;
579
- background: var(--soft);
580
- font-size: 13px;
581
- font-weight: 850;
582
- }
583
-
584
- .ee-step-list b {
585
- display: grid;
586
- width: 30px;
587
- height: 30px;
588
- place-items: center;
589
- border-radius: 999px;
590
- background: var(--theme-soft);
591
- color: var(--theme);
592
- }
593
-
594
- .ee-status-panel {
595
- display: grid;
596
- gap: 4px;
597
- padding: 11px;
598
- border-radius: 14px;
599
- border: 1px solid var(--line);
600
- background: #fff4d7;
601
- color: #704800;
602
- font-size: 13px;
603
- font-weight: 800;
604
- }
605
-
606
- .ee-status-panel[data-tone="success"] {
607
- background: #e7f8ef;
608
- color: #0d5c3b;
609
- }
610
-
611
- .ee-loading {
612
- margin: 0;
613
- padding: 18px;
614
- color: var(--muted);
615
- font-weight: 850;
616
- }
617
-
618
- @media (max-width: 380px) {
619
- .ee-root {
620
- padding: 8px;
621
- }
622
-
623
- .ee-body {
624
- padding: 12px;
625
- }
626
-
627
- .ee-brand {
628
- font-size: 27px;
629
- }
630
-
631
- .ee-screen-title {
632
- font-size: 24px;
633
- }
634
-
635
- .ee-avatar-grid,
636
- .ee-kid-grid {
637
- grid-template-columns: 1fr;
638
- }
639
- }
640
- """
641
-
642
-
643
- def build_app_head() -> str:
644
- return """
645
- <meta name="viewport" content="width=device-width, initial-scale=1">
646
- """
647
-
648
-
649
- def build_app_bootstrap_js(asset_base: str = "/file=frontend/assets/") -> str:
650
- del asset_base
651
- state_json = json.dumps(_initial_state())
652
- return (
653
- r"""
654
- const root = element.querySelector("[data-epic-root]");
655
- if (root && root.dataset.ready !== "true") {
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
-
673
- function escapeHtml(value) {
674
- return String(value)
675
- .replace(/&/g, "&amp;")
676
- .replace(/</g, "&lt;")
677
- .replace(/>/g, "&gt;")
678
- .replace(/"/g, "&quot;")
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() {
698
- return state.goals.filter((goal) => goal.parent_reward_state === "waiting_for_approval").length;
699
- }
700
-
701
- function stateChip(goal) {
702
- if (goal.parent_reward_state === "approved_reward_given") {
703
- return '<span class="ee-chip" data-tone="success">Reward given</span>';
704
- }
705
- if (goal.kid_completion_state === "completed") {
706
- return '<span class="ee-chip" data-tone="waiting">Awaiting parent</span>';
707
- }
708
- if (goal.kid_completion_state === "in_progress") {
709
- return '<span class="ee-chip">In progress</span>';
710
- }
711
- return '<span class="ee-chip">Ready</span>';
712
- }
713
-
714
- function renderTabs() {
715
- const tabs = [
716
- ["home", "Home", "HM"],
717
- ["parent", "Parent", "PA"],
718
- ["kid", "Kid", "KD"],
719
- ["diy", "DIY Lab", "DIY"],
720
- ];
721
- return `<nav class="ee-tabs" data-design-id="mobile-tab-nav" aria-label="Epic Errands tabs">
722
- ${tabs.map(([id, label, mark]) => {
723
- const disabled = id === "diy";
724
- return `<button class="ee-tab" data-action="tab" data-tab="${id}" ${state.activeTab === id ? 'aria-current="page"' : ""} ${disabled ? 'aria-disabled="true" data-design-id="diy-lab-nav" title="DIY Lab is planned for later."' : ""}>
725
- <span class="ee-tab-mark">${mark}</span>
726
- <span>${label}</span>
727
- </button>`;
728
- }).join("")}
729
- </nav>`;
730
- }
731
-
732
- function renderHeader() {
733
- const style = shellStyle();
734
- return `<header class="ee-top">
735
- <div class="ee-brand-row">
736
- <div>
737
- <p class="ee-kicker">V1 mobile review</p>
738
- <h1 class="ee-brand">Epic Errands</h1>
739
- </div>
740
- <span class="ee-pill">${escapeHtml(style.label)}</span>
741
- </div>
742
- ${renderTabs()}
743
- </header>`;
744
- }
745
-
746
- function renderHome() {
747
- const style = shellStyle();
748
- return `<section class="ee-screen" data-design-id="home-screen" aria-labelledby="home-title">
749
- <div>
750
- <h2 class="ee-screen-title" id="home-title">Choose today's style</h2>
751
- <p class="ee-copy">${escapeHtml(state.parentName)} is ${escapeHtml(style.parent_title)}. ${escapeHtml(state.kidName)} is ${escapeHtml(style.kid_title)}.</p>
752
- </div>
753
-
754
- <section class="ee-panel" data-design-id="style-mode-picker" aria-labelledby="style-label">
755
- <p class="ee-section-label" id="style-label">Goal style</p>
756
- <div class="ee-style-list">
757
- ${Object.entries(state.styles).map(([id, item]) => `<button class="ee-style-button" data-action="style" data-style="${id}" aria-pressed="${state.defaultStyleId === id}">
758
- <span class="ee-mark">${escapeHtml(item.mark)}</span>
759
- <span>
760
- <span class="ee-style-name">${escapeHtml(item.label)}</span>
761
- <span class="ee-style-meta">${escapeHtml(item.goal_noun)} / ${escapeHtml(item.reward_noun)}. ${escapeHtml(item.copy)}</span>
762
- </span>
763
- <span class="ee-dot" aria-hidden="true"></span>
764
- </button>`).join("")}
765
- </div>
766
- </section>
767
-
768
- <section class="ee-panel" data-design-id="avatar-creator" aria-labelledby="avatar-label">
769
- <p class="ee-section-label" id="avatar-label">Avatars</p>
770
- <div class="ee-avatar-grid">
771
- ${renderAvatar("parent", state.parentName, style.parent_title)}
772
- ${renderAvatar("kid", state.kidName, style.kid_title)}
773
- </div>
774
- <p class="ee-privacy">Photo previews are session-only in this prototype. Nothing is persisted.</p>
775
- </section>
776
-
777
- <section class="ee-counter-grid" data-design-id="active-goal-count" aria-label="Goal counts">
778
- <div class="ee-counter"><strong>${state.goals.length}</strong><span>active goals</span></div>
779
- <div class="ee-counter"><strong>${awaitingApprovalCount()}</strong><span>awaiting approval</span></div>
780
- </section>
781
- </section>`;
782
- }
783
-
784
- function renderAvatar(role, name, title) {
785
- const preview = state.avatarPreviews[role];
786
- const label = role === "parent" ? "Parent" : "Kid";
787
- return `<div class="ee-avatar">
788
- <div class="ee-name-row">
789
- ${preview ? `<img class="ee-avatar-photo" src="${preview}" alt="${label} avatar preview">` : `<span class="ee-avatar-mark">${role === "parent" ? "PA" : "KD"}</span>`}
790
- <div>
791
- <span class="ee-style-name">${escapeHtml(label)}</span>
792
- <span class="ee-small">${escapeHtml(title)}</span>
793
- </div>
794
- </div>
795
- <label class="ee-small">${label} name
796
- <input class="ee-name-input" data-action="name" data-role="${role}" value="${escapeHtml(name)}" autocomplete="off">
797
- </label>
798
- <input class="ee-file" data-action="avatar" data-role="${role}" type="file" accept="image/*" aria-label="Upload ${label} photo">
799
- </div>`;
800
- }
801
-
802
- function renderParent() {
803
- return `<section class="ee-screen" data-design-id="parent-goals-screen" aria-labelledby="parent-title">
804
- <div>
805
- <h2 class="ee-screen-title" id="parent-title">Parent Goals</h2>
806
- <p class="ee-copy">Approve completed goals and grant the reward. Everything else is coordinated offline.</p>
807
- </div>
808
- <div class="ee-goal-list" data-design-id="parent-goal-list">
809
- ${state.goals.map((goal) => {
810
- const style = styleFor(goal);
811
- const canApprove = goal.parent_reward_state === "waiting_for_approval";
812
- const approved = goal.parent_reward_state === "approved_reward_given";
813
- return `<article class="ee-goal-row" data-goal-id="${goal.id}">
814
- <div class="ee-title-row">
815
- <span class="ee-goal-name">${escapeHtml(goal.title)}</span>
816
- ${stateChip(goal)}
817
- </div>
818
- <div class="ee-chip-row">
819
- <span class="ee-chip">${escapeHtml(style.label)}</span>
820
- <span class="ee-chip">${escapeHtml(style.reward)}</span>
821
- </div>
822
- <span class="ee-goal-meta">Ordinary goal: ${escapeHtml(goal.ordinary_goal)}</span>
823
- <button class="ee-approve-button" data-design-id="parent-approve-action" data-action="approve" data-goal-id="${goal.id}" ${canApprove ? "" : "disabled"}>${approved ? "Approved" : "Approve reward"}</button>
824
- </article>`;
825
- }).join("")}
826
- </div>
827
- </section>`;
828
- }
829
-
830
- function renderKid() {
831
- const selected = state.goals.find((goal) => goal.id === state.selectedGoalId) || state.goals[0];
832
- return `<section class="ee-screen" data-design-id="kid-goals-screen" aria-labelledby="kid-title">
833
- <div>
834
- <h2 class="ee-screen-title" id="kid-title">Kid Goals</h2>
835
- <p class="ee-copy">Tap a goal, open the card, mark it complete, then wait for your grown-up's themed approval.</p>
836
- </div>
837
- <div class="ee-kid-grid" data-design-id="kid-goal-thumbnail-grid">
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)}
845
- </span>
846
- </button>`;
847
- }).join("")}
848
- </div>
849
- ${renderGoalCard(selected)}
850
- </section>`;
851
- }
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>
864
- <h2>${escapeHtml(goal.title)}</h2>
865
- <p class="ee-copy">${escapeHtml(style.copy)}</p>
866
- </div>
867
- <ol class="ee-step-list">
868
- ${steps.map((step, index) => `<li><b>${index + 1}</b><span>${escapeHtml(step)}</span></li>`).join("")}
869
- </ol>
870
- <div class="ee-card-action-row">
871
- <button class="ee-audio-button" data-design-id="audio-play-hook" disabled>Audio in V1.1</button>
872
- <button class="ee-complete-button" data-action="complete" data-goal-id="${goal.id}" ${goal.kid_completion_state === "completed" ? "disabled" : ""}>${goal.kid_completion_state === "completed" ? "Completed" : `Mark ${style.goal_noun} complete`}</button>
873
- </div>
874
- ${waiting ? `<div class="ee-status-panel" data-design-id="waiting-for-parent-approval"><strong>${escapeHtml(style.waiting)}</strong><span>Your ${escapeHtml(style.reward_noun)} unlocks when ${escapeHtml(style.parent_title)} approves.</span></div>` : ""}
875
- ${rewarded ? `<div class="ee-status-panel" data-tone="success"><strong>${escapeHtml(style.reward)} earned!</strong><span>${escapeHtml(style.parent_title)} approved this goal.</span></div>` : ""}
876
- </div>
877
- </article>`;
878
- }
879
-
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
-
887
- root.addEventListener("click", (event) => {
888
- const target = event.target.closest("[data-action]");
889
- if (!target) return;
890
-
891
- if (target.dataset.action === "tab") {
892
- if (target.getAttribute("aria-disabled") === "true") return;
893
- state.activeTab = target.dataset.tab || "home";
894
- render();
895
- return;
896
- }
897
-
898
- if (target.dataset.action === "style") {
899
- state.defaultStyleId = normalizeStyleId(target.dataset.style || state.defaultStyleId);
900
- render();
901
- return;
902
- }
903
-
904
- if (target.dataset.action === "select-goal") {
905
- state.selectedGoalId = target.dataset.goalId || state.selectedGoalId;
906
- render();
907
- return;
908
- }
909
-
910
- if (target.dataset.action === "complete") {
911
- const goal = state.goals.find((item) => item.id === target.dataset.goalId);
912
- if (goal) {
913
- goal.kid_completion_state = "completed";
914
- if (goal.parent_reward_state !== "approved_reward_given") {
915
- goal.parent_reward_state = "waiting_for_approval";
916
- }
917
- render();
918
- }
919
- return;
920
- }
921
-
922
- if (target.dataset.action === "approve") {
923
- const goal = state.goals.find((item) => item.id === target.dataset.goalId);
924
- if (goal && goal.kid_completion_state === "completed") {
925
- goal.parent_reward_state = "approved_reward_given";
926
- render();
927
- }
928
- }
929
- });
930
-
931
- root.addEventListener("input", (event) => {
932
- const target = event.target;
933
- if (!target || target.dataset.action !== "name") return;
934
- if (target.dataset.role === "parent") {
935
- state.parentName = target.value || "Parent";
936
- } else if (target.dataset.role === "kid") {
937
- state.kidName = target.value || "Kid";
938
- }
939
- });
940
-
941
- root.addEventListener("change", (event) => {
942
- const target = event.target;
943
- if (!target || target.dataset.action !== "avatar") return;
944
- const file = target.files && target.files[0];
945
- if (!file) return;
946
- const role = target.dataset.role;
947
- if (state.avatarPreviews[role]) {
948
- URL.revokeObjectURL(state.avatarPreviews[role]);
949
- }
950
- state.avatarPreviews[role] = URL.createObjectURL(file);
951
- render();
952
- });
953
-
954
- render();
955
- }
956
- """
957
- ).replace("__STATE_JSON__", state_json)
958
-
959
-
960
- def build_app_document(asset_base: str = "/file=frontend/assets/") -> str:
961
- del asset_base
962
- return f"""<!doctype html>
963
- <html lang="en">
964
- <head>
965
- <meta charset="utf-8">
966
- <meta name="viewport" content="width=device-width, initial-scale=1">
967
- <title>Epic Errands V1 - Mobile Review Prototype</title>
968
- <style>
969
- body {{
970
- margin: 0;
971
- background: #e9f1fb;
972
- }}
973
- {build_app_css()}
974
- </style>
975
- </head>
976
- <body>
977
- {build_app_island()}
978
- <script>
979
- const element = document;
980
- {build_app_bootstrap_js()}
981
- </script>
982
- </body>
983
- </html>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/assets/demo-placeholders/placeholder-boy-720.png DELETED

Git LFS Details

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

Git LFS Details

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

Git LFS Details

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

Git LFS Details

  • SHA256: ba2b250f01fedc514b0e66f08b2dc4f97426b76eea8170852943bec2164dd316
  • Pointer size: 131 Bytes
  • Size of remote file: 737 kB
frontend/assets/generated-audio-1-clean-up-my-room-before-dinner-56693c13.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:52b5cef22f4556f7cb181a6dec7383f5df848cf7bb4d8b97780a0ffe8d91682b
3
- size 445484
 
 
 
 
frontend/assets/generated-audio-2-finish-my-class-project-outline-62a0674b.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:d91ef4c89158b69585ce0d457a784fd9664e0c5b1f8889159678d9e9259fd69b
3
- size 568364
 
 
 
 
frontend/assets/generated-audio-3-read-for-20-minutes-5e944b16.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:425f6bc70db9b75fdd6b4c0209737ea59181dfd72fd50fc6a1cea249f764490b
3
- size 537644
 
 
 
 
frontend/assets/generated-hero-1-clean-up-my-room-before-dinner-07b300bc.png DELETED

Git LFS Details

  • SHA256: 5f0b9c1f82957d455bc48b6a282d9301cc7ef0e06952f8acd9c2d2443c10722c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
frontend/assets/generated-hero-1-finish-class-project-8f64eeb3.png DELETED

Git LFS Details

  • SHA256: 3820603f8b0dc3f2727d7cc7273f317a1b01a8a5c4424b38cfc57172d02cba58
  • Pointer size: 132 Bytes
  • Size of remote file: 1.08 MB
frontend/assets/generated-hero-2-finish-my-class-project-outline-4a8a06bc.png DELETED

Git LFS Details

  • SHA256: ac4265cbb71302d549e104b928ae654c2efe5a53d3f41fc0585535399cb11975
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
frontend/assets/generated-hero-2-read-for-20-minutes-895e7642.png DELETED

Git LFS Details

  • SHA256: 090929d75ecebebcb394032ae2424a09859e3bbde2aa064675bb50e32742ba40
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
frontend/assets/generated-hero-3-put-away-laundry-083482db.png DELETED

Git LFS Details

  • SHA256: 4ae5704e5d461a0072d81f462745efab4b085e5ff91d521e3325fd9575488519
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
frontend/assets/generated-hero-3-read-for-20-minutes-3781199a.png DELETED

Git LFS Details

  • SHA256: 08a72b2e8461a36a8ceda90cd1b43535c79d8547630222c46ecf07a2253ca455
  • Pointer size: 131 Bytes
  • Size of remote file: 939 kB
frontend/assets/generated-hero-manifest.json DELETED
The diff for this file is too large to render. See raw diff
 
frontend/assets/sc1-crayon-green.png DELETED
Binary file (19.4 kB)
 
frontend/assets/sc1-pencil-yellow.png DELETED
Binary file (9.06 kB)
 
frontend/assets/sc1-photo-bridge-sketch.png DELETED
Binary file (33.5 kB)
 
frontend/assets/sc1-photo-child-bridge.png DELETED
Binary file (34.7 kB)
 
frontend/assets/sc1-photo-student-drawing.png DELETED
Binary file (33.8 kB)
 
frontend/assets/sc2-hero-illustration.png DELETED

Git LFS Details

  • SHA256: 21a60d0d2ca592a362b47fe70600ba5d2a4550a370b9da393205fcc556854a30
  • Pointer size: 131 Bytes
  • Size of remote file: 594 kB
frontend/assets/sc2-hero-scene-only.png DELETED

Git LFS Details

  • SHA256: b05b380f7a4d807d0f42f074a92ab3215ca6403ab8cfc29a6cff4bac51f48f7e
  • Pointer size: 131 Bytes
  • Size of remote file: 515 kB
frontend/assets/sc2-parent-strip.png DELETED
Binary file (36.1 kB)
 
frontend/assets/sc2-reward-sticker.png DELETED
Binary file (51.2 kB)
 
frontend/assets/source-sc1.png DELETED

Git LFS Details

  • SHA256: 5c2654f93280228a641ecf0d6d59e1c9ec4aee3f28d3d6bcb4b1daa770a3c2a3
  • Pointer size: 131 Bytes
  • Size of remote file: 799 kB
frontend/assets/source-sc2.png DELETED

Git LFS Details

  • SHA256: 784ca9e39dcfdbd9c6d5c826427aaf95659027f22eb8dfe63045061c027ba085
  • Pointer size: 132 Bytes
  • Size of remote file: 1.12 MB
frontend/index.html CHANGED
@@ -7,17 +7,17 @@
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
  <main class="stage" aria-label="Epic Errands V2">
16
  <div id="app"></div>
17
  </main>
18
  <script>
19
- window.EPIC_ASSET_BASE = "frontend/assets/";
20
  </script>
21
- <script src="frontend/scripts/app.js"></script>
22
  </body>
23
  </html>
 
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="/epic/styles/tokens.css">
11
+ <link rel="stylesheet" href="/epic/styles/components.css">
12
+ <link rel="stylesheet" href="/epic/styles/app.css">
13
  </head>
14
  <body>
15
  <main class="stage" aria-label="Epic Errands V2">
16
  <div id="app"></div>
17
  </main>
18
  <script>
19
+ window.EPIC_ASSET_BASE = "/epic/assets/";
20
  </script>
21
+ <script src="/epic/scripts/app.js"></script>
22
  </body>
23
  </html>