curieous commited on
Commit
623d0ca
·
verified ·
1 Parent(s): 5f67324

Deploy Epic Errands V1 private Space

Browse files
.gitattributes CHANGED
@@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ frontend/assets/generated-hero-1-finish-class-project-8f64eeb3.png filter=lfs diff=lfs merge=lfs -text
37
+ frontend/assets/generated-hero-2-read-for-20-minutes-895e7642.png filter=lfs diff=lfs merge=lfs -text
38
+ frontend/assets/generated-hero-3-put-away-laundry-083482db.png filter=lfs diff=lfs merge=lfs -text
39
+ frontend/assets/sc2-hero-illustration.png filter=lfs diff=lfs merge=lfs -text
40
+ frontend/assets/sc2-hero-scene-clean.png filter=lfs diff=lfs merge=lfs -text
41
+ frontend/assets/sc2-hero-scene-only.png filter=lfs diff=lfs merge=lfs -text
42
+ frontend/assets/source-sc1.png filter=lfs diff=lfs merge=lfs -text
43
+ frontend/assets/source-sc2.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,77 @@
1
  ---
2
- title: Epic Errands
3
- emoji: 📚
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Epic Errands V1 Mobile Review
3
+ emoji: 🧭
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.18.0
 
8
  app_file: app.py
9
  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
+ - Classroom, Magical, and Comic style switching.
24
+ - Theme-based parent/kid titles such as Chief Wizard and Junior Wizard.
25
+ - Kid completion state separated from parent approval/reward state.
26
+ - Parent approve-only reward flow.
27
+ - Disabled V1.1 audio hook.
28
+ - Session-only avatar note.
29
+
30
+ ## Runtime Honesty
31
+
32
+ Current review-prototype package mode:
33
+
34
+ ```text
35
+ lifecycle_stage=testing
36
+ app_host=local or hf_space
37
+ model_runtime=none
38
+ model_backend=static_review_asset
39
+ inference_engine=none
40
+ model_artifact_format=png
41
+ quantization=none
42
+ model_id=none
43
+ fallback_used=true
44
+ ```
45
+
46
+ The visible hero images are cached review assets. This Space does not claim live
47
+ image generation, generated audio, sponsor-model proof, or judge-ready status.
48
+
49
+ Future live image generation may use:
50
+
51
+ ```text
52
+ APP_MODAL_BASE_URL
53
+ APP_MODAL_AUTH_TOKEN
54
+ APP_MODAL_TIMEOUT_SECONDS
55
+ ```
56
+
57
+ Secret values must be configured as HF Space secrets/variables and must never
58
+ be committed.
59
+
60
+ ## Local Run
61
+
62
+ ```bash
63
+ python3 app.py
64
+ ```
65
+
66
+ Main routes:
67
+
68
+ - `/`: custom mobile app shell.
69
+ - `/frontend/...`: static CSS and JS.
70
+ - `/assets/...`: static review images.
71
+ - `/health`: hosted smoke endpoint.
72
+ - `/api/bootstrap` and `/api/quest-pack`: lightweight JSON endpoints with
73
+ inline image data stripped.
74
+
75
+ Future FLUX, HF Inference, Modal, or other image-generation paths should be
76
+ added behind `epic_errands_space/adapters.py` and must keep provenance visible
77
+ in the UI.
app.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastapi import Body
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from gradio import Server
11
+
12
+ from epic_errands_space.quest_generation import (
13
+ build_initial_quest_pack,
14
+ build_quest_pack,
15
+ )
16
+
17
+
18
+ ROOT = Path(__file__).resolve().parent
19
+ FRONTEND_ROOT = ROOT / "frontend"
20
+ ASSETS_ROOT = FRONTEND_ROOT / "assets"
21
+
22
+
23
+ def build_server() -> Server:
24
+ app = Server(
25
+ title="Epic Errands",
26
+ description="A fully-custom mobile UI served by a Gradio Server.",
27
+ docs_url=None,
28
+ redoc_url=None,
29
+ )
30
+
31
+ app.mount("/frontend", StaticFiles(directory=FRONTEND_ROOT), name="frontend")
32
+ app.mount("/assets", StaticFiles(directory=ASSETS_ROOT), name="assets")
33
+
34
+ @app.get("/")
35
+ async def homepage() -> FileResponse:
36
+ return FileResponse(FRONTEND_ROOT / "index.html")
37
+
38
+ @app.get("/health")
39
+ async def health() -> dict[str, str]:
40
+ return {
41
+ "status": "ok",
42
+ "app": "epic-errands",
43
+ "frontend": "fully-custom-ui",
44
+ }
45
+
46
+ @app.get("/api/bootstrap")
47
+ async def bootstrap() -> JSONResponse:
48
+ return JSONResponse(_strip_inline_media(build_initial_quest_pack()))
49
+
50
+ @app.post("/api/quest-pack")
51
+ async def quest_pack(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
52
+ return JSONResponse(_strip_inline_media(build_quest_pack(payload)))
53
+
54
+ @app.api(
55
+ name="generate-quest-pack",
56
+ description="Generate an Epic Errands quest pack with visible runtime provenance.",
57
+ queue=True,
58
+ api_visibility="public",
59
+ )
60
+ def generate_quest_pack(payload: dict[str, Any] | None = None) -> dict[str, Any]:
61
+ return _strip_inline_media(build_quest_pack(payload or {}))
62
+
63
+ return app
64
+
65
+
66
+ def _strip_inline_media(pack: dict[str, Any]) -> dict[str, Any]:
67
+ """Keep API payloads lightweight; images are served as static assets."""
68
+ cleaned = dict(pack)
69
+ live_generation_enabled = os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1"
70
+ runtime_metadata = cleaned.get("runtime_metadata")
71
+ if not live_generation_enabled:
72
+ runtime_metadata = _static_review_runtime_metadata()
73
+ cleaned["runtime_metadata"] = runtime_metadata
74
+ cleaned["fallback_used"] = True
75
+ cleaned["blocker"] = "Live generation is disabled for this review prototype."
76
+
77
+ cleaned["quests"] = [
78
+ _clean_quest(quest, runtime_metadata=runtime_metadata, static_review=not live_generation_enabled)
79
+ for quest in cleaned.get("quests", [])
80
+ if isinstance(quest, dict)
81
+ ]
82
+ return cleaned
83
+
84
+
85
+ def _clean_quest(
86
+ quest: dict[str, Any],
87
+ *,
88
+ runtime_metadata: Any,
89
+ static_review: bool,
90
+ ) -> dict[str, Any]:
91
+ cleaned = {key: value for key, value in quest.items() if key != "hero_src"}
92
+ if static_review:
93
+ cleaned["image_origin"] = "static_review_asset"
94
+ cleaned["runtime_metadata"] = runtime_metadata
95
+ return cleaned
96
+
97
+
98
+ def _static_review_runtime_metadata() -> dict[str, Any]:
99
+ return {
100
+ "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
101
+ "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
102
+ "model_runtime": "none",
103
+ "model_backend": "static_review_asset",
104
+ "inference_engine": "none",
105
+ "model_artifact_format": "png",
106
+ "quantization": "none",
107
+ "model_id": "none",
108
+ "latency_ms": 0,
109
+ "fallback_used": True,
110
+ }
111
+
112
+
113
+ app = build_server()
114
+
115
+
116
+ if __name__ == "__main__":
117
+ app.launch(
118
+ server_name=os.environ.get("HOST", "0.0.0.0"),
119
+ server_port=int(os.environ.get("PORT", "7860")),
120
+ footer_links=[],
121
+ show_error=True,
122
+ )
epic_errands_space/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
25
+ STEP_LIBRARY = {
26
+ "project": [
27
+ ("Plan your ideas", "edit_note"),
28
+ ("Build your project", "construction"),
29
+ ("Check your work", "search"),
30
+ ("Share your masterpiece", "chat_bubble"),
31
+ ],
32
+ "read": [
33
+ ("Find a cozy reading spot", "chair"),
34
+ ("Read the next pages", "menu_book"),
35
+ ("Collect three bright clues", "travel_explore"),
36
+ ("Tell someone the best part", "forum"),
37
+ ],
38
+ "laundry": [
39
+ ("Sort the tiny treasure piles", "inventory_2"),
40
+ ("Fold each cloth banner", "featured_seasonal_and_gifts"),
41
+ ("Deliver clothes to their homes", "home"),
42
+ ("Celebrate the tidy kingdom", "workspace_premium"),
43
+ ],
44
+ "default": [
45
+ ("Name the mission", "flag"),
46
+ ("Do the first brave bit", "bolt"),
47
+ ("Check what changed", "search"),
48
+ ("Mark the win", "workspace_premium"),
49
+ ],
50
+ }
51
+
52
+
53
+ def default_goal_payload() -> list[dict[str, str]]:
54
+ return [{"id": goal.id, "text": goal.text} for goal in DEFAULT_GOALS]
55
+
56
+
57
+ def normalize_goals(raw_goals: Any) -> list[dict[str, str]]:
58
+ goals: list[dict[str, str]] = []
59
+ if isinstance(raw_goals, list):
60
+ for index, item in enumerate(raw_goals):
61
+ if not isinstance(item, dict):
62
+ continue
63
+ text = str(item.get("text") or "").strip()
64
+ if not text:
65
+ continue
66
+ goals.append({
67
+ "id": str(item.get("id") or f"goal-{index + 1}"),
68
+ "text": text,
69
+ })
70
+ if len(goals) == 3:
71
+ break
72
+
73
+ for fallback in default_goal_payload():
74
+ if len(goals) == 3:
75
+ break
76
+ if not any(goal["text"].casefold() == fallback["text"].casefold() for goal in goals):
77
+ goals.append(fallback)
78
+
79
+ return goals[:3]
80
+
81
+
82
+ def generated_file_name(goal: dict[str, str], index: int) -> str:
83
+ slug = re.sub(r"[^a-z0-9]+", "-", goal["text"].lower()).strip("-")[:36] or f"goal-{index + 1}"
84
+ digest = hashlib.sha1(f"{goal['id']}:{goal['text']}".encode("utf-8")).hexdigest()[:8]
85
+ return f"generated-hero-{index + 1}-{slug}-{digest}.png"
86
+
87
+
88
+ def _asset_to_data_uri(file_name: str) -> str:
89
+ path = ASSETS_DIR / file_name
90
+ mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
91
+ return f"data:{mime_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
92
+
93
+
94
+ def _runtime_metadata(**overrides: Any) -> dict[str, Any]:
95
+ metadata = {
96
+ "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
97
+ "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
98
+ "model_runtime": "none",
99
+ "model_backend": "template",
100
+ "inference_engine": "none",
101
+ "model_artifact_format": "none",
102
+ "quantization": "none",
103
+ "model_id": "none",
104
+ "latency_ms": 0,
105
+ "fallback_used": True,
106
+ }
107
+ metadata.update(overrides)
108
+ return metadata
109
+
110
+
111
+ def _step_key(goal_text: str) -> str:
112
+ lowered = goal_text.lower()
113
+ if "project" in lowered or "class" in lowered:
114
+ return "project"
115
+ if "read" in lowered or "book" in lowered:
116
+ return "read"
117
+ if "laundry" in lowered or "clothes" in lowered:
118
+ return "laundry"
119
+ return "default"
120
+
121
+
122
+ def _quest_title(goal_text: str, tone: str) -> str:
123
+ lowered = goal_text.lower()
124
+ tone_word = {
125
+ "cozy": "Cozy",
126
+ "brave": "Brave",
127
+ "magical": "Magic",
128
+ "silly": "Silly",
129
+ "epic": "Epic",
130
+ }.get(tone, "Cozy")
131
+ if "project" in lowered or "class" in lowered:
132
+ return f"{tone_word} Bridge of Bright Ideas"
133
+ if "read" in lowered:
134
+ return f"{tone_word} Reading Lantern Quest"
135
+ if "laundry" in lowered or "clothes" in lowered:
136
+ return f"{tone_word} Laundry Kingdom Rescue"
137
+ return f"{tone_word} Quest for {goal_text[:28]}"
138
+
139
+
140
+ def _why_it_helps(goal_text: str) -> str:
141
+ return (
142
+ "Keeps the ordinary goal visible for parents while giving the child a "
143
+ "small, story-shaped path to finish it."
144
+ )
145
+
146
+
147
+ def _prompt_for_goal(goal: dict[str, str], tone: str) -> str:
148
+ return (
149
+ "A delightful storybook classroom adventure hero image for a children's "
150
+ "goal-planning app. Warm handcrafted illustration, cozy school supplies, "
151
+ "sunny classroom, gentle fantasy quest mood, no readable text, no UI, "
152
+ "kid-safe, expressive, polished. "
153
+ f"Tone: {tone}. Ordinary goal transformed visually: {goal['text']}."
154
+ )
155
+
156
+
157
+ def _fallback_image_result(goal: dict[str, str], index: int, reason: str) -> dict[str, Any]:
158
+ return {
159
+ "goal_id": goal["id"],
160
+ "ordinary_goal": goal["text"],
161
+ "asset_name": FALLBACK_HERO,
162
+ "hero_src": _asset_to_data_uri(FALLBACK_HERO),
163
+ "origin": "deterministic_fallback",
164
+ "prompt_summary": f"Fallback classroom adventure image for goal: {goal['text']}",
165
+ "runtime_metadata": _runtime_metadata(
166
+ fallback_used=True,
167
+ fallback_reason=reason,
168
+ ),
169
+ }
170
+
171
+
172
+ def _load_manifest() -> dict[str, Any]:
173
+ try:
174
+ return json.loads(CACHE_MANIFEST.read_text(encoding="utf-8"))
175
+ except (OSError, json.JSONDecodeError):
176
+ return {"items": []}
177
+
178
+
179
+ def _write_manifest(items: list[dict[str, Any]], metadata: dict[str, Any]) -> None:
180
+ CACHE_MANIFEST.write_text(
181
+ json.dumps(
182
+ {
183
+ "generated_at": datetime.now(timezone.utc).isoformat(),
184
+ "model_id": MODEL_ID,
185
+ "items": items,
186
+ "runtime_metadata": metadata,
187
+ },
188
+ indent=2,
189
+ sort_keys=True,
190
+ )
191
+ + "\n",
192
+ encoding="utf-8",
193
+ )
194
+
195
+
196
+ def _cached_images_for(goals: list[dict[str, str]]) -> list[dict[str, Any]] | None:
197
+ manifest = _load_manifest()
198
+ items = manifest.get("items")
199
+ if not isinstance(items, list) or len(items) < len(goals):
200
+ return None
201
+
202
+ by_goal = {
203
+ str(item.get("goal_id")): item
204
+ for item in items
205
+ if isinstance(item, dict) and item.get("goal_id") and item.get("asset_name")
206
+ }
207
+ results: list[dict[str, Any]] = []
208
+ for index, goal in enumerate(goals):
209
+ item = by_goal.get(goal["id"])
210
+ if not item:
211
+ return None
212
+ asset_name = str(item["asset_name"])
213
+ if not (ASSETS_DIR / asset_name).exists():
214
+ return None
215
+ results.append({
216
+ "goal_id": goal["id"],
217
+ "ordinary_goal": goal["text"],
218
+ "asset_name": asset_name,
219
+ "hero_src": _asset_to_data_uri(asset_name),
220
+ "origin": item.get("origin", "modal_flux_cached"),
221
+ "prompt_summary": item.get("prompt_summary", _prompt_for_goal(goal, "cozy")),
222
+ "runtime_metadata": item.get("runtime_metadata", manifest.get("runtime_metadata", {})),
223
+ })
224
+ return results
225
+
226
+
227
+ def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
228
+ base_url = os.environ.get("APP_MODAL_BASE_URL", "").strip()
229
+ token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
230
+ timeout = float(os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "180"))
231
+ if not base_url or not token:
232
+ raise RuntimeError("APP_MODAL_BASE_URL and APP_MODAL_AUTH_TOKEN are required for Modal FLUX generation.")
233
+
234
+ payload = {
235
+ "items": [
236
+ {
237
+ "goal_id": goal["id"],
238
+ "ordinary_goal": goal["text"],
239
+ "prompt": _prompt_for_goal(goal, tone),
240
+ "seed": 26000 + index,
241
+ }
242
+ for index, goal in enumerate(goals)
243
+ ],
244
+ "tone": tone,
245
+ "height": 768,
246
+ "width": 768,
247
+ "steps": 4,
248
+ "guidance": 1.0,
249
+ }
250
+ request = urllib.request.Request(
251
+ base_url,
252
+ data=json.dumps(payload).encode("utf-8"),
253
+ headers={
254
+ "Authorization": f"Bearer {token}",
255
+ "Content-Type": "application/json",
256
+ },
257
+ method="POST",
258
+ )
259
+ started = time.monotonic()
260
+ with urllib.request.urlopen(request, timeout=timeout) as response:
261
+ body = json.loads(response.read().decode("utf-8"))
262
+ latency_ms = int((time.monotonic() - started) * 1000)
263
+ outputs = body.get("outputs")
264
+ if not isinstance(outputs, list) or len(outputs) < len(goals):
265
+ raise RuntimeError("Modal response missing generated image outputs.")
266
+
267
+ metadata = {
268
+ "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
269
+ "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
270
+ "model_runtime": "modal",
271
+ "model_backend": body.get("model_backend", "modal_http"),
272
+ "inference_engine": body.get("inference_engine", "diffusers"),
273
+ "model_artifact_format": body.get("model_artifact_format", "safetensors"),
274
+ "quantization": body.get("quantization", "bf16"),
275
+ "model_id": body.get("model_id", MODEL_ID),
276
+ "latency_ms": body.get("latency_ms", latency_ms),
277
+ "fallback_used": bool(body.get("fallback_used", False)),
278
+ }
279
+
280
+ saved: list[dict[str, Any]] = []
281
+ for index, goal in enumerate(goals):
282
+ output = outputs[index]
283
+ image_b64 = output.get("image_png_base64")
284
+ if not image_b64:
285
+ raise RuntimeError("Modal image output missing image_png_base64.")
286
+ image_bytes = base64.b64decode(image_b64)
287
+ asset_name = generated_file_name(goal, index)
288
+ (ASSETS_DIR / asset_name).write_bytes(image_bytes)
289
+ saved.append({
290
+ "goal_id": goal["id"],
291
+ "ordinary_goal": goal["text"],
292
+ "asset_name": asset_name,
293
+ "hero_src": f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}",
294
+ "origin": "modal_flux_generated",
295
+ "prompt_summary": output.get("prompt_summary", _prompt_for_goal(goal, tone)),
296
+ "runtime_metadata": metadata,
297
+ "seed": output.get("seed", 26000 + index),
298
+ })
299
+ _write_manifest(saved, metadata)
300
+ return saved, metadata
301
+
302
+
303
+ def _quest_for_goal(goal: dict[str, str], image_result: dict[str, Any], index: int, tone: str) -> dict[str, Any]:
304
+ steps = [
305
+ {"id": f"{goal['id']}-step-{step_index + 1}", "label": label, "icon": icon}
306
+ for step_index, (label, icon) in enumerate(STEP_LIBRARY[_step_key(goal["text"])])
307
+ ]
308
+ return {
309
+ "id": f"quest-{goal['id']}",
310
+ "source_goal_id": goal["id"],
311
+ "ordinary_goal": goal["text"],
312
+ "title": _quest_title(goal["text"], tone),
313
+ "subtitle": f"Quest {index + 1} of 3",
314
+ "hero_asset_name": image_result["asset_name"],
315
+ "hero_src": image_result.get("hero_src", ""),
316
+ "hero_alt": f"Storybook classroom adventure for {goal['text']}",
317
+ "reward": {
318
+ "label": ["Idea Builder", "Lantern Reader", "Tidy Hero"][index] if index < 3 else "Quest Hero",
319
+ "description": "Reward earned after all steps are complete.",
320
+ },
321
+ "steps": steps,
322
+ "why_it_helps": _why_it_helps(goal["text"]),
323
+ "image_origin": image_result.get("origin", "unknown"),
324
+ "prompt_summary": image_result.get("prompt_summary", ""),
325
+ "runtime_metadata": image_result.get("runtime_metadata", _runtime_metadata()),
326
+ }
327
+
328
+
329
+ def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool = True) -> dict[str, Any]:
330
+ payload = payload or {}
331
+ tone = str(payload.get("tone") or "cozy").strip().lower() or "cozy"
332
+ goals = normalize_goals(payload.get("goals"))
333
+ force_refresh = bool(payload.get("force_refresh"))
334
+
335
+ images: list[dict[str, Any]] | None = None
336
+ metadata = _runtime_metadata()
337
+ blocker = ""
338
+ if use_cache and not force_refresh:
339
+ images = _cached_images_for(goals)
340
+ if images:
341
+ metadata = dict(images[0].get("runtime_metadata", metadata))
342
+ metadata["fallback_used"] = any(
343
+ bool(item.get("runtime_metadata", {}).get("fallback_used", True))
344
+ for item in images
345
+ )
346
+
347
+ if images is None:
348
+ try:
349
+ images, metadata = _call_modal(goals, tone)
350
+ except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
351
+ blocker = f"{type(exc).__name__}: {exc}"
352
+ images = [_fallback_image_result(goal, index, blocker) for index, goal in enumerate(goals)]
353
+ metadata = _runtime_metadata(fallback_used=True, fallback_reason=blocker)
354
+
355
+ quests = [_quest_for_goal(goal, images[index], index, tone) for index, goal in enumerate(goals)]
356
+ return {
357
+ "goals": goals,
358
+ "quests": quests,
359
+ "runtime_metadata": metadata,
360
+ "fallback_used": any(bool(quest["runtime_metadata"].get("fallback_used", True)) for quest in quests),
361
+ "blocker": blocker,
362
+ "generated_at": datetime.now(timezone.utc).isoformat(),
363
+ }
364
+
365
+
366
+ def build_initial_quest_pack() -> dict[str, Any]:
367
+ return build_quest_pack({"goals": default_goal_payload(), "tone": "cozy"}, use_cache=True)
epic_errands_space/render.py ADDED
@@ -0,0 +1,968 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "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": {
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": "Spell Quest: Bright Idea Bridge",
57
+ "style_id": "magical",
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": "magical",
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="magical"] {
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 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
+
665
+ function escapeHtml(value) {
666
+ return String(value)
667
+ .replace(/&/g, "&amp;")
668
+ .replace(/</g, "&lt;")
669
+ .replace(/>/g, "&gt;")
670
+ .replace(/"/g, "&quot;")
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() {
684
+ return state.goals.filter((goal) => goal.parent_reward_state === "waiting_for_approval").length;
685
+ }
686
+
687
+ function stateChip(goal) {
688
+ if (goal.parent_reward_state === "approved_reward_given") {
689
+ return '<span class="ee-chip" data-tone="success">Reward given</span>';
690
+ }
691
+ if (goal.kid_completion_state === "completed") {
692
+ return '<span class="ee-chip" data-tone="waiting">Awaiting parent</span>';
693
+ }
694
+ if (goal.kid_completion_state === "in_progress") {
695
+ return '<span class="ee-chip">In progress</span>';
696
+ }
697
+ return '<span class="ee-chip">Ready</span>';
698
+ }
699
+
700
+ function renderTabs() {
701
+ const tabs = [
702
+ ["home", "Home", "HM"],
703
+ ["parent", "Parent", "PA"],
704
+ ["kid", "Kid", "KD"],
705
+ ["diy", "DIY Lab", "DIY"],
706
+ ];
707
+ return `<nav class="ee-tabs" data-design-id="mobile-tab-nav" aria-label="Epic Errands tabs">
708
+ ${tabs.map(([id, label, mark]) => {
709
+ const disabled = id === "diy";
710
+ 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."' : ""}>
711
+ <span class="ee-tab-mark">${mark}</span>
712
+ <span>${label}</span>
713
+ </button>`;
714
+ }).join("")}
715
+ </nav>`;
716
+ }
717
+
718
+ function renderHeader() {
719
+ const style = shellStyle();
720
+ return `<header class="ee-top">
721
+ <div class="ee-brand-row">
722
+ <div>
723
+ <p class="ee-kicker">V1 mobile review</p>
724
+ <h1 class="ee-brand">Epic Errands</h1>
725
+ </div>
726
+ <span class="ee-pill">${escapeHtml(style.label)}</span>
727
+ </div>
728
+ ${renderTabs()}
729
+ </header>`;
730
+ }
731
+
732
+ function renderHome() {
733
+ const style = shellStyle();
734
+ return `<section class="ee-screen" data-design-id="home-screen" aria-labelledby="home-title">
735
+ <div>
736
+ <h2 class="ee-screen-title" id="home-title">Choose today's style</h2>
737
+ <p class="ee-copy">${escapeHtml(state.parentName)} is ${escapeHtml(style.parent_title)}. ${escapeHtml(state.kidName)} is ${escapeHtml(style.kid_title)}.</p>
738
+ </div>
739
+
740
+ <section class="ee-panel" data-design-id="style-mode-picker" aria-labelledby="style-label">
741
+ <p class="ee-section-label" id="style-label">Goal style</p>
742
+ <div class="ee-style-list">
743
+ ${Object.entries(state.styles).map(([id, item]) => `<button class="ee-style-button" data-action="style" data-style="${id}" aria-pressed="${state.defaultStyleId === id}">
744
+ <span class="ee-mark">${escapeHtml(item.mark)}</span>
745
+ <span>
746
+ <span class="ee-style-name">${escapeHtml(item.label)}</span>
747
+ <span class="ee-style-meta">${escapeHtml(item.goal_noun)} / ${escapeHtml(item.reward_noun)}. ${escapeHtml(item.copy)}</span>
748
+ </span>
749
+ <span class="ee-dot" aria-hidden="true"></span>
750
+ </button>`).join("")}
751
+ </div>
752
+ </section>
753
+
754
+ <section class="ee-panel" data-design-id="avatar-creator" aria-labelledby="avatar-label">
755
+ <p class="ee-section-label" id="avatar-label">Avatars</p>
756
+ <div class="ee-avatar-grid">
757
+ ${renderAvatar("parent", state.parentName, style.parent_title)}
758
+ ${renderAvatar("kid", state.kidName, style.kid_title)}
759
+ </div>
760
+ <p class="ee-privacy">Photo previews are session-only in this prototype. Nothing is persisted.</p>
761
+ </section>
762
+
763
+ <section class="ee-counter-grid" data-design-id="active-goal-count" aria-label="Goal counts">
764
+ <div class="ee-counter"><strong>${state.goals.length}</strong><span>active goals</span></div>
765
+ <div class="ee-counter"><strong>${awaitingApprovalCount()}</strong><span>awaiting approval</span></div>
766
+ </section>
767
+ </section>`;
768
+ }
769
+
770
+ function renderAvatar(role, name, title) {
771
+ const preview = state.avatarPreviews[role];
772
+ const label = role === "parent" ? "Parent" : "Kid";
773
+ return `<div class="ee-avatar">
774
+ <div class="ee-name-row">
775
+ ${preview ? `<img class="ee-avatar-photo" src="${preview}" alt="${label} avatar preview">` : `<span class="ee-avatar-mark">${role === "parent" ? "PA" : "KD"}</span>`}
776
+ <div>
777
+ <span class="ee-style-name">${escapeHtml(label)}</span>
778
+ <span class="ee-small">${escapeHtml(title)}</span>
779
+ </div>
780
+ </div>
781
+ <label class="ee-small">${label} name
782
+ <input class="ee-name-input" data-action="name" data-role="${role}" value="${escapeHtml(name)}" autocomplete="off">
783
+ </label>
784
+ <input class="ee-file" data-action="avatar" data-role="${role}" type="file" accept="image/*" aria-label="Upload ${label} photo">
785
+ </div>`;
786
+ }
787
+
788
+ function renderParent() {
789
+ return `<section class="ee-screen" data-design-id="parent-goals-screen" aria-labelledby="parent-title">
790
+ <div>
791
+ <h2 class="ee-screen-title" id="parent-title">Parent Goals</h2>
792
+ <p class="ee-copy">Approve completed goals and grant the reward. Everything else is coordinated offline.</p>
793
+ </div>
794
+ <div class="ee-goal-list" data-design-id="parent-goal-list">
795
+ ${state.goals.map((goal) => {
796
+ const style = styleFor(goal);
797
+ const canApprove = goal.parent_reward_state === "waiting_for_approval";
798
+ const approved = goal.parent_reward_state === "approved_reward_given";
799
+ return `<article class="ee-goal-row" data-goal-id="${goal.id}">
800
+ <div class="ee-title-row">
801
+ <span class="ee-goal-name">${escapeHtml(goal.title)}</span>
802
+ ${stateChip(goal)}
803
+ </div>
804
+ <div class="ee-chip-row">
805
+ <span class="ee-chip">${escapeHtml(style.label)}</span>
806
+ <span class="ee-chip">${escapeHtml(style.reward)}</span>
807
+ </div>
808
+ <span class="ee-goal-meta">Ordinary goal: ${escapeHtml(goal.ordinary_goal)}</span>
809
+ <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>
810
+ </article>`;
811
+ }).join("")}
812
+ </div>
813
+ </section>`;
814
+ }
815
+
816
+ function renderKid() {
817
+ const selected = state.goals.find((goal) => goal.id === state.selectedGoalId) || state.goals[0];
818
+ return `<section class="ee-screen" data-design-id="kid-goals-screen" aria-labelledby="kid-title">
819
+ <div>
820
+ <h2 class="ee-screen-title" id="kid-title">Kid Goals</h2>
821
+ <p class="ee-copy">Tap a goal, open the card, mark it complete, then wait for your grown-up's themed approval.</p>
822
+ </div>
823
+ <div class="ee-kid-grid" data-design-id="kid-goal-thumbnail-grid">
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)}
831
+ </span>
832
+ </button>`;
833
+ }).join("")}
834
+ </div>
835
+ ${renderGoalCard(selected)}
836
+ </section>`;
837
+ }
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>
849
+ <h2>${escapeHtml(goal.title)}</h2>
850
+ <p class="ee-copy">${escapeHtml(style.copy)}</p>
851
+ </div>
852
+ <ol class="ee-step-list">
853
+ ${steps.map((step, index) => `<li><b>${index + 1}</b><span>${escapeHtml(step)}</span></li>`).join("")}
854
+ </ol>
855
+ <div class="ee-card-action-row">
856
+ <button class="ee-audio-button" data-design-id="audio-play-hook" disabled>Audio in V1.1</button>
857
+ <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>
858
+ </div>
859
+ ${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>` : ""}
860
+ ${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>` : ""}
861
+ </div>
862
+ </article>`;
863
+ }
864
+
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
+
872
+ root.addEventListener("click", (event) => {
873
+ const target = event.target.closest("[data-action]");
874
+ if (!target) return;
875
+
876
+ if (target.dataset.action === "tab") {
877
+ if (target.getAttribute("aria-disabled") === "true") return;
878
+ state.activeTab = target.dataset.tab || "home";
879
+ render();
880
+ return;
881
+ }
882
+
883
+ if (target.dataset.action === "style") {
884
+ state.defaultStyleId = target.dataset.style || state.defaultStyleId;
885
+ render();
886
+ return;
887
+ }
888
+
889
+ if (target.dataset.action === "select-goal") {
890
+ state.selectedGoalId = target.dataset.goalId || state.selectedGoalId;
891
+ render();
892
+ return;
893
+ }
894
+
895
+ if (target.dataset.action === "complete") {
896
+ const goal = state.goals.find((item) => item.id === target.dataset.goalId);
897
+ if (goal) {
898
+ goal.kid_completion_state = "completed";
899
+ if (goal.parent_reward_state !== "approved_reward_given") {
900
+ goal.parent_reward_state = "waiting_for_approval";
901
+ }
902
+ render();
903
+ }
904
+ return;
905
+ }
906
+
907
+ if (target.dataset.action === "approve") {
908
+ const goal = state.goals.find((item) => item.id === target.dataset.goalId);
909
+ if (goal && goal.kid_completion_state === "completed") {
910
+ goal.parent_reward_state = "approved_reward_given";
911
+ render();
912
+ }
913
+ }
914
+ });
915
+
916
+ root.addEventListener("input", (event) => {
917
+ const target = event.target;
918
+ if (!target || target.dataset.action !== "name") return;
919
+ if (target.dataset.role === "parent") {
920
+ state.parentName = target.value || "Parent";
921
+ } else if (target.dataset.role === "kid") {
922
+ state.kidName = target.value || "Kid";
923
+ }
924
+ });
925
+
926
+ root.addEventListener("change", (event) => {
927
+ const target = event.target;
928
+ if (!target || target.dataset.action !== "avatar") return;
929
+ const file = target.files && target.files[0];
930
+ if (!file) return;
931
+ const role = target.dataset.role;
932
+ if (state.avatarPreviews[role]) {
933
+ URL.revokeObjectURL(state.avatarPreviews[role]);
934
+ }
935
+ state.avatarPreviews[role] = URL.createObjectURL(file);
936
+ render();
937
+ });
938
+
939
+ render();
940
+ }
941
+ """
942
+ ).replace("__STATE_JSON__", state_json)
943
+
944
+
945
+ def build_app_document(asset_base: str = "/file=frontend/assets/") -> str:
946
+ del asset_base
947
+ return f"""<!doctype html>
948
+ <html lang="en">
949
+ <head>
950
+ <meta charset="utf-8">
951
+ <meta name="viewport" content="width=device-width, initial-scale=1">
952
+ <title>Epic Errands V1 - Mobile Review Prototype</title>
953
+ <style>
954
+ body {{
955
+ margin: 0;
956
+ background: #e9f1fb;
957
+ }}
958
+ {build_app_css()}
959
+ </style>
960
+ </head>
961
+ <body>
962
+ {build_app_island()}
963
+ <script>
964
+ const element = document;
965
+ {build_app_bootstrap_js()}
966
+ </script>
967
+ </body>
968
+ </html>"""
frontend/assets/generated-hero-1-finish-class-project-8f64eeb3.png ADDED

Git LFS Details

  • SHA256: 3820603f8b0dc3f2727d7cc7273f317a1b01a8a5c4424b38cfc57172d02cba58
  • Pointer size: 132 Bytes
  • Size of remote file: 1.08 MB
frontend/assets/generated-hero-2-read-for-20-minutes-895e7642.png ADDED

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 ADDED

Git LFS Details

  • SHA256: 4ae5704e5d461a0072d81f462745efab4b085e5ff91d521e3325fd9575488519
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
frontend/assets/generated-hero-manifest.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/assets/sc1-crayon-green.png ADDED
frontend/assets/sc1-pencil-yellow.png ADDED
frontend/assets/sc1-photo-bridge-sketch.png ADDED
frontend/assets/sc1-photo-child-bridge.png ADDED
frontend/assets/sc1-photo-student-drawing.png ADDED
frontend/assets/sc2-hero-illustration.png ADDED

Git LFS Details

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

Git LFS Details

  • SHA256: 28f674286256ffc22de3346b8db81d1dfb45d51220a0befe967d424427a602a7
  • Pointer size: 131 Bytes
  • Size of remote file: 453 kB
frontend/assets/sc2-hero-scene-only.png ADDED

Git LFS Details

  • SHA256: b05b380f7a4d807d0f42f074a92ab3215ca6403ab8cfc29a6cff4bac51f48f7e
  • Pointer size: 131 Bytes
  • Size of remote file: 515 kB
frontend/assets/sc2-parent-strip.png ADDED
frontend/assets/sc2-reward-sticker.png ADDED
frontend/assets/source-sc1.png ADDED

Git LFS Details

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

Git LFS Details

  • SHA256: 784ca9e39dcfdbd9c6d5c826427aaf95659027f22eb8dfe63045061c027ba085
  • Pointer size: 132 Bytes
  • Size of remote file: 1.12 MB
frontend/index.html ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#fffdf8">
7
+ <title>Epic Errands</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@700;800&family=Nunito:wght@700;800;900&family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,600,0,0&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/frontend/styles/app.css">
12
+ </head>
13
+ <body>
14
+ <main class="epic-app" aria-label="Epic Errands mobile prototype">
15
+ <div id="epic-errands-app"></div>
16
+ </main>
17
+ <script>
18
+ window.EPIC_ASSET_BASE = "/assets/";
19
+ </script>
20
+ <script src="/frontend/scripts/app.js" defer></script>
21
+ </body>
22
+ </html>
frontend/scripts/app.js ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "Warm school adventure with stickers, notebooks, and sunny goals.",
16
+ },
17
+ magical: {
18
+ label: "Magical",
19
+ icon: "auto_fix_high",
20
+ goalNoun: "Spell Quest",
21
+ parentTitle: "Chief Wizard",
22
+ kidTitle: "Junior Wizard",
23
+ rewardNoun: "charm",
24
+ waitingCopy: "Waiting for Chief Wizard approval.",
25
+ copy: "Cozy spellbook fantasy with gentle magic and glowing rewards.",
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: "goal-1",
48
+ defaultStyleId: "magical",
49
+ parentAvatarReady: true,
50
+ kidAvatarReady: true,
51
+ goals: [
52
+ {
53
+ id: "goal-1",
54
+ ordinaryGoal: "Finish class project",
55
+ styleId: "magical",
56
+ kidCompletionState: "completed",
57
+ parentRewardState: "waiting_for_approval",
58
+ },
59
+ {
60
+ id: "goal-2",
61
+ ordinaryGoal: "Read for 20 minutes",
62
+ styleId: "classroom",
63
+ kidCompletionState: "in_progress",
64
+ parentRewardState: "not_reviewed",
65
+ },
66
+ {
67
+ id: "goal-3",
68
+ ordinaryGoal: "Put away laundry",
69
+ styleId: "comic",
70
+ kidCompletionState: "not_started",
71
+ parentRewardState: "not_reviewed",
72
+ },
73
+ ],
74
+ };
75
+
76
+ function asset(name) {
77
+ if (EMBEDDED_ASSETS[name]) return EMBEDDED_ASSETS[name];
78
+ return `${ASSET_BASE}${name}`;
79
+ }
80
+
81
+ function icon(name) {
82
+ return `<span class="material-symbols-rounded" aria-hidden="true">${name}</span>`;
83
+ }
84
+
85
+ function escapeHtml(value) {
86
+ return String(value)
87
+ .replace(/&/g, "&amp;")
88
+ .replace(/</g, "&lt;")
89
+ .replace(/>/g, "&gt;")
90
+ .replace(/"/g, "&quot;")
91
+ .replace(/'/g, "&#039;");
92
+ }
93
+
94
+ function currentStyle() {
95
+ return styles[state.defaultStyleId];
96
+ }
97
+
98
+ function goalStyle(goal) {
99
+ return styles[goal.styleId] || styles.classroom;
100
+ }
101
+
102
+ function activeGoals() {
103
+ return state.goals.length;
104
+ }
105
+
106
+ function awaitingApproval() {
107
+ return state.goals.filter((goal) => goal.parentRewardState === "waiting_for_approval").length;
108
+ }
109
+
110
+ function goalTitle(goal) {
111
+ const style = goalStyle(goal);
112
+ const lower = goal.ordinaryGoal.toLowerCase();
113
+ if (goal.styleId === "magical") return `${style.goalNoun}: Bright Idea Bridge`;
114
+ if (goal.styleId === "comic") return `${style.goalNoun}: Laundry Launch`;
115
+ if (lower.includes("read")) return `${style.goalNoun}: Reading Lantern`;
116
+ return `${style.goalNoun}: ${goal.ordinaryGoal}`;
117
+ }
118
+
119
+ function rewardLabel(goal) {
120
+ const style = goalStyle(goal);
121
+ if (goal.styleId === "magical") return `Glow ${style.rewardNoun}`;
122
+ if (goal.styleId === "comic") return `Pow ${style.rewardNoun}`;
123
+ return `Helper ${style.rewardNoun}`;
124
+ }
125
+
126
+ function derivedState(goal) {
127
+ if (goal.parentRewardState === "approved_reward_given") return "reward_given";
128
+ if (goal.kidCompletionState === "completed") return "waiting_for_parent";
129
+ if (goal.kidCompletionState === "in_progress") return "working_on_it";
130
+ return "ready_to_start";
131
+ }
132
+
133
+ function stateChip(goal) {
134
+ const derived = derivedState(goal);
135
+ if (derived === "reward_given") return `<span class="chip success">Reward given</span>`;
136
+ if (derived === "waiting_for_parent") return `<span class="chip waiting">Awaiting parent</span>`;
137
+ if (derived === "working_on_it") return `<span class="chip">In progress</span>`;
138
+ return `<span class="chip">Ready</span>`;
139
+ }
140
+
141
+ function stepsFor(goal) {
142
+ const style = goalStyle(goal);
143
+ if (goal.styleId === "magical") {
144
+ return [
145
+ "Gather your project ideas",
146
+ "Build the first bright piece",
147
+ "Check your spellbook notes",
148
+ ];
149
+ }
150
+ if (goal.styleId === "comic") {
151
+ return ["Sort the pile", "Fold the hero gear", "Launch it to the drawer"];
152
+ }
153
+ return ["Find your reading spot", "Read the next pages", "Tell the best part"];
154
+ }
155
+
156
+ function renderHome() {
157
+ const style = currentStyle();
158
+ return `
159
+ <section class="screen" data-design-id="home-screen" aria-labelledby="home-title">
160
+ <div class="screen-head">
161
+ <h1 class="screen-title" id="home-title">Choose today's style</h1>
162
+ <p class="screen-copy">${escapeHtml(style.parentTitle)} and ${escapeHtml(style.kidTitle)} will use this style for new goals.</p>
163
+ </div>
164
+
165
+ <section class="panel" data-design-id="style-mode-picker" aria-labelledby="style-label">
166
+ <h2 class="section-label" id="style-label">Goal style</h2>
167
+ <div class="style-grid">
168
+ ${Object.entries(styles).map(([id, item]) => `
169
+ <button class="style-option" data-action="select-style" data-style="${id}" aria-pressed="${state.defaultStyleId === id}">
170
+ <span class="style-mark">${icon(item.icon)}</span>
171
+ <span>
172
+ <span class="style-name">${item.label}</span>
173
+ <span class="style-meta">${item.goalNoun} / ${item.rewardNoun}. ${item.copy}</span>
174
+ </span>
175
+ <span class="selected-dot" aria-hidden="true"></span>
176
+ </button>
177
+ `).join("")}
178
+ </div>
179
+ </section>
180
+
181
+ <section class="panel" data-design-id="avatar-creator" aria-labelledby="avatar-label">
182
+ <h2 class="section-label" id="avatar-label">Avatars</h2>
183
+ <div class="avatar-grid">
184
+ <div class="avatar-slot">
185
+ <div>
186
+ <div class="avatar-face">${icon("shield_person")}</div>
187
+ <p class="avatar-name">Maya</p>
188
+ <p class="avatar-title">${escapeHtml(style.parentTitle)}</p>
189
+ </div>
190
+ </div>
191
+ <div class="avatar-slot">
192
+ <div>
193
+ <div class="avatar-face">${icon("face_3")}</div>
194
+ <p class="avatar-name">Ari</p>
195
+ <p class="avatar-title">${escapeHtml(style.kidTitle)}</p>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ <p class="privacy-note">${icon("lock")} Photos create session-only avatars in this prototype. Nothing is persisted.</p>
200
+ </section>
201
+
202
+ <section class="counter-grid" aria-label="Goal counts">
203
+ <div class="counter">
204
+ <strong>${activeGoals()}</strong>
205
+ <span>active goals</span>
206
+ </div>
207
+ <div class="counter">
208
+ <strong>${awaitingApproval()}</strong>
209
+ <span>awaiting approval</span>
210
+ </div>
211
+ </section>
212
+ </section>
213
+ `;
214
+ }
215
+
216
+ function renderParentGoals() {
217
+ return `
218
+ <section class="screen" data-design-id="parent-goals-screen" aria-labelledby="parent-title">
219
+ <div class="screen-head">
220
+ <h1 class="screen-title" id="parent-title">Parent Goals</h1>
221
+ <p class="screen-copy">Approve completed goals and grant the reward. Everything else is handled offline.</p>
222
+ </div>
223
+ <div class="goal-list" data-design-id="parent-goal-list">
224
+ ${state.goals.map((goal) => {
225
+ const style = goalStyle(goal);
226
+ const canApprove = goal.parentRewardState === "waiting_for_approval";
227
+ const approved = goal.parentRewardState === "approved_reward_given";
228
+ return `
229
+ <article class="goal-row" data-goal-id="${goal.id}">
230
+ <div class="goal-main">
231
+ <div class="goal-title-line">
232
+ <span class="goal-name">${escapeHtml(goalTitle(goal))}</span>
233
+ <span class="chip">${escapeHtml(style.label)}</span>
234
+ ${stateChip(goal)}
235
+ </div>
236
+ <p class="goal-plain">Ordinary goal: ${escapeHtml(goal.ordinaryGoal)}</p>
237
+ <p class="goal-plain">Reward: ${escapeHtml(rewardLabel(goal))}</p>
238
+ </div>
239
+ <button class="approve-button" data-design-id="parent-approve-action" data-action="approve-goal" data-goal-id="${goal.id}" ${canApprove ? "" : "disabled"}>
240
+ ${approved ? "Approved" : "Approve"}
241
+ </button>
242
+ </article>
243
+ `;
244
+ }).join("")}
245
+ </div>
246
+ </section>
247
+ `;
248
+ }
249
+
250
+ function renderKidGoals() {
251
+ const selected = state.goals.find((goal) => goal.id === state.selectedGoalId) || state.goals[0];
252
+ return `
253
+ <section class="screen" data-design-id="kid-goals-screen" aria-labelledby="kid-title">
254
+ <div class="screen-head">
255
+ <h1 class="screen-title" id="kid-title">Kid Goals</h1>
256
+ <p class="screen-copy">Tap a goal, finish it, then wait for your grown-up's themed approval.</p>
257
+ </div>
258
+ <div class="kid-grid" data-design-id="kid-goal-thumbnail-grid">
259
+ ${state.goals.map((goal) => {
260
+ const style = goalStyle(goal);
261
+ return `
262
+ <button class="kid-tile" data-action="select-goal" data-goal-id="${goal.id}" aria-pressed="${state.selectedGoalId === goal.id}">
263
+ <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} illustration for ${escapeHtml(goal.ordinaryGoal)}">
264
+ <span class="tile-body">
265
+ <span class="tile-title">${escapeHtml(goalTitle(goal))}</span>
266
+ ${stateChip(goal)}
267
+ </span>
268
+ </button>
269
+ `;
270
+ }).join("")}
271
+ </div>
272
+ ${renderGoalCard(selected)}
273
+ </section>
274
+ `;
275
+ }
276
+
277
+ function renderGoalCard(goal) {
278
+ const style = goalStyle(goal);
279
+ const isWaiting = goal.parentRewardState === "waiting_for_approval";
280
+ const rewarded = goal.parentRewardState === "approved_reward_given";
281
+ const steps = stepsFor(goal);
282
+ return `
283
+ <article class="goal-card" data-design-id="kid-goal-card">
284
+ <div class="goal-card-hero">
285
+ <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} goal card image for ${escapeHtml(goal.ordinaryGoal)}">
286
+ <span class="hero-badge">${escapeHtml(style.kidTitle)} ${escapeHtml(style.goalNoun)}</span>
287
+ </div>
288
+ <div class="goal-card-body">
289
+ <h2>${escapeHtml(goalTitle(goal))}</h2>
290
+ <p class="screen-copy">${escapeHtml(style.copy)}</p>
291
+ <ol class="step-list">
292
+ ${steps.map((step, index) => `
293
+ <li>
294
+ <span class="step-index">${index + 1}</span>
295
+ <span>${escapeHtml(step)}</span>
296
+ </li>
297
+ `).join("")}
298
+ </ol>
299
+ <div class="card-actions">
300
+ <button class="audio-button" data-design-id="audio-play-hook" disabled>
301
+ ${icon("volume_up")} Audio in V1.1
302
+ </button>
303
+ <button class="complete-button" data-action="complete-goal" data-goal-id="${goal.id}" ${goal.kidCompletionState === "completed" ? "disabled" : ""}>
304
+ ${goal.kidCompletionState === "completed" ? "Completed" : `Mark ${style.goalNoun} complete`}
305
+ </button>
306
+ </div>
307
+ ${isWaiting ? `
308
+ <div class="waiting-panel" data-design-id="waiting-for-parent-approval">
309
+ <strong>${escapeHtml(style.waitingCopy)}</strong>
310
+ <span>Your ${escapeHtml(style.rewardNoun)} unlocks when ${escapeHtml(style.parentTitle)} approves.</span>
311
+ </div>
312
+ ` : ""}
313
+ ${rewarded ? `
314
+ <div class="reward-panel">
315
+ <strong>${escapeHtml(rewardLabel(goal))} earned!</strong>
316
+ <span>${escapeHtml(style.parentTitle)} approved this goal.</span>
317
+ </div>
318
+ ` : ""}
319
+ </div>
320
+ </article>
321
+ `;
322
+ }
323
+
324
+ function renderNav() {
325
+ const items = [
326
+ ["home", "Home", "home"],
327
+ ["parent", "Parent", "fact_check"],
328
+ ["kid", "Kid", "view_carousel"],
329
+ ["diy", "DIY Lab", "experiment"],
330
+ ];
331
+ return `
332
+ <nav class="nav" data-design-id="mobile-tab-nav" aria-label="Epic Errands tabs">
333
+ ${items.map(([id, label, iconName]) => {
334
+ const isDisabled = id === "diy";
335
+ const active = state.activeTab === id;
336
+ return `
337
+ <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."' : ""}>
338
+ ${icon(iconName)}
339
+ <span>${label}</span>
340
+ </button>
341
+ `;
342
+ }).join("")}
343
+ </nav>
344
+ `;
345
+ }
346
+
347
+ function render() {
348
+ const style = currentStyle();
349
+ const screen = state.activeTab === "parent"
350
+ ? renderParentGoals()
351
+ : state.activeTab === "kid"
352
+ ? renderKidGoals()
353
+ : renderHome();
354
+
355
+ root.innerHTML = `
356
+ <div class="phone" data-style="${state.defaultStyleId}" data-design-id="mobile-app-shell">
357
+ <header class="app-top">
358
+ <div class="brand-row">
359
+ <div class="brand-lockup">
360
+ <p class="eyebrow">V1 mobile review</p>
361
+ <h1 class="brand-title">Epic Errands</h1>
362
+ </div>
363
+ <span class="status-pill">${icon(style.icon)} ${escapeHtml(style.label)}</span>
364
+ </div>
365
+ </header>
366
+ <div class="screen-region">${screen}</div>
367
+ ${renderNav()}
368
+ </div>
369
+ `;
370
+ }
371
+
372
+ function updateGoal(goalId, updater) {
373
+ const goal = state.goals.find((item) => item.id === goalId);
374
+ if (goal) updater(goal);
375
+ }
376
+
377
+ root.addEventListener("click", (event) => {
378
+ const target = event.target.closest("[data-action]");
379
+ if (!target) return;
380
+
381
+ const action = target.dataset.action;
382
+ if (action === "select-style") {
383
+ state.defaultStyleId = target.dataset.style || state.defaultStyleId;
384
+ render();
385
+ return;
386
+ }
387
+
388
+ if (action === "nav") {
389
+ if (target.getAttribute("aria-disabled") === "true") return;
390
+ state.activeTab = target.dataset.tab || "home";
391
+ render();
392
+ return;
393
+ }
394
+
395
+ if (action === "select-goal") {
396
+ state.selectedGoalId = target.dataset.goalId || state.selectedGoalId;
397
+ render();
398
+ return;
399
+ }
400
+
401
+ if (action === "complete-goal") {
402
+ const goalId = target.dataset.goalId;
403
+ updateGoal(goalId, (goal) => {
404
+ goal.kidCompletionState = "completed";
405
+ if (goal.parentRewardState !== "approved_reward_given") {
406
+ goal.parentRewardState = "waiting_for_approval";
407
+ }
408
+ });
409
+ render();
410
+ return;
411
+ }
412
+
413
+ if (action === "approve-goal") {
414
+ const goalId = target.dataset.goalId;
415
+ updateGoal(goalId, (goal) => {
416
+ if (goal.kidCompletionState === "completed") {
417
+ goal.parentRewardState = "approved_reward_given";
418
+ }
419
+ });
420
+ render();
421
+ }
422
+ });
423
+
424
+ render();
425
+ })();
frontend/styles/app.css ADDED
@@ -0,0 +1,786 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --font-display: "Baloo 2", system-ui, sans-serif;
3
+ --font-body: "Nunito", system-ui, sans-serif;
4
+ --ink: #1f2a37;
5
+ --muted: #607086;
6
+ --line: rgba(31, 42, 55, 0.14);
7
+ --paper: #fffdf8;
8
+ --surface: #ffffff;
9
+ --soft: #f4f7fb;
10
+ --primary: #e3403a;
11
+ --primary-ink: #ffffff;
12
+ --success: #128a55;
13
+ --warning: #b36b00;
14
+ --shadow: 0 18px 42px rgba(30, 42, 64, 0.14);
15
+ --shadow-tight: 0 8px 22px rgba(30, 42, 64, 0.12);
16
+ --radius-sm: 10px;
17
+ --radius-md: 16px;
18
+ --radius-lg: 24px;
19
+ --nav-h: 76px;
20
+ color-scheme: light;
21
+ }
22
+
23
+ * {
24
+ box-sizing: border-box;
25
+ }
26
+
27
+ html,
28
+ body {
29
+ min-height: 100%;
30
+ height: 100%;
31
+ }
32
+
33
+ body {
34
+ margin: 0;
35
+ background: #dfe9f7;
36
+ color: var(--ink);
37
+ font-family: var(--font-body);
38
+ overflow: hidden;
39
+ }
40
+
41
+ button,
42
+ input {
43
+ font: inherit;
44
+ }
45
+
46
+ button {
47
+ cursor: pointer;
48
+ }
49
+
50
+ button:focus-visible,
51
+ input:focus-visible {
52
+ outline: 3px solid rgba(31, 105, 255, 0.45);
53
+ outline-offset: 3px;
54
+ }
55
+
56
+ .material-symbols-rounded {
57
+ width: 1em;
58
+ max-width: 1em;
59
+ display: inline-block;
60
+ overflow: hidden;
61
+ font-family: "Material Symbols Rounded";
62
+ font-size: 22px;
63
+ font-style: normal;
64
+ font-weight: 500;
65
+ font-feature-settings: "liga";
66
+ line-height: 1;
67
+ text-transform: none;
68
+ vertical-align: middle;
69
+ white-space: nowrap;
70
+ }
71
+
72
+ .epic-app {
73
+ min-height: 100dvh;
74
+ height: 100dvh;
75
+ display: grid;
76
+ place-items: center;
77
+ padding: 18px;
78
+ overflow: hidden;
79
+ background:
80
+ radial-gradient(circle at 20% 10%, rgba(255, 255, 255, 0.92), transparent 30%),
81
+ linear-gradient(135deg, #d8ebff 0%, #fff2d8 52%, #f5e9ff 100%);
82
+ }
83
+
84
+ .phone {
85
+ width: min(100%, 430px);
86
+ height: min(920px, calc(100dvh - 36px));
87
+ min-height: min(760px, calc(100dvh - 36px));
88
+ max-height: calc(100dvh - 36px);
89
+ overflow: hidden;
90
+ display: grid;
91
+ grid-template-rows: auto minmax(0, 1fr) auto;
92
+ border: 1px solid rgba(31, 42, 55, 0.16);
93
+ border-radius: 30px;
94
+ background: var(--paper);
95
+ box-shadow: var(--shadow);
96
+ }
97
+
98
+ .phone[data-style="classroom"] {
99
+ --theme: #e3403a;
100
+ --theme-2: #1467b3;
101
+ --theme-3: #f7c948;
102
+ --theme-soft: #fff3d7;
103
+ --theme-panel: #f8fbff;
104
+ --theme-border: #9bc4ef;
105
+ --reward: #128a55;
106
+ }
107
+
108
+ .phone[data-style="magical"] {
109
+ --theme: #7652c7;
110
+ --theme-2: #0f877e;
111
+ --theme-3: #efb84a;
112
+ --theme-soft: #f2eaff;
113
+ --theme-panel: #fff7e4;
114
+ --theme-border: #c5b2f3;
115
+ --reward: #b36b00;
116
+ }
117
+
118
+ .phone[data-style="comic"] {
119
+ --theme: #136bd9;
120
+ --theme-2: #d62957;
121
+ --theme-3: #ffd33d;
122
+ --theme-soft: #fff2a8;
123
+ --theme-panel: #f8fbff;
124
+ --theme-border: #1f2a37;
125
+ --reward: #d64b20;
126
+ }
127
+
128
+ .app-top {
129
+ padding: 22px 20px 12px;
130
+ background:
131
+ linear-gradient(90deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.42)),
132
+ var(--theme-panel);
133
+ border-bottom: 1px solid var(--line);
134
+ }
135
+
136
+ .brand-row {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ gap: 12px;
141
+ }
142
+
143
+ .brand-lockup {
144
+ min-width: 0;
145
+ }
146
+
147
+ .eyebrow {
148
+ margin: 0 0 2px;
149
+ color: var(--theme-2);
150
+ font-size: 12px;
151
+ font-weight: 900;
152
+ text-transform: uppercase;
153
+ }
154
+
155
+ .brand-title {
156
+ margin: 0;
157
+ color: var(--ink);
158
+ font-family: var(--font-display);
159
+ font-size: 29px;
160
+ line-height: 0.95;
161
+ }
162
+
163
+ .status-pill {
164
+ display: inline-flex;
165
+ align-items: center;
166
+ gap: 6px;
167
+ min-height: 34px;
168
+ padding: 0 10px;
169
+ border: 1px solid var(--line);
170
+ border-radius: 999px;
171
+ background: rgba(255, 255, 255, 0.84);
172
+ color: var(--muted);
173
+ font-size: 12px;
174
+ font-weight: 900;
175
+ white-space: nowrap;
176
+ }
177
+
178
+ .screen-region {
179
+ min-height: 0;
180
+ overflow: auto;
181
+ overscroll-behavior: contain;
182
+ -webkit-overflow-scrolling: touch;
183
+ padding: 18px 18px calc(var(--nav-h) + 18px);
184
+ background:
185
+ linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0)),
186
+ var(--theme-panel);
187
+ }
188
+
189
+ .screen {
190
+ display: grid;
191
+ gap: 14px;
192
+ }
193
+
194
+ .screen-head {
195
+ display: grid;
196
+ gap: 4px;
197
+ }
198
+
199
+ .screen-title {
200
+ margin: 0;
201
+ font-family: var(--font-display);
202
+ font-size: 28px;
203
+ line-height: 1;
204
+ }
205
+
206
+ .screen-copy {
207
+ margin: 0;
208
+ color: var(--muted);
209
+ font-size: 15px;
210
+ font-weight: 800;
211
+ line-height: 1.35;
212
+ }
213
+
214
+ .panel,
215
+ .goal-row,
216
+ .kid-tile,
217
+ .goal-card,
218
+ .style-option,
219
+ .avatar-slot {
220
+ border: 1px solid var(--line);
221
+ border-radius: var(--radius-md);
222
+ background: rgba(255, 255, 255, 0.9);
223
+ box-shadow: var(--shadow-tight);
224
+ }
225
+
226
+ .panel {
227
+ padding: 15px;
228
+ }
229
+
230
+ .section-label {
231
+ margin: 0 0 10px;
232
+ color: var(--ink);
233
+ font-size: 13px;
234
+ font-weight: 1000;
235
+ text-transform: uppercase;
236
+ }
237
+
238
+ .style-grid {
239
+ display: grid;
240
+ gap: 9px;
241
+ }
242
+
243
+ .style-option {
244
+ width: 100%;
245
+ min-height: 78px;
246
+ display: grid;
247
+ grid-template-columns: 46px 1fr auto;
248
+ align-items: center;
249
+ gap: 12px;
250
+ padding: 12px;
251
+ color: var(--ink);
252
+ text-align: left;
253
+ }
254
+
255
+ .style-option[aria-pressed="true"] {
256
+ border-color: var(--theme);
257
+ background: linear-gradient(135deg, var(--theme-soft), #fff);
258
+ }
259
+
260
+ .style-mark {
261
+ display: grid;
262
+ width: 46px;
263
+ height: 46px;
264
+ place-items: center;
265
+ border-radius: 14px;
266
+ background: var(--theme-soft);
267
+ color: var(--theme);
268
+ }
269
+
270
+ .style-name {
271
+ display: block;
272
+ font-size: 18px;
273
+ font-weight: 1000;
274
+ }
275
+
276
+ .style-meta {
277
+ display: block;
278
+ color: var(--muted);
279
+ font-size: 13px;
280
+ font-weight: 800;
281
+ line-height: 1.25;
282
+ }
283
+
284
+ .selected-dot {
285
+ width: 18px;
286
+ height: 18px;
287
+ border: 2px solid var(--theme);
288
+ border-radius: 50%;
289
+ }
290
+
291
+ .style-option[aria-pressed="true"] .selected-dot {
292
+ background: radial-gradient(circle, var(--theme) 45%, transparent 49%);
293
+ }
294
+
295
+ .avatar-grid {
296
+ display: grid;
297
+ grid-template-columns: 1fr 1fr;
298
+ gap: 10px;
299
+ }
300
+
301
+ .avatar-slot {
302
+ min-height: 134px;
303
+ padding: 12px;
304
+ display: grid;
305
+ align-content: space-between;
306
+ background:
307
+ linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.92)),
308
+ var(--theme-soft);
309
+ }
310
+
311
+ .avatar-face {
312
+ width: 62px;
313
+ height: 62px;
314
+ display: grid;
315
+ place-items: center;
316
+ border: 3px solid #fff;
317
+ border-radius: 22px;
318
+ background: linear-gradient(135deg, var(--theme-3), var(--theme-soft));
319
+ color: var(--ink);
320
+ box-shadow: var(--shadow-tight);
321
+ }
322
+
323
+ .avatar-name {
324
+ margin: 10px 0 0;
325
+ font-size: 16px;
326
+ font-weight: 1000;
327
+ }
328
+
329
+ .avatar-title {
330
+ margin: 2px 0 0;
331
+ color: var(--muted);
332
+ font-size: 12px;
333
+ font-weight: 900;
334
+ }
335
+
336
+ .privacy-note {
337
+ display: flex;
338
+ gap: 8px;
339
+ align-items: flex-start;
340
+ margin: 0;
341
+ color: #4d6174;
342
+ font-size: 13px;
343
+ font-weight: 800;
344
+ line-height: 1.3;
345
+ }
346
+
347
+ .counter-grid {
348
+ display: grid;
349
+ grid-template-columns: 1fr 1fr;
350
+ gap: 10px;
351
+ }
352
+
353
+ .counter {
354
+ min-height: 96px;
355
+ display: grid;
356
+ align-content: center;
357
+ gap: 2px;
358
+ padding: 12px;
359
+ border-radius: var(--radius-md);
360
+ background: var(--surface);
361
+ border: 1px solid var(--line);
362
+ }
363
+
364
+ .counter strong {
365
+ color: var(--theme);
366
+ font-family: var(--font-display);
367
+ font-size: 38px;
368
+ line-height: 0.9;
369
+ }
370
+
371
+ .counter span {
372
+ color: var(--muted);
373
+ font-size: 13px;
374
+ font-weight: 900;
375
+ }
376
+
377
+ .goal-list {
378
+ display: grid;
379
+ gap: 10px;
380
+ }
381
+
382
+ .goal-row {
383
+ display: grid;
384
+ grid-template-columns: 1fr auto;
385
+ gap: 10px;
386
+ padding: 13px;
387
+ }
388
+
389
+ .goal-main {
390
+ min-width: 0;
391
+ }
392
+
393
+ .goal-title-line {
394
+ display: flex;
395
+ align-items: center;
396
+ flex-wrap: wrap;
397
+ gap: 6px;
398
+ margin-bottom: 5px;
399
+ }
400
+
401
+ .goal-name {
402
+ font-size: 17px;
403
+ font-weight: 1000;
404
+ }
405
+
406
+ .goal-plain {
407
+ margin: 0;
408
+ color: var(--muted);
409
+ font-size: 13px;
410
+ font-weight: 800;
411
+ line-height: 1.3;
412
+ }
413
+
414
+ .chip {
415
+ display: inline-flex;
416
+ align-items: center;
417
+ min-height: 24px;
418
+ padding: 0 8px;
419
+ border-radius: 999px;
420
+ background: var(--theme-soft);
421
+ color: var(--theme);
422
+ font-size: 11px;
423
+ font-weight: 1000;
424
+ white-space: nowrap;
425
+ }
426
+
427
+ .chip.success {
428
+ background: #e6f7ee;
429
+ color: var(--success);
430
+ }
431
+
432
+ .chip.waiting {
433
+ background: #fff4d8;
434
+ color: var(--warning);
435
+ }
436
+
437
+ .approve-button,
438
+ .complete-button,
439
+ .audio-button {
440
+ min-height: 44px;
441
+ border: 0;
442
+ border-radius: 999px;
443
+ padding: 0 14px;
444
+ background: var(--theme);
445
+ color: var(--primary-ink);
446
+ font-weight: 1000;
447
+ box-shadow: var(--shadow-tight);
448
+ }
449
+
450
+ .approve-button:disabled,
451
+ .audio-button:disabled {
452
+ cursor: not-allowed;
453
+ border: 1px solid var(--line);
454
+ background: #eef2f7;
455
+ color: #6b7888;
456
+ box-shadow: none;
457
+ }
458
+
459
+ .kid-grid {
460
+ display: grid;
461
+ grid-template-columns: 1fr 1fr;
462
+ gap: 10px;
463
+ }
464
+
465
+ .kid-tile {
466
+ min-height: 178px;
467
+ overflow: hidden;
468
+ text-align: left;
469
+ color: var(--ink);
470
+ }
471
+
472
+ .kid-tile img {
473
+ width: 100%;
474
+ height: 96px;
475
+ display: block;
476
+ object-fit: cover;
477
+ background: var(--theme-soft);
478
+ }
479
+
480
+ .tile-body {
481
+ display: grid;
482
+ gap: 6px;
483
+ padding: 10px;
484
+ }
485
+
486
+ .tile-title {
487
+ min-height: 38px;
488
+ font-size: 15px;
489
+ font-weight: 1000;
490
+ line-height: 1.1;
491
+ }
492
+
493
+ .goal-card {
494
+ overflow: hidden;
495
+ }
496
+
497
+ .goal-card-hero {
498
+ position: relative;
499
+ min-height: 188px;
500
+ background: var(--theme-soft);
501
+ }
502
+
503
+ .goal-card-hero img {
504
+ width: 100%;
505
+ height: 206px;
506
+ display: block;
507
+ object-fit: cover;
508
+ }
509
+
510
+ .hero-badge {
511
+ position: absolute;
512
+ left: 12px;
513
+ bottom: 12px;
514
+ max-width: calc(100% - 24px);
515
+ padding: 8px 10px;
516
+ border: 1px solid rgba(255, 255, 255, 0.72);
517
+ border-radius: 999px;
518
+ background: rgba(255, 255, 255, 0.9);
519
+ color: var(--theme);
520
+ font-size: 12px;
521
+ font-weight: 1000;
522
+ }
523
+
524
+ .goal-card-body {
525
+ display: grid;
526
+ gap: 12px;
527
+ padding: 14px;
528
+ background: var(--surface);
529
+ }
530
+
531
+ .goal-card h2 {
532
+ margin: 0;
533
+ font-family: var(--font-display);
534
+ font-size: 28px;
535
+ line-height: 0.98;
536
+ }
537
+
538
+ .step-list {
539
+ display: grid;
540
+ gap: 8px;
541
+ margin: 0;
542
+ padding: 0;
543
+ list-style: none;
544
+ }
545
+
546
+ .step-list li {
547
+ display: grid;
548
+ grid-template-columns: 32px 1fr;
549
+ align-items: center;
550
+ gap: 9px;
551
+ min-height: 42px;
552
+ padding: 7px 10px;
553
+ border-radius: 13px;
554
+ background: var(--soft);
555
+ font-weight: 900;
556
+ }
557
+
558
+ .step-index {
559
+ display: grid;
560
+ width: 32px;
561
+ height: 32px;
562
+ place-items: center;
563
+ border-radius: 50%;
564
+ background: var(--theme-soft);
565
+ color: var(--theme);
566
+ font-weight: 1000;
567
+ }
568
+
569
+ .card-actions {
570
+ display: grid;
571
+ grid-template-columns: 1fr;
572
+ gap: 10px;
573
+ }
574
+
575
+ .audio-button {
576
+ background: #eef2f7;
577
+ color: #536273;
578
+ }
579
+
580
+ .complete-button {
581
+ min-height: 54px;
582
+ background: var(--reward);
583
+ font-size: 16px;
584
+ }
585
+
586
+ .waiting-panel,
587
+ .reward-panel {
588
+ display: grid;
589
+ gap: 5px;
590
+ padding: 12px;
591
+ border-radius: var(--radius-md);
592
+ border: 1px solid var(--line);
593
+ }
594
+
595
+ .waiting-panel {
596
+ background: #fff5dd;
597
+ color: #744800;
598
+ }
599
+
600
+ .reward-panel {
601
+ background: #eaf9f0;
602
+ color: #105c3a;
603
+ }
604
+
605
+ .waiting-panel strong,
606
+ .reward-panel strong {
607
+ font-size: 15px;
608
+ }
609
+
610
+ .waiting-panel span,
611
+ .reward-panel span {
612
+ font-size: 13px;
613
+ font-weight: 800;
614
+ line-height: 1.3;
615
+ }
616
+
617
+ .nav {
618
+ display: grid;
619
+ grid-template-columns: repeat(4, 1fr);
620
+ min-height: var(--nav-h);
621
+ border-top: 1px solid var(--line);
622
+ background: rgba(255, 255, 255, 0.96);
623
+ }
624
+
625
+ .nav-button {
626
+ display: grid;
627
+ place-items: center;
628
+ align-content: center;
629
+ gap: 4px;
630
+ min-width: 0;
631
+ border: 0;
632
+ background: transparent;
633
+ color: #68778a;
634
+ font-size: 11px;
635
+ font-weight: 1000;
636
+ }
637
+
638
+ .nav-button.is-active {
639
+ color: var(--theme);
640
+ }
641
+
642
+ .nav-button[aria-disabled="true"] {
643
+ cursor: not-allowed;
644
+ opacity: 0.46;
645
+ }
646
+
647
+ .empty-state {
648
+ display: grid;
649
+ place-items: center;
650
+ min-height: 260px;
651
+ padding: 24px;
652
+ border: 1px dashed var(--line);
653
+ border-radius: var(--radius-lg);
654
+ background: rgba(255, 255, 255, 0.58);
655
+ color: var(--muted);
656
+ text-align: center;
657
+ font-weight: 900;
658
+ }
659
+
660
+ @media (max-width: 520px) {
661
+ .epic-app {
662
+ padding: 0;
663
+ min-height: 100dvh;
664
+ height: 100dvh;
665
+ }
666
+
667
+ .phone {
668
+ width: 100%;
669
+ height: 100dvh;
670
+ min-height: 0;
671
+ max-height: 100dvh;
672
+ display: grid;
673
+ grid-template-rows: auto minmax(0, 1fr) auto;
674
+ overflow: hidden;
675
+ border: 0;
676
+ border-radius: 0;
677
+ }
678
+
679
+ .app-top {
680
+ padding: 14px 16px 10px;
681
+ }
682
+
683
+ .brand-title {
684
+ font-size: 25px;
685
+ }
686
+
687
+ .status-pill {
688
+ min-height: 30px;
689
+ font-size: 11px;
690
+ }
691
+
692
+ .screen-region {
693
+ min-height: 0;
694
+ overflow-y: auto;
695
+ padding: 14px 14px 16px;
696
+ }
697
+
698
+ .screen {
699
+ min-height: auto;
700
+ gap: 10px;
701
+ }
702
+
703
+ .screen-head {
704
+ gap: 2px;
705
+ }
706
+
707
+ .screen-title {
708
+ font-size: 26px;
709
+ }
710
+
711
+ .screen-copy {
712
+ font-size: 13px;
713
+ }
714
+
715
+ .panel {
716
+ padding: 11px;
717
+ }
718
+
719
+ .section-label {
720
+ margin-bottom: 7px;
721
+ font-size: 12px;
722
+ }
723
+
724
+ .style-grid {
725
+ gap: 7px;
726
+ }
727
+
728
+ .style-option {
729
+ min-height: 62px;
730
+ grid-template-columns: 38px 1fr auto;
731
+ gap: 9px;
732
+ padding: 9px;
733
+ }
734
+
735
+ .style-mark {
736
+ width: 38px;
737
+ height: 38px;
738
+ border-radius: 12px;
739
+ }
740
+
741
+ .style-name {
742
+ font-size: 16px;
743
+ }
744
+
745
+ .style-meta {
746
+ font-size: 12px;
747
+ }
748
+
749
+ .avatar-slot {
750
+ min-height: 108px;
751
+ }
752
+
753
+ .avatar-face {
754
+ width: 50px;
755
+ height: 50px;
756
+ border-radius: 18px;
757
+ }
758
+
759
+ .counter {
760
+ min-height: 76px;
761
+ }
762
+
763
+ .counter strong {
764
+ font-size: 30px;
765
+ }
766
+
767
+ .nav {
768
+ min-height: 68px;
769
+ }
770
+ }
771
+
772
+ @media (max-width: 374px) {
773
+ .screen-region {
774
+ padding-inline: 12px;
775
+ }
776
+
777
+ .kid-grid,
778
+ .counter-grid {
779
+ gap: 8px;
780
+ }
781
+
782
+ .screen-title,
783
+ .goal-card h2 {
784
+ font-size: 25px;
785
+ }
786
+ }
frontend/styles/components.css ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
frontend/styles/tokens.css ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=6.18.0