================ ONE-SHOT BUILD CONTRACT (read first) ================
Build this in Google AI Studio "Build" in ONE shot — a complete, working app,
no follow-up turns. These are hard rules, not suggestions:
1. TARGET = Full-Stack Web (Node server runtime, secrets, Firebase allowed).
If you target Native Android instead, you MUST drop all server/DB/Workspace/
secrets and persist locally (Room / SharedPreferences) only.
2. PIN EVERY MODEL CALL — never let the agent auto-select (it downgrades on quota):
- Reasoning / text -> gemini-3.5-flash (thinkingLevel: minimal|low|medium|high)
- 4K image + legible text -> gemini-3-pro-image (image_size "4K", up to 14 refs)
- High-volume image -> gemini-3.1-flash-image
- Expressive TTS -> gemini-3.1-flash-tts-preview (inline tags e.g. [whispers])
- Realtime audio/video (WebSocket) -> gemini-3.1-flash-live-preview
- Sandboxed agent -> antigravity-preview-05-2026
3. DIVISION OF LABOR — the model ONLY parses/extracts to a strict responseSchema.
ALL math, money (store currency as integer minor units / cents), sorting,
balancing and graph logic run in deterministic TypeScript/Python. The model
must never compute totals, splits or balances itself.
4. responseSchema sanitation — no regex patterns, no fixed-length tuples, no
format validators in the schema (they crash the OpenAPI engine). Enforce those
in server-side code AFTER parsing the JSON.
5. responseSchema and google_search grounding are MUTUALLY EXCLUSIVE in one call.
6. CODEGEN — split large output into modular, single-responsibility files so no
file is truncated by the output-token cap.
7. Every external call gets a graceful fallback (e.g. manual paste if a Workspace
read fails). Never a silent dead end.
8. ROBUST STORAGE & CANVAS — Wrap all `localStorage`/`sessionStorage` operations (especially JSON parsing and writes) in `try-catch` blocks to prevent crashes in private windows or quota overflows. Canvas drawing elements must dynamically handle window resize and scale pixel density (`window.devicePixelRatio`) to avoid blurry graphics on retina displays.
=====================================================================
# MUST OBEY — Mobile-first build requirements
This app's PRIMARY surface is a mobile phone. Build it impeccably on mobile FIRST, then verify on tablet and desktop. Treat the rules below as non-negotiable hard constraints, not suggestions.
## Viewports to verify (every screen, every state)
- 320 px, 360 px, 375 px, 390 px, 414 px, 480 px
- 768 px, 834 px (iPad portrait / Pro 11)
- 1024 px, 1280 px, 1440 px, 1920 px, 2560 px
- Plus: 200% browser zoom, landscape orientation on every mobile width, iPhone with safe-area insets visible
## Hard layout rules
- Mobile-first CSS. Default styles target mobile; `@media (min-width: ...)` for larger viewports.
- Use `dvh` and `svh` instead of `vh` for full-height surfaces (iOS Safari URL-bar bug).
- Use `clamp()` for fluid typography across all viewports.
- Prefer container queries (`@container`) over media queries for component-level responsiveness.
- Use `min(100%, ...)` widths so content never overflows. Zero horizontal overflow at any viewport.
- Add `` to every page.
- Apply `padding: max(safe-area-inset-X, fallback)` on every edge-bleeding container so notched iPhones in landscape never clip content.
- Wide tables and code blocks scroll INSIDE their container (`overflow-x: auto`), never push the body.
- Use `background-attachment: scroll` on mobile, not `fixed` (iOS Safari repaint bug).
- Avoid `backdrop-filter` on animated elements. Use it sparingly on static surfaces only.
- **Canvas Scaling**: Canvases must dynamically scale with window resize events and properly handle high-DPI screens (`window.devicePixelRatio`). Set physical dimensions (`canvas.width`/`canvas.height`) using pixel ratio and render relative to this grid, using CSS to control responsive viewport scaling.
- **Robust Storage**: Every access to `localStorage`/`sessionStorage` (especially `JSON.parse` of loaded state or writes) MUST be wrapped in a `try-catch` block to handle disabled storage, private browsing mode, quota limits, or corrupted JSON gracefully. Fall back to a robust in-memory object store.
## Touch & accessibility
- Tap targets ≥ 44 × 44 px on touch (Apple HIG). Increase to 48 px under `@media (hover: none) and (pointer: coarse)`.
- All interactive controls reachable by keyboard with a visible focus ring; respect `:focus-visible`.
- Color contrast ≥ 4.5:1 for body text, 3:1 for UI components.
- All images have meaningful `alt`. Decorative images use `alt=""`.
- Respect `prefers-reduced-motion: reduce` — zero animation durations under that query.
- Forms validate inline; error messages are specific, not "Invalid input".
- Modals: focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, focus restored on close.
## Performance bar (Lighthouse mobile, throttled 3G/4G)
- LCP < 2.5 s · INP < 200 ms · CLS < 0.1
- JS bundle gzip < 200 KB mobile-first; lazy-load non-critical screens via `React.lazy` / dynamic imports.
- No render-blocking resources above the fold.
- Images: WebP/AVIF preferred, `loading="lazy"`, explicit `width`/`height` attributes (zero CLS), `srcset` for retina.
- Videos: `preload="metadata"`, low-resolution poster, max 720p mobile fallback. Never autoplay with audio.
- Fonts: `font-display: swap`; preload only the one used above the fold.
- Smooth scroll honoured via CSS `scroll-behavior: smooth` with reduced-motion fallback.
## Pre-ship mobile checklist (the deployer MUST verify before declaring done)
1. Open at 375 px in DevTools — every screen scrolls vertically only; zero horizontal scroll.
2. Browser zoom 200% — layout reflows without overlap.
3. iPhone Safari with the URL bar visible AND landscape — no content under the home indicator; no notch clipping.
4. iPad portrait (768 px) and landscape (1024 px) — no awkward gaps; tablet-specific breakpoints land cleanly.
5. Tap every interactive element with a thumb at real-device size — every target is easy to hit.
6. `prefers-reduced-motion: reduce` — every transition / animation skips cleanly, scroll-behavior becomes instant.
7. Lighthouse mobile score ≥ 90 across all 4 categories.
8. Zero `console.error` and zero CLS shift in real-device testing on a mid-tier Android (e.g. Pixel 6a) and an iPhone SE.
---
The original template starts below. All rules above apply on TOP of whatever this template specifies.
---
# Train Show
## 1. Project
**Train Show** is a back-seat narrator for a small child who is obsessed
with trains. The grown-up — usually a parent at the wheel, sometimes a
grandparent on a long platform wait — opens the app, hands the phone to
the kid, and a calm voice begins an in-character story about a specific
train. Not a generic story. Not a YouTube compilation. A real journey
on a real route, told as it unfolds, with a small seat-side window that
draws what the kid is "seeing" as the train moves. A new station every
ninety seconds. A small question every couple of minutes — "what do you
think is in the goods wagon at the back?" — and a calm reply, in
character, to whatever the kid says back.
This is the kind of app a Japanese-American dad in Seattle builds for
his four-year-old, Kenta, on the third long car ride of the week to his
mother-in-law's place in Tacoma. Kenta has watched every shinkansen
clip on YouTube; the algorithm now only feeds him louder, faster
content; he has run out of new trains and the dad has run out of
stories. It is also the kind of app a Nigerian-British dad in Croydon
opens for his five-year-old, Tobi, on the rainy walk to nursery —
Tobi wants the Northern Line, every morning, from Morden to Edgware,
and his dad knows about three of the stops. Same shape of moment,
different child, different rail network.
The single demo that proves the magic: the kid taps a big red button
labelled **Trains** and says, out loud, "tell me about the nine-fourteen
from York". In under three seconds, a warm voice replies — "the
nine-fourteen from York… that's the LNER Azuma to King's Cross, twenty
carriages of blue and red, two diesel engines coupled at Edinburgh, and
today the conductor is called Mr Adeyemi. Shall we get on?" A small
illustration fades into the bottom half of the screen: the front of an
Azuma at platform three, painted by a hand that draws the way picture
books draw. The train pulls out. Every ninety seconds a new station; a
new picture; a new gentle question. Eight minutes later the train
arrives and the voice says, "we're at King's Cross. Mr Adeyemi says
thank you for travelling. Shall we wait for the next one?"
And the harder cases — the four-year-old on the way to a paediatric MRI,
the kid whose train-obsessed older sibling died and who now needs train
stories without sadness in them, the autistic six-year-old who only
calms down when someone speaks slowly about timetables — are why the
app refuses every cliché: no danger arcs, no missing trains, no jokes
that depend on something going wrong, no jolly sound effects, no bright
flashing screens. A calm voice. A real route. A picture that fades in.
Pause when the kid speaks. Resume when the kid wants more.
**Tagline:** _A calm in-character narrator who knows your kid's favourite trains — in any country, on any line, for as long as the journey takes._
## 2. Target audience
- Parents of train-obsessed pre-schoolers (3-6) on repeat car-rides, long walks, pram pushes, the daily nursery school commute, the ferry queue, the airport gate
- Parents of autistic and sensory-sensitive children who need a calm, predictable, content-rich narrator without ads, jingles, or surprise volume changes
- Parents in countries with rich rail networks the family actually rides — Japanese-American families with shinkansen-curious kids, Nigerian-British families on the Underground, Indian-Australian families on Sydney's NSW TrainLink, Mexican-American families on Mexico City's Metro, Filipino-Canadian families on the Vancouver SkyTrain
- Grandparents who babysit and want one tool that calms their grandchild on the school run for a week of holidays
- Parents waiting in paediatric hospital waiting rooms, ER triage, dentist's chairs, vaccinations — a calm story that lasts exactly as long as the wait
- Bereaved families looking for gentle content for a small child who used to share train videos with a sibling who has died — no surprise sadness, no danger arcs
- Long-distance separated parents who want to record a short greeting in their own voice that the in-character narrator hands off to ("Dad says hello from Tokyo — shall we look at the Yamanote Line together?")
- Multilingual families who want the narration in the family's first language — Mandarin, Cantonese, Tagalog, Vietnamese, Tamil, Hindi, Urdu, Bengali, Punjabi, Amharic, Swahili, Farsi, Khmer, Korean, Japanese, Arabic, Spanish, Portuguese, French, German, Italian, Polish — with the same in-character voice
- Childminders and nursery teachers running quiet hours after lunch with a group of pre-schoolers
## 3. Core value propositions
Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons grown-ups choose this app for the kid in their arms.
- **In-character live narration that does not run out of story** — Gemini Live API holds the character (the calm narrator, "Mr Adeyemi the conductor", "Sumire the station guard") for as long as the journey takes. No script ends. No looping. The voice responds to whatever the kid says back, in character, without breaking the spell.
- **A real route, on a real timetable, not a fantasy** — the 9:14 from York is the actual LNER Azuma to King's Cross. The 8:02 from Tokyo Station to Shin-Yokohama is a real Hikari shinkansen. The 06:53 Morden-bound Northern Line train stops at all 49 stations. The narrator names the real intermediate stops in their right order. Grounded — never invented.
- **A seat-side window that draws what the kid sees** — Nano Banana 2 fades in a new illustration every ninety seconds: the front of the train at the platform; the view from the carriage of a river; a station roof; the conductor walking the aisle. Picture-book style, warm gouache, never photoreal. The pictures match what the narrator just said.
- **Calm by design — no danger, no fear, no chaos** — no near-misses, no "oh no!", no missed stops, no jingles, no jump scares, no bright flashing. A real rule the model follows on every turn. A calm voice, a soft pace, the same warmth from start to finish.
- **Vocabulary calibrated to ages 3-6** — no "telematics", no "electromultiple unit", no "diagrammatical timetable". The model speaks at the kid's level — short sentences, concrete nouns, words a four-year-old has met before, with one new train word per minute introduced gently ("the **pantograph** is the bit on the roof that touches the wire").
- **The kid can talk back, and the narrator listens** — when the kid says "is there a buffet car?" mid-story, the voice replies in character: "there is — coach H, two doors down. Would you like to know what they sell?" When the kid stops talking, the journey resumes. The Live API turn-taking does the work; the parent does not have to intervene.
- **Designed for the hand of an adult, used by a child** — the only big button on the first screen says "Trains". Everything else is hidden in a small grown-up settings drawer. No ads ever. No in-app purchases ever. No subscriptions in v1.
- **The kid's name and saved journeys stay on the device** — names, voice clips of the parent saying hello, the list of journeys ridden, the favourite character — none of it goes to the cloud unless the grown-up explicitly turns sync on for a co-parent.
## 4. Features to build
- One enormous tap target on the home screen labelled **"Trains"** — taps in big-headphones-friendly haptic — opens the listening conversation
- "Tell me about" prompt: kid speaks the name of a train, line, or station; if mute, a soft scrolling list of suggestions paced for a 4-year-old's attention
- Live API in-character narrator with named character cards (calm conductor, friendly station guard, the train itself as a character) and a parent-managed default character
- Real-route grounding: the narrator names actual intermediate stations in their actual order — Shinjuku→Yoyogi→Harajuku→Shibuya, not a hallucinated sequence
- Live timetable awareness when online: the narrator can say "this train would be running in real life right now" or "this train is parked at the depot tonight — let's catch its sister train instead"
- Seat-side illustration every ninety seconds (Nano Banana 2) — picture-book style, the parent picks the visual style on setup; the kid never picks
- A small "what do you see?" beat every two stations — gentle, open-ended, in character, never a quiz, never a wrong answer
- Pause for the kid to speak — the Live API listens, replies in character, resumes the journey when the kid is done
- Multilingual narration — pick the family's first language at setup; the narrator speaks in that language; the on-screen captions show both languages for older bilingual kids
- Parent voice handoff — the grown-up records a short greeting in their own voice ("Dad says hello from Tokyo"), and the in-character narrator hands off to the recording at the start of each journey
- Saved journeys — the kid's favourite trips are saved on-device; reload one with one big tap
- Bedtime mode — same narrator, slower pace, lower volume, screen dimmed to ember; ends in silence, no notifications
- Big-grown-up settings drawer behind a long-press in the corner: language, narrator voice, illustration style, journey length, "tonight no danger words", co-parent sync toggle, delete-everything
- Co-parent sync (off by default, opt-in) — when on, journeys made by one parent appear on the other parent's device under the same kid's name; nothing leaves the family
- Offline pack — pre-cached narration for the kid's top three favourite trains, so the long ferry ride works without signal
- Live API caption track in two scripts (Latin + the family's heritage script — Devanagari, Hangul, Hanzi, Arabic) for parents who want the kid to start recognising the heritage script
- Accessibility profile — "reduced motion" turns illustrations into still cross-fades; "high contrast" thickens the picture-book lines; "no sound effects" runs voice-only the whole time
- A "next clear journey" surface for kids who like real timetables — the narrator can say "tonight the next Eurostar from St Pancras leaves at 19:01 — shall we ride that one?"
- A grown-up-facing "what we did today" recap — one short paragraph, hidden behind the settings drawer, never surfaced to the kid
- One-tap "I need to drive now" — pauses the journey gracefully, voice says "we'll wait at this station until you come back", picks up where it left off
## 4b. Required Gemini capabilities + backend services
**This template's intelligence comes from the Gemini capabilities below. Wire them up explicitly — don't substitute generic LLM calls.**
### Gemini capabilities (the load-bearing intelligence)
- **Gemini Live API (audio-in, audio-out, low latency)** — the heart of the app. A single Live API session per journey holds the character, listens for the kid's interjections, replies in character, and resumes the narration. Use the bidirectional streaming endpoint. Pin a `systemInstruction` that locks the character, the vocabulary band (ages 3-6), the calm-only constraint, and the route. Voice activity detection (VAD) handles turn-taking; configure barge-in so the narrator stops mid-sentence when the kid talks. Target end-to-end latency under 800 ms.
- **Gemini 3.5 Flash (planning + structured output)** — runs ONCE before each journey to plan the route: real intermediate stations in their right order, approximate seat-side picture beats every 90 s with one-sentence visual prompts, two-three "what do you see?" question beats, total journey length matched to the parent's "journey length" setting (typically 5, 8, 12, or 20 minutes). The plan is structured JSON; the Live API session receives it as part of its system context.
- **Gemini 3.5 Flash + `google_search` grounding** — resolves the route at plan time. A query like "Northern Line, Morden to Edgware via Bank, all stations in order" returns a grounded list of stations with citation URLs from `response.groundingMetadata.groundingChunks[].web.uri`. The grounding citation is logged server-side for the parent's "how was this made" panel; it is never shown to the kid.
- **Nano Banana 2 (`gemini-3.1-flash-image`)** — generates the seat-side illustrations. Each picture is generated from a short visual prompt the planner produced and the visual-style preset the parent picked at setup. Style consistency across a single journey is preserved by passing the previous picture as a reference input; the narrator and the conductor look like the same character from picture to picture.
- **Gemini TTS (`gemini-3.1-flash-tts-preview`)** — used ONLY for the offline pack (pre-cached narration for the kid's top three favourite journeys) and for the parent voice handoff cross-fade. The live journey uses the Live API's own audio output; do not double up. Pick the TTS voice whose `languageCode` matches the family's chosen language.
- **Thinking levels** — `medium` for the route planner (it has to reason about which trains are real, in which order, with what visual beats). `low` for the per-picture visual prompt expansion. **`thinkingConfig` is omitted entirely on the Live API call, on the Nano Banana 2 call, and on the TTS call — those models do not support it.**
### Backend services
- **Auth — Required, but minimal.** Firebase Auth with Google sign-in for the parent (auto-provisioned by AI Studio Build). The kid never signs in. The kid's name and saved journeys are stored on-device only by default. Co-parent sync (opt-in) ties two Google accounts to one kid profile. Apple sign-in is optional but **requires the user to provide an Apple Developer Service ID, Key ID, and private key** and wire them into Firebase Auth — flag in the capabilities panel as user-config.
- **Database — Required.** Firestore for: `parents` (one doc per signed-in adult), `kids` (one doc per child under a parent or pair of co-parents, **kid's name lives here only if co-parent sync is ON**), `journeys` (one doc per saved journey, stored under the kid), `routes_cache` (a per-route grounding-call cache so the same Northern Line route is not re-resolved twice). When co-parent sync is OFF (default), nothing about the kid is written to Firestore — everything lives in the device's IndexedDB.
- **File storage — Required.** Firebase Storage for the generated seat-side illustrations (cached so the same journey replayed at bedtime does not re-generate pictures), the parent's recorded "hello" voice clips, and the offline-pack pre-cached audio. **Storage is NOT auto-provisioned by AI Studio Build today** — the user enables it in the Firebase console and pastes the bucket name into the AIS Build project. Pre-signed URLs only. Co-parent sync mirrors these objects; non-sync mode keeps them in device storage.
- **Email — Not used.** No marketing email, no invitations email, no transactional email in v1. Co-parent sync uses the in-app share-link flow (the other parent signs in with their own Google account and accepts).
- **Payments — Not needed for v1.** Free for personal use. Hard rule: no in-app purchases ever; the kid is four. A future tier might charge a flat one-time fee for the offline pack — never a subscription, never an ad surface, never targeted to the kid.
- **External APIs:** Gemini API for all intelligence. No third-party rail-data API is required — `google_search` grounding handles route resolution from publicly-indexed timetables. Optional: if the parent enables it, an open-data rail-disruption feed (TfL Open Data, JR East public API, Amtrak status JSON) for the "this train is running in real life right now" surface — never required.
**Environment variables:** every secret (Gemini API key, Firebase service-account JSON, optional rail-data API tokens) lives in environment variables — never in client bundle. Include a `.env.example`.
**Auth + data privacy reminders:** never log secrets · never store passwords in plain text · use HTTPS everywhere · honour 'delete everything' inside the grown-up drawer (the kid's name, every saved journey, every parent voice clip — gone in 30 seconds) · explicit opt-in for analytics, defaulted OFF · the kid's voice is never sent to Gemini for model training (use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms) · no advertising surface anywhere · no in-app purchases · the kid's name and saved journeys stay on the device unless co-parent sync is explicitly turned on.
**Read this first — prompt-craft rules that apply to every call in this template:**
1. **Name the model variant explicitly** in every Gemini API call. Do not let the agent pick the model. See the per-call matrix below.
2. **Pin `thinkingLevel` explicitly** per call. See the matrix. **Omit `thinkingConfig` entirely on Live API, Nano Banana 2, and TTS calls** — those models do not support it. The matrix `n/a` cells are documentation only; do NOT serialise `n/a` into the request body.
3. **Seed the JSON Schema as a fenced TypeScript / Zod block** in the system instruction or `responseSchema` field. The literal schema is below. **Convert the Zod schema to Gemini's `Schema` type via the SDK helper** before passing to `responseSchema` — do NOT pass raw Zod. **Numeric `min`/`max` constraints are documentation only inside `responseSchema`; clamp on the server after the response arrives.**
4. **Pin the system instruction separately** from user input. Use the `systemInstruction` field for persona + behavioural rules; use `contents` for user input. Never concatenate.
5. **Pre-declare tools as an enable/disable list** per call. The matrix below names which tools are enabled per call. Tools NOT listed for a call should be disabled. **`google_search` grounding and `responseSchema` cannot both be specified on the same Gemini call.** When grounding is used, instruct the model to emit JSON in the text body and parse server-side; read citations from `groundingMetadata.groundingChunks[].web.uri`.
6. **State negative constraints explicitly** — they are listed below. They are NOT "be careful" suggestions; they are hard rules the model must follow.
7. **Grounded responses can wrap JSON in ```json fences or add prose preamble.** Server-side, strip fences and brace-extract:
```typescript
function safeExtractJSON(raw: string): T {
const clean = raw.replace(/```json\s*|```/gi, '').trim();
const s = clean.indexOf('{'); const e = clean.lastIndexOf('}');
if (s === -1 || e === -1) throw new Error('No JSON boundaries in grounded response');
return JSON.parse(clean.slice(s, e + 1)) as T;
}
```
8. **Strip unsupported Zod modifiers before passing to `responseSchema`** — Gemini's OpenAPI subset rejects `.regex()` / `pattern`, fixed-length `z.tuple()`, and other custom validators. Use a sanitizer that flattens tuples to length-2 arrays and removes regex patterns before serializing. Validate those constraints in middleware AFTER parsing.
### Per-call model + tools matrix
| Call | Model | thinkingLevel | Tools enabled |
|------|-------|---------------|---------------|
| Resolve real route → ordered station list (plan-time) | `gemini-3.5-flash` | low | `google_search` grounding (NO `responseSchema` on this call — emit JSON in body, parse server-side) |
| Plan the journey (length, picture beats, question beats) | `gemini-3.5-flash` | medium | (none) — uses route list from previous call as input |
| Live in-character narration (per journey) | `gemini-3.1-flash-live-preview` (Live API) | n/a | none — system instruction holds the character, voice activity detection handles turn-taking |
| Generate seat-side illustration (per beat) | `gemini-3.1-flash-image` (Nano Banana 2) | n/a | n/a — image input is the previous beat's illustration for style consistency |
| Pre-cache offline narration for a saved journey | `gemini-3.1-flash-tts-preview` | n/a | n/a |
| Parent-facing recap ("what we did today") | `gemini-3.5-flash` | low | (none) — input is the journey's structured event log |
*Note for builders:* the Live API call uses a different SDK shape (`live.connect()` with audio chunks streaming both ways) than `generateContent`. Pin the Live model id explicitly; the Live API has its own model family — do NOT pass `gemini-3.5-flash` to the Live endpoint, it will fail. On TTS and image-generation calls, omit `thinkingConfig` entirely — it is not a supported field on those models. The `n/a` cells are documentation only; do not serialise them into the request body.
### Primary structured-output schema (seed this verbatim in the prompt)
```typescript
import { z } from "zod";
const Station = z.object({
name_verbatim: z.string(), // "Shinjuku", "Morden", "York"
name_local_script: z.string().nullable(), // "新宿", "মর্ডেন", null if same as verbatim
index_in_route: z.number().int().min(0),
approximate_dwell_seconds: z.number().int().nullable(),// real timetable dwell, if known
is_interchange: z.boolean(),
is_terminus: z.boolean(),
});
const PictureBeat = z.object({
beat_index: z.number().int().min(0),
triggered_at_seconds: z.number().int(), // when in the journey this picture fades in
one_sentence_visual_prompt: z.string(), // short, concrete, sensory — fed to Nano Banana 2
caption_for_grownup_panel: z.string(), // shown in the grown-up "how was this made" drawer only
references_previous_picture: z.boolean(), // true if Nano Banana 2 should pass the previous image
});
const QuestionBeat = z.object({
beat_index: z.number().int().min(0),
triggered_at_seconds: z.number().int(),
open_question_for_the_kid: z.string(), // "what do you think is in the goods wagon at the back?"
one_sentence_followup_if_kid_silent: z.string(), // a soft prompt if the kid says nothing
in_character_acknowledgement_template: z.string(), // a short pattern the narrator uses to acknowledge any answer
});
const CharacterCard = z.object({
character_id: z.string(), // "conductor_calm", "station_guard_friendly", "the_train_itself"
display_name_for_grownup: z.string(), // "Mr Adeyemi the conductor" — only shown in grown-up drawer
in_character_self_introduction: z.string(), // 1-2 sentences the narrator opens with
voice_style_directive: z.string(), // "calm, unhurried, kind; pauses for breath; no gusto"
vocabulary_band: z.enum(["ages-3-4", "ages-5-6", "mixed-3-6"]),
language_bcp47: z.string(), // "en-GB", "ja-JP", "yo-NG", "es-MX"
});
const JourneyPlan = z.object({
journey_id: z.string(),
kid_facing_title: z.string(), // "The 9:14 from York"
route_country: z.string(), // "United Kingdom"
route_line_or_service: z.string(), // "LNER Azuma, York → King's Cross"
origin_station: z.string(),
destination_station: z.string(),
intermediate_stations: z.array(Station),
total_journey_seconds: z.number().int().min(180).max(1800), // 3 to 30 minutes; clamped server-side
character_card: CharacterCard,
picture_beats: z.array(PictureBeat),
question_beats: z.array(QuestionBeat),
grounding_citation_uris: z.array(z.string()), // logged for the grown-up drawer, never shown to the kid
is_real_route_at_plan_time: z.boolean(), // false → the planner refused; surface a "we couldn't verify this train" to the grown-up
notes_for_grownup: z.string().nullable(), // shown in the grown-up drawer only
});
type JourneyPlan = z.infer;
const LiveEvent = z.object({
event_id: z.string(),
journey_id: z.string(),
timestamp_iso: z.string(),
kind: z.enum([
"narration_segment_started",
"narration_segment_ended",
"kid_spoke",
"narrator_responded_in_character",
"picture_beat_triggered",
"question_beat_triggered",
"journey_paused_by_grownup",
"journey_resumed",
"journey_ended_at_destination",
"journey_ended_early_by_grownup",
]),
transcript_text: z.string().nullable(), // never sent to model training, never shown beyond grown-up drawer
picture_beat_uri: z.string().nullable(),
});
```
### Common failure modes (and how to avoid them)
- Agent picks `gemini-3.5-flash` for the Live narration to "be smarter" — pin the Live API model (`gemini-3.1-flash-live-preview`) explicitly. The Live endpoint is its own SDK call; passing a non-Live model will fail at connect time with a misleading error.
- Live API request includes `thinkingConfig` — the Live model rejects the request. **Omit `thinkingConfig` entirely** on Live calls.
- Nano Banana 2 call includes `responseSchema` or `thinkingConfig` — both are not supported on image-generation calls. Omit both.
- The route planner is allowed to hallucinate stations on the Tokyo Yamanote Line — pin grounding ON for the route-resolution call. The model without grounding will confidently invent station orders and the parent will catch it on the first car ride.
- `responseSchema` and `google_search` both set on the route-resolution call — Gemini will reject. Use grounding only; instruct the model to emit JSON in the response body and parse server-side; read citations from `response.groundingMetadata.groundingChunks[].web.uri`.
- Live API session lacks an explicit `systemInstruction` and the narrator drifts out of character mid-journey. The system instruction MUST be ≥30 lines and explicit about character + vocabulary band + calm-only rule.
- Nano Banana 2 produces a different-looking conductor in every picture — pass the previous picture as a reference input on every beat after the first, and reference the conductor by name + clothing in every visual prompt.
- Voice activity detection (VAD) sensitivity too high — the narrator pauses every time the parent talks to the driver in the front seat. Set VAD sensitivity to "low" on the Live config so only intentional kid-facing speech triggers turn-taking; expose the toggle in the grown-up drawer.
- The narrator answers a kid's question by leaving the train ("let's get off and look around the city!") and then can't get back on the timetable — pin in the system instruction: "you are always on the train; questions are answered from the seat".
- The narrator introduces a danger arc to add interest — "oh no, the train is going to miss the platform!". Hard-banned in the system instruction. Calm by design.
- Kid says something the narrator can't parse — the narrator must NEVER ask "what?" or "I don't understand". Pin: "respond warmly, acknowledge the kid spoke, continue in character with a soft bridging phrase".
- Multi-language drift — the parent picked Japanese; halfway through the journey the model slips into English because the kid said an English word. Pin: "stay in the chosen language for narration; mirror back the kid's word if they used another language, then continue".
- The narrator says the kid's name aloud and the kid's name was a typo the parent fixed last week — the Live session was holding the stale name. Re-seed the system instruction with the current kid name at the start of every journey, never trust the previous session's context.
- The Live API session exceeds the per-session minute budget the grown-up set ("12 minutes maximum") — the parent expected a 12-minute drive and the journey is still going at 18. Enforce the limit server-side: when the configured total elapses, the narrator says the goodbye line and disconnects; do not rely on the model to self-limit. **Also note the platform cap: Live API audio-only sessions are limited to 15 minutes; for 20-minute journeys, stitch two sessions back-to-back, carrying the character + plan state across the seam so the kid never hears the break.**
- Long-context bloat — if the parent runs ten journeys in one car ride, the per-session transcript balloons. Each new journey is its own Live session; do not accumulate transcripts across journeys.
### Negative constraints (hard rules)
- Do NOT use language outside the ages 3-6 vocabulary band. No "telematics", no "diagrammatical", no "deadheading", no "electromultiple unit". Train words (pantograph, signal box, buffer stop, rolling stock) are allowed but introduced gently — one new word per minute, with a one-sentence explanation in kid terms.
- Do NOT introduce danger, fear, jeopardy, missed trains, near-misses, "oh no" moments, jump scares, loud noises, bright flashing, surprise sadness, or anything a calm parent would not voice. Calm by design, every turn.
- Do NOT advertise. No product names except the real train name itself (e.g. "the Azuma", "the shinkansen", "the Northern Line"). No "did you know that [brand] makes the best train sets?". No "ask your grown-up to subscribe". No mention of any sponsor.
- Do NOT propose an in-app purchase, ever. The kid is four.
- Do NOT collect or transmit the kid's voice for model training. The Live API session runs on the paid tier; Google does not use the content for training, per the Gemini API Additional Terms. The grown-up drawer says this in plain English.
- Do NOT use the kid's name in any analytics event, log, or telemetry. The kid's name lives on the device (or in Firestore only if co-parent sync is on) and is never bundled with usage stats.
- Do NOT show ANY content to the kid that wasn't generated for this journey. No carousels of "other kids loved this train". No recommendations. The kid sees one screen at a time: the big "Trains" button, or the journey-in-progress screen.
- Do NOT add notifications. The app never pings the kid (or the parent) outside the session.
- Do NOT auto-play a journey on app open. Always wait for the kid to tap "Trains" — the grown-up may be putting on shoes.
- Do NOT translate proper nouns. "Shinkansen" stays "shinkansen" in an English-language narration. "Eurostar" stays "Eurostar". "Northern Line" stays "Northern Line". Station names are spoken in their local pronunciation.
- Do NOT modernise place names. If the kid asks about "Petrograd", the narrator says "we'd call that Saint Petersburg today" but tells the story with "Petrograd" in it.
- Do NOT extrapolate from a kid's silence. If the kid does not answer a "what do you see?" beat within 6 seconds, the narrator continues with the soft follow-up — never with "are you still there?".
- Do NOT include any imagery of accidents, crashes, derailments, war-time train history, or distress. Picture-book gouache only. Daytime or evening; never night-time crash scenes.
- Do NOT show the kid the grown-up panel. The grown-up panel is behind a long-press in the corner. The kid will not find it by accident.
### Per-call `systemInstruction` strings
Use these as the literal `systemInstruction` field for each Gemini API call the built app makes. They complement the series-wide rules already uploaded as the global instructions file (`00-series-instructions.txt`).
### Call: Resolve real route → ordered station list (plan-time)
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding (no `responseSchema`)
```
You resolve a real-world train route to its ordered list of stations.
Given a free-form description of a train, line, or service that a
small child has named — for example "the nine-fourteen from York",
"the Northern Line from Morden to Edgware", "the Hikari from Tokyo
Station", "the Amtrak Coast Starlight", "the Sydney T1 from Central
to Hornsby" — use `google_search` grounding to find the real
intermediate stations in their right order, the real line name, and
the real origin and terminus.
Output the response as JSON in the text body, with this shape:
{
"route_country": "...",
"route_line_or_service": "...",
"origin_station": "...",
"destination_station": "...",
"intermediate_stations": [
{ "name_verbatim": "...", "name_local_script": "..." or null,
"is_interchange": true/false, "is_terminus": true/false }
],
"is_real_route_at_plan_time": true/false,
"notes_for_grownup": "..." or null
}
Hard rules:
- Use grounded search. Do NOT invent stations. If you cannot verify
any part of the route, set `is_real_route_at_plan_time` to false
and put a short explanation in `notes_for_grownup`.
- Preserve station names in their local language. Tokyo stations
carry both "Shinjuku" and "新宿". Mumbai stations carry both
"Chhatrapati Shivaji Maharaj Terminus" and the verbatim Marathi.
Lagos stations carry the local Yoruba spelling where one exists.
- Do NOT translate proper nouns. "Shinkansen", "TGV", "Eurostar",
"Northern Line", "Amtrak", "Bakerloo", "Yamanote-sen" stay
verbatim.
- Do NOT include any cancelled, closed, or under-construction
stations. If a station is currently closed for engineering, mark
it but leave it in the ordered list with a brief note.
- For circular lines (Yamanote, Circle Line) pick the most common
travel direction; note it in `notes_for_grownup`.
- Do NOT include URLs in the JSON body. Server-side code reads
citation URIs from `groundingMetadata.groundingChunks[].web.uri`.
- If the kid named a service that does not exist (a hallucinated
train, a misheard line), set `is_real_route_at_plan_time` to
false and suggest in `notes_for_grownup` the closest real
service. Do not invent a fake match.
No commentary outside the JSON.
```
---
### Call: Plan the journey (length, picture beats, question beats)
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none)
```
You receive a resolved route (origin, terminus, ordered intermediate
stations) and the grown-up's settings (journey length in minutes,
language, vocabulary band, illustration style, character card).
Your task: produce a `JourneyPlan` JSON object matching the schema
below. The plan drives the Live API narration session and the Nano
Banana 2 illustrations.
Hard rules:
- Pick a `total_journey_seconds` close to the grown-up's setting
(typically 5, 8, 12, or 20 minutes). Clamp between 180 and 1800.
The journey ends at the destination — not mid-route.
- Place a picture beat every 75-105 seconds. The first beat is at
t=0 (the train pulling out of the origin). The last beat is at
destination-arrival. Each beat has a one-sentence visual prompt
fed to Nano Banana 2.
- The visual prompt is concrete and sensory and matches what the
narrator will be saying at that moment in the journey. "The
Azuma's nose pulling out of York under a grey morning sky, two
birds on the platform clock." Never abstract; never "a train".
- Place a question beat every 90-180 seconds. Each question beat
is open-ended and answerable by a four-year-old with one word
or one short sentence. "What colour do you think the inside of
the buffet car is?" "What do you think Mr Adeyemi is having for
lunch?" Never a quiz; never a wrong answer; never a yes/no.
- The character card is provided. Do NOT invent a different one.
The character's voice style directive and vocabulary band are
load-bearing.
- Vocabulary band drives word choice. For "ages-3-4", short
sentences (≤8 words), concrete nouns, one new train word per
minute. For "ages-5-6", sentences up to 12 words, slightly
richer train vocabulary, gentle questions. For "mixed-3-6",
default to the younger band with one richer aside per minute.
- Use the local script for station names where one exists. The
caption track and the spoken narration both reference the local
pronunciation.
- Do NOT plan danger arcs, near-misses, missed connections,
surprise sadness, jokes that depend on something going wrong,
or any element that breaks the calm-by-design rule. Calm by
design is a load-bearing constraint, not a tonal preference.
- Do NOT plan an in-app purchase prompt, an advertisement, a
recommendation carousel, or any UI surface beyond the journey
itself.
- `notes_for_grownup` is for the grown-up panel only. Use it to
flag any compromise (e.g. "the Northern Line splits at
Camden Town — I planned the High Barnet branch; the grown-up
can edit on next journey").
Output ONLY the `JourneyPlan` JSON. No commentary.
```
---
### Call: Live in-character narration (per journey)
Model: `gemini-3.1-flash-live-preview` · n/a · n/a (Live API, bidirectional streaming)
```
You are the in-character narrator of a calm train journey for a
small child between three and six years old. The character card,
the route plan, the picture beats, and the question beats are in
the session context.
Speak warmly, unhurriedly, kindly. Pause for breath. Use the
language and vocabulary band specified. Stay in character for the
entire journey.
You are always on the train. Every question the child asks is
answered from the seat: looking out of the window, glancing down
the aisle, asking the conductor. You never get off until the
destination.
Hard rules:
- Stay in the named character. If the character is Mr Adeyemi the
conductor, you ARE Mr Adeyemi for this journey. Refer to
yourself as "I" if the character would. Refer to the kid as
"my friend" or by the kid's name if it was passed in. Never
break character. Never say "as an AI" or "as a language model".
- Vocabulary band is load-bearing. For "ages-3-4", short
sentences, concrete nouns, one new train word per minute,
explained in kid terms. For "ages-5-6", up to 12-word
sentences. Never use words a four-year-old will not have met.
Train vocabulary is allowed but introduced gently — when you
say "pantograph", you say what it is in the same breath.
- Calm by design. No danger, no fear, no near-misses, no missed
trains, no jokes that depend on something going wrong, no loud
noises, no excited gasps, no "oh no". You voice everything
evenly, kindly, with the same warmth from start to end.
- You listen for the kid's voice. When the kid speaks, stop
speaking immediately. Listen. Respond in character. If the kid
said something you cannot parse, respond warmly anyway — never
say "what?", never say "I don't understand", never ask the kid
to repeat. Acknowledge the kid spoke ("that's a good thought,
my friend"), give a soft in-character response, and return to
the journey.
- Stay in the chosen language. If the kid speaks a word in
another language, mirror it back briefly, then continue in the
chosen language. Do NOT drift.
- Match the journey to real time. When a picture beat is
triggered, mention what the kid is seeing in the seat-side
window in the same breath ("look — the green hills near
Doncaster"). Do NOT describe the picture as a picture; describe
it as the view from the train.
- Question beats are open-ended. Ask gently. Wait. If the kid is
silent past six seconds, use the planned soft follow-up. Never
say "are you still there?". Never imply the kid is missing.
- Do NOT use the kid's surname. Do NOT use the kid's full address
or other family details, even if you somehow have them. The
kid's first name is enough.
- Do NOT advertise. No product names except the real train
names. No "would you like to buy?". No "ask your grown-up".
- Do NOT extrapolate to dark history. If asked about a station
with a wartime past, answer in the kid's vocabulary band and
redirect gently to the train at the platform.
- At the destination, name the terminus, name the conductor,
thank the kid for travelling, ask if they want to wait for
another train. Do NOT keep narrating past the destination.
The session has a hard time budget set by the grown-up. When the
elapsed time approaches the budget, gracefully bring the journey
to its destination — not abruptly.
No commentary. You are the narrator only.
```
---
### Call: Generate seat-side illustration (per beat)
Model: `gemini-3.1-flash-image` (Nano Banana 2) · n/a · n/a
```
Generate a picture-book illustration for a small child to see in a
seat-side window while a calm narrator speaks. Style: warm gouache,
visible brush strokes, soft edges, slightly off-register colours,
the way a contemporary picture-book illustrator paints. Never
photoreal. Never CGI. Never anime. Never glossy "AI render". Never
night-time crash scenes.
The visual prompt for this beat is in the user input. Use it
literally — paint what the prompt says, in the chosen style.
Style consistency across the journey:
- If a previous picture from this journey is provided as a
reference input, match the character designs (conductor, station
guard, the kid in the carriage if any), the train livery, the
weather, and the time of day. The conductor in beat 4 must be
recognisably the same conductor as in beat 1.
- Do NOT introduce characters the prompt does not mention. The
conductor is in the visual prompt; do not add additional
passengers unless the prompt asked for them.
- Do NOT include text, signage, or station names rendered as
letters in the image. Stations are named in the audio narration,
not painted into the picture. Exception: a single train number
on the front of the locomotive is allowed if the prompt names it.
Hard rules:
- Calm by design. Daytime or evening only. No crashes, no
derailments, no fire, no smoke beyond a gentle steam-engine plume
if the train is a steam train. No war-time imagery. No distress.
- No people in distress; no crying children; no medical scenes
even if the journey starts at a hospital (the journey-from-
hospital case is handled by starting the illustration at the
platform).
- The kid in the carriage, if depicted, is a non-specific child of
the age range — never the user's actual kid (the app has no
reference photograph; do not invent one).
- Background colours stay in a warm, gentle palette appropriate
for a four-year-old's bedtime book.
- Aspect ratio: 4:3 portrait, suitable for the seat-side window
on a phone screen held in landscape by the grown-up. If the
prompt names a specific aspect ratio, follow that instead.
Output: one image, picture-book gouache style, matching the visual
prompt and the previous image's style. No text overlay.
```
---
### Call: Pre-cache offline narration for a saved journey
Model: `gemini-3.1-flash-tts-preview` · n/a · n/a
```
Voice: warm, unhurried. Pick the Gemini 2.5 Flash TTS voice whose
`languageCode` matches the family's chosen narration language —
pronunciation follows the voice's native locale automatically.
Prefer a voice whose published gender matches the character card
where one is specified; fall back to whichever is available rather
than blocking.
This call is ONLY for the offline pack: the grown-up has saved a
favourite journey and the kid is about to ride a long ferry with
no signal. Pre-render the narration so the journey works offline.
Live journeys use the Live API; do not double up.
Pre-process the text before sending to TTS:
- Insert a single ellipsis (`…`) at sentence breaks for natural
short pauses. Insert a blank line plus an em-dash (`—`) at
paragraph breaks for longer pauses. Gemini 2.5 TTS does not
support SSML `` — these textual cues are how you
signal pace.
- Skip stage directions ("[the train pulls out]") — they are for
the picture beat, not the audio. Skip the kid's planned soft
follow-up prompts (they are spoken live in the offline player
by a small local TTS shim or are pre-rendered as separate
short clips).
- Target rate: ~110 words per minute — letter-reading pace, not
podcast pace. The narrator reads to a four-year-old.
Style direction: prepend ONE short directive sentence to the text
input, exactly like: "Read warmly and unhurriedly, in character as
the calm train conductor, to a child between three and six. …".
There is no separate `style` API field on Gemini 2.5 TTS; the
directive sentence inside the input is how style is conveyed.
Phoneme overrides (Polish ł, Japanese long vowels, Yoruba tones,
Tamil retroflex consonants) are NOT exposed by Gemini 2.5 TTS —
no SSML `` tag. Pronunciation comes from the chosen
voice's native locale. If the chosen language is Yoruba and the
voice catalogue does not list a Yoruba voice, fall back to the
closest published voice (e.g. English (Nigeria)) and surface a
note in the grown-up drawer rather than failing silently.
Mid-call voice switching is not supported. If the journey
narration includes any short phrase in another language (a
kid said a word in Tagalog and the narrator mirrored it back),
keep the whole render in the chosen voice — pronunciation
within the inserted phrase will follow the voice's native
locale.
No commentary outside the audio output.
```
---
### Call: Parent-facing recap ("what we did today")
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive the structured event log of one or more journeys the
kid rode today. Produce a single short paragraph for the grown-up
panel ONLY — never shown to the kid.
The paragraph names: the journeys (real route names), the rough
duration, one or two things the kid said that the grown-up might
want to know ("she asked twice about the goods wagon"), and any
journey that ended early because the grown-up pressed "I need to
drive now".
Hard rules:
- Do NOT include the kid's full transcript. The kid's voice is
private to the family. Summarise themes; quote at most one or
two short phrases the kid said, only if non-sensitive.
- Do NOT include marketing language ("a magical morning of
discovery"). Plain, kind, direct: "Kenta rode the 9:14 from
York this morning. He asked about the goods wagon twice and
laughed when Mr Adeyemi mentioned the conductor's cat. The
journey ran 11 minutes and ended at King's Cross."
- Do NOT recommend additional journeys. The grown-up will decide
what's next.
- Plain text. One paragraph. ≤ 80 words.
Output: a single string. No commentary.
```
## 5. Use cases & content to include
Build dedicated UI sections or flows for each of these — they tell you what content the app must support.
- **The shinkansen-obsessed four-year-old.** A Japanese-American dad in Seattle is driving Kenta to his grandmother's house in Tacoma for the third time this week. Kenta has watched every shinkansen video on the kids-tablet and the algorithm has nothing new. The dad opens Train Show, the character card is Sumire the platform guard at Tokyo Station (the dad set this up last weekend), the language is Japanese, the vocabulary band is "ages-3-4". Kenta says "Hikari from Tokyo to Shin-Osaka" and Sumire welcomes him onto coach 5. The journey is twelve minutes — exactly the drive to grandma's. Tokyo → Shinagawa → Shin-Yokohama → Odawara → Atami → Mishima → Shizuoka → Hamamatsu → Nagoya → Kyoto → Shin-Osaka. The dad never has to say a word.
- **The Northern Line every morning.** A Nigerian-British dad in Croydon walks Tobi to nursery every weekday morning. Tobi wants the Northern Line. The dad set the character card to "Mr Adeyemi the conductor" — the kid's grandfather had the same name. The language is English with a Lagos-warm voice when the catalogue has one, falling back to British English when not. The journey is six minutes — exactly the walk. Morden → South Wimbledon → Colliers Wood → Tooting Broadway. The Tube doesn't go past Tooting in six minutes but the journey ends gently at the picture beat of the carriage doors closing at Tooting Broadway and Mr Adeyemi saying "tomorrow we'll go all the way to the next stop".
- **The pediatric MRI waiting room.** A mother in Manchester is waiting with her four-year-old, Sofía, before a scheduled MRI. Sofía is frightened. Mum opens Train Show — they've never used it before, but the kid at the previous appointment had it. The character card defaults to a calm conductor. Sofía says, quietly, "Eurostar". Twenty minutes of the Eurostar from St Pancras to Paris Nord — that's most of the wait. No danger, no surprise sadness, no MRI mentioned. The picture beats are: the Eurostar's nose at the platform, the Channel under it, the Pas-de-Calais in green, the Gare du Nord roof. The narrator's calm voice slows Sofía's breathing. Mum keeps the journey saved for next time.
- **The autistic six-year-old who loves timetables.** A mother in Brisbane has a son, Marcus, who only really calms when someone speaks slowly about timetables. The grown-up drawer's bedtime mode is on by default. The character card is "the station announcer" — the voice the model uses is steady, monotone, kind. Tonight's journey: the Sydney Trains T1 from Central to Hornsby, every station named, the dwell time at each platform announced in seconds. Twenty minutes. Marcus falls asleep at Pymble.
- **The hand-off when the parent is travelling.** A mum in Tokyo on a work trip records, before her flight, a 5-second clip: "Mum says hello from Tokyo — shall we look at the Yamanote Line together?" That clip is saved as the parent-voice handoff. Two days later, in Vancouver, her husband opens Train Show with their five-year-old. The narrator starts with the recorded clip, then the in-character voice continues the journey. The wife is on a plane; the kid hears her.
- **The grandfather babysitting for a week of holidays.** A grandfather in Mumbai is looking after his grandson Aarav for ten days while the parents are away. The grandfather sets the character to a friendly station guard and the language to Hindi with the on-screen captions also showing Devanagari. The journey: Mumbai Central → Borivali on the Western Line, with every station name in Devanagari on the caption strip. The grandson learns to recognise the Devanagari letters for "Andheri" by Tuesday.
- **The bereaved family.** A father in Atlanta lost his older daughter, Mira, last year. His four-year-old son, Aman, used to watch train videos with her. Aman still asks for trains and his father can't always face the YouTube algorithm's lurch into "remember when". Train Show, with the calm narrator, no danger arcs, no sadness, no surprise references to the past — just the 6:53 from Atlanta to New Orleans, told kindly. The father set the grown-up drawer to "no danger words" (already the default) and "no sound effects".
- **The ferry queue.** A Filipino-Canadian mum on the BC Ferries terminal at Tsawwassen, two hours before her ferry. Her four-year-old daughter wants a train. The mum had pre-cached the kid's three favourite journeys overnight when she had wi-fi. The offline pack plays the SkyTrain from Waterfront to King George without a signal. The seat-side illustrations are cached. Twelve minutes per loop; three loops fills the wait.
- **The first weekend in a new country.** A newly-arrived family in Lisbon. The five-year-old, Lucia, has been talking about the Lisbon trams since they landed. The dad opens Train Show, picks Portuguese (Portugal), the character card is the friendly conductor of Tram 28. The journey starts at Martim Moniz, every stop named verbatim, the carriages painted yellow in the seat-side window. The dad learns the route alongside Lucia; tomorrow they ride it in real life.
- **The co-parented kid.** Two dads, one in Berlin and one in Munich, share custody of their four-year-old daughter every two weeks. They turned on co-parent sync. The kid's saved journeys appear on both phones. The Berlin S-Bahn journey she rode at one dad's house on Tuesday is on the Munich dad's phone on Saturday. The kid does not know about the sync; she just sees her trains.
## 6. Page structure
Build the following screens / sections in this order. Adjust copy to fit the voice, but keep the structural intent.
1. **First-launch grown-up setup.** Only shown once. One screen for: child's first name (saved on-device), child's age (3-6), narration language (picker, defaults to device locale), character card (calm conductor / friendly station guard / the train itself), illustration style (gouache picture-book is the default; one alternate is a warmer felt-tip style), default journey length (5, 8, 12, 20 minutes), parent voice handoff (optional — record a 5-second hello). One sentence at the top: "Set up once. Hand the phone to the kid." Skip is allowed (defaults apply).
2. **Home screen.** One enormous button: **"Trains"**. Centred. The button is the entire screen below a thin, calm header (kid's name in small type, today's date in small type, a tiny grown-up cog in the very corner). No carousels. No recent journeys list (kids find the saved-journeys list inside the conversation, not on the home screen). The button is reachable by a small hand without the grown-up holding the phone.
3. **The listening screen.** After the "Trains" button is tapped, the screen shows a calm waveform animation and the prompt "tell me about a train" in the kid's narration language. The Live API listens. If the kid is silent for 8 seconds, a soft scrolling row of suggestions appears below the waveform — gentle line drawings of three trains (e.g. the shinkansen, the Northern Line, the Eurostar) that the parent pre-seeded in setup or that the kid has ridden before.
4. **The journey-in-progress screen.** The whole screen is the seat-side window: a single illustration occupies the upper half (picture-book gouache, warm). The lower half is a calm, dim caption track — two lines of text in the chosen language, optionally bilingual. A barely-visible pause button appears only on a long-press by an adult hand (kids' taps do not surface it). Picture beats fade in and out (250 ms cross-fade) on the upper half. Question beats appear with a soft chime (an optional setting; default OFF) and a brief speech-bubble-style caption beneath the picture.
5. **Mid-journey kid talk-back.** When the Live API detects the kid spoke, a small ring of soft light pulses around the screen edge. The narrator stops, waits, replies in character, resumes. No "you said" transcript on screen — the kid is four; the kid does not read.
6. **Destination arrival screen.** The terminus illustration. The narrator says goodbye in character. Two soft choices appear: "another journey?" and "save this one". A long-press on "save this one" shows the grown-up the option to add it to the offline pack.
7. **Save journey flow.** Behind the scenes: the journey plan, the picture beats, and the chosen character are saved on-device (or to Firestore if co-parent sync is on). The grown-up can name the journey ("Kenta's morning Hikari").
8. **Saved journeys (kid-facing).** A simple row of recent journeys, each a single big tile with the destination illustration. Tap to ride again. The list is bounded (the last six journeys); older saved journeys live behind the grown-up drawer to avoid an endless scroll.
9. **Grown-up drawer.** Long-press the cog in the corner of the home screen. Slides up from the bottom; full-screen on mobile. Sections: kid's name & age, language, character, illustration style, default journey length, parent voice handoff, "no sound effects" toggle, "reduced motion" toggle, "high contrast" toggle, VAD sensitivity (low / medium / high), co-parent sync toggle, offline pack ("pre-cache the top three saved journeys"), "what we did today" recap, "delete everything" (irreversible, 30-second confirmation).
10. **Co-parent sync flow.** From the grown-up drawer → "share with another grown-up" → a one-time share link the other adult opens, signs in with their own Google account, accepts. After acceptance, the kid's name and saved journeys appear on the other adult's device. Either grown-up can revoke at any time.
11. **Offline pack screen.** The top three favourite journeys are pre-cached overnight (narration audio + illustrations). A small progress strip shows percentage cached. Once cached, the journey works without a signal. Cached journeys carry a small offline glyph in the saved-journeys row.
12. **Capabilities info button.** A `(i)` icon in the grown-up drawer — never on the kid-facing screens — opens the "what powers this app" panel.
13. **Footer (grown-up drawer only).** "Made for the kid in the back seat." Privacy: "Your kid's name and journeys stay on this phone. We never use your kid's voice for AI training." Tiny version number.
## 6b. First-visit onboarding
Show a **first-visit onboarding** the first time the grown-up signs in (detect via `localStorage` flag; do not show on return visits). Three slides, dismissible at any time. Persistent re-entry: a `?` icon inside the grown-up drawer reopens it.
**Slide 1 — What this is.**
- Headline: "Welcome to Train Show."
- Subhead: "A calm in-character narrator who knows your kid's favourite trains — in any country, on any line, for as long as the journey takes."
- One paragraph (≤ 60 words) explaining who this is for: a small child between three and six who loves trains, and a grown-up who has run out of stories. The narrator stays in character, the route is real, every journey is calm by design.
- Visual: a small annotated illustration of a phone showing the seat-side window — the picture-book conductor at the front, the captions beneath, the soft pulse of "the kid is speaking now" — not a generic stock illustration.
**Slide 2 — Try it now.**
- One short prompt: "Try with Kenta's Hikari from Tokyo".
- A live demo input pre-loaded with three saved journeys from the seed content in section 8a: the Hikari from Tokyo to Shin-Osaka, the Northern Line from Morden to Edgware, the Eurostar from St Pancras to Paris Nord.
- 1-2 sentences pointing at *the specific page elements* where the Gemini magic happens (the Live API's in-character listening, the Nano Banana 2 seat-side picture fading in every 90 seconds, the grounded real route).
**Slide 3 — How to remix this.**
- Headline: "Make this yours."
- Three short bullets:
- "Swap the seed journeys in `/data/seed-journeys/` for your kid's favourite trains."
- "Adjust the character cards in `/data/characters/` to match the family — the conductor's name, the language, the warmth."
- "Wire up your Gemini API key and Firebase project via the env-var list in the capabilities panel."
- Primary CTA: "Use this template" → links to AI Studio Build remix entry point.
- Secondary: "Just exploring — close" (sets localStorage flag, never auto-shows again).
**Accessibility:** focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, focus restored to trigger on close. Respect `prefers-reduced-motion`. Onboarding never shows to the kid — it appears on the grown-up's first sign-in and is gated behind the grown-up drawer thereafter.
**Don't:**
- Don't gate content behind the modal. The home screen beneath must be fully usable.
- Don't auto-reshow on return visits. Use `localStorage['onboarding-seen-v1']`.
- Don't include unrelated CTAs (newsletter signup, social follow). Keep it about the template only.
- Don't show this to the kid. Onboarding lives entirely inside the grown-up surface.
## 6c. Capabilities info button (persistent in grown-up drawer)
Add a persistent `(i)` icon inside the grown-up drawer (NOT on the home screen — the kid will be looking at the home screen). Tap → opens a modal/panel titled **"What powers this app"**.
**Panel contents (in this order):**
**Gemini capabilities used (the hero list):**
- **Gemini Live API (`gemini-3.1-flash-live-preview`)** — the in-character narrator. One Live API session per journey holds the character, listens for the kid's voice, replies in character, resumes the story. Voice activity detection handles turn-taking.
- **Gemini 3.5 Flash (planning)** — runs once before each journey to plan the route, the picture beats, the question beats, and the journey length.
- **Gemini 3.5 Flash + grounded search** — resolves the kid's "tell me about" request to a real route with real intermediate stations in their right order. Citations logged for the grown-up panel.
- **Nano Banana 2 (`gemini-3.1-flash-image`)** — paints the seat-side illustration every 90 seconds. Picture-book gouache style. The previous illustration is passed back in so the conductor looks like the same conductor from beat to beat.
- **Gemini TTS (`gemini-3.1-flash-tts-preview`)** — used for the offline pack only (pre-cached narration for journeys that ride through no-signal patches like ferries, tunnels, the long drive to grandma's).
- **Firebase Auth** — Google sign-in for the grown-up only. The kid never signs in.
- **Firestore** — used only when co-parent sync is ON. Without sync, the kid's name and journeys stay in the device's IndexedDB.
- **Firebase Storage** — caches the seat-side illustrations so a saved journey replayed at bedtime does not re-generate.
- **Cost note** — see the detailed breakdown in 6d. A typical 12-minute journey costs about $0.16 of Gemini API spend. A week of daily journeys: ~$1.10.
- **Privacy note** — your kid's first name and saved journeys stay on this phone unless you turn on co-parent sync. The kid's voice is never used to train any AI model — this app uses the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. No ads. No in-app purchases. Ever.
**Backend services this app depends on:**
- Auth: see section 4b (Apple sign-in requires Apple Developer Service ID + Key ID + private key wired into Firebase Auth — flagged here as user-config)
- Database: see section 4b
- Storage: see section 4b (Firebase Storage is NOT auto-provisioned by AI Studio Build — enable in the Firebase console and paste the bucket name into the AIS Build project)
- Email: see section 4b (not used)
- Payments: see section 4b (not used in v1; will never be a kid-facing purchase surface)
- External APIs: see section 4b
**Environment variables you'll need to configure:**
- `GEMINI_API_KEY` — your Google AI Studio API key
- `FIREBASE_PROJECT_ID` — your Firebase project id
- `FIREBASE_SERVICE_ACCOUNT` — service-account JSON (server-side only)
- `RAIL_DISRUPTION_API_TOKEN` — optional, only if you wire in a public rail-status feed for the "this train is running in real life right now" surface
**Cost + privacy notes:**
- One short paragraph per cost-sensitive capability: the Live API is the largest line — billed per minute of bidirectional audio. A 12-minute journey costs about $0.13 of Live audio plus $0.025 of pictures plus $0.005 of route planning. Bedtime mode does not change the cost (the model still runs).
- One short paragraph on privacy: where the data lives (the device, by default — Firestore only with co-parent sync on), how to delete it (Grown-up drawer → "Delete everything" — gone in 30 seconds), what is never sent for training. The kid's voice is never logged for analytics; only the journey duration and the route name are.
**Documentation links:**
- AI Studio Build docs
- Gemini Live API docs (bidirectional streaming, voice activity detection, system instruction)
- Gemini 3.5 Flash multimodal + structured-output docs
- Nano Banana 2 docs (image-to-image style transfer for character consistency)
- Gemini TTS docs (`languageCode`, pause cues, style directives)
- Firebase Auth, Firestore, Firebase Storage docs
**Accessibility:** same standards as the onboarding modal — focus trap, `Esc`, ARIA, restored focus.
**Behaviour:**
- Always available from the grown-up drawer — never surfaced on a kid-facing screen.
- Tooltip on the `(i)` icon: "How this app is built".
- Mobile: opens as a full-screen sheet that slides up.
- Should be the most honest part of the app — never hand-wave service requirements; never say "AI" without naming the specific Gemini model and capability.
## 6d. Detailed cost breakdown (deployer reads this BEFORE shipping)
- **Resolve real route (Gemini 3.5 Flash + grounded search)** — single short call; ~500 input tokens, ~300 output tokens. ~$0.0001 per resolution. Cached in `routes_cache` per (route_country, line, origin, destination) tuple — most routes resolve once per family per year.
- **Plan the journey (Gemini 3.5 Flash, medium thinking)** — input ~2k tokens (route + character card + settings), output ~3k tokens (JourneyPlan JSON). Medium thinking adds reasoning tokens. ~$0.005 per journey.
- **Live in-character narration (`gemini-3.1-flash-live-preview`)** — Live API is billed per minute of bidirectional audio at roughly $0.011/min for input and $0.011/min for output. A 12-minute journey ≈ $0.13 of Live audio. (Confirm 2026 published rate before shipping; clamp the journey-length setting in the grown-up drawer to limit a max-budget journey.)
- **Seat-side illustration (Nano Banana 2)** — ~$0.03 per image. Picture beats fire every ~90 s; a 12-minute journey ≈ 8 pictures ≈ $0.24. (Cache per journey aggressively — replays cost zero new image spend.) **Tune the per-journey picture count down to 5 or 6 if cost dominates.**
- **TTS pre-cache (Gemini 2.5 Flash TTS)** — only runs for offline-pack journeys; ~$0.005 per saved journey ≈ ~$0.015 for three journeys.
- **Parent-facing recap (Gemini 3.5 Flash, low thinking)** — tiny input, ~120 output tokens. ~$0.00005 per recap.
- **Expected per-journey cost on first ride:** ~$0.16 for 8 minutes / ~$0.20 for 12 minutes (the picture beats dominate). **Daily-use family:** ~$1.10 / week. **Replayed saved journey:** ~$0.13 (Live audio only; pictures are cached).
- **Image storage:** Firebase Storage standard tier, ~$0.026/GB/month. A typical journey's 8 illustrations cache at ~2 MB total; 100 saved journeys ≈ 200 MB ≈ ~$0.005/month.
- **Firestore:** negligible when co-parent sync is OFF (no writes). With sync on, a journey doc is ~3 KB — within the free tier for most families.
## 7. Design language
- **Mood:** A small thoughtful toy for a small child. Not a "learning app". Not a "smart device". Not a tech product. The vibe of a picture book opened in the back seat of a car at four in the afternoon, with the warm light through the rear window and the parent's hand on the wheel. The kid is the audience. The grown-up is the editor.
- **Typography:** Display rounded sans for the home-screen "Trains" button (Founders Grotesk Mono Round, or DM Sans with rounded terminals — feels friendly to a four-year-old reading their first words). Body sans for the grown-up drawer (Inter). Captions on the journey screen use a slightly larger size than usual (≥20 px at 1× scale) for easy reading. Heritage-script captions render at the same height — never compressed.
- **Palette:** Soft cream background `#FBF6EE` for the journey screen, deep ink `#1B2230` for body text, a warm signal-amber `#E8A33A` for the home-screen "Trains" button, a calm bottle-green `#2F5D4F` for the platform guard accent and the "save this one" affordance. The picture-book illustrations carry their own palette; the chrome stays muted so the picture is the hero.
- **Imagery:** Picture-book gouache, warm brush, slightly off-register. Never photoreal. Never a "3D render". The illustrations are the heart of the experience — every other UI element steps back to let them breathe. Captions and chrome are translucent over the picture, never on top.
- **Hand-feel touches:** The home-screen "Trains" button gives a single, satisfying soft haptic on tap. The picture-beat cross-fades at 250 ms with a subtle "lift" — like turning a board-book page. The mid-journey kid-talk-back ring of light is the only motion that scales — it grows softly when the kid is speaking, settles when the kid stops. No theatrical motion. No hero animations.
- **Spacing:** consistent 4-px base, but the home screen breaks the rule — the "Trains" button is unapologetically huge, taking 70% of the viewport, with generous empty space around it.
- **Radius:** consistent token set (e.g. 12 / 20 / 36 px). The "Trains" button uses 36; the saved-journey tiles use 20; the grown-up drawer cards use 12.
- **Shadows:** soft, warm, sepia-tinted. Avoid heavy drop-shadows. The "Trains" button has a single soft inset shadow that sells the tap.
- **Motion:** purposeful — picture-beat cross-fades, the talk-back ring, the saved-journey hover lift. Respect `prefers-reduced-motion`. No bouncing splash animations. No theatrical hero animations. The picture-beat cross-fade is the canonical reduced-motion check — verify it jumps rather than fades.
- **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons not spinners where possible. Empty states have helpful next-action guidance ("Tap **Trains** to pick your first train") — never a generic "Add your first item".
## 8. Content generation rules
- Write **realistic, specific copy**. NO Lorem Ipsum. NO generic placeholders like 'Your tagline here'.
- Invent plausible kid names, real train names, real station orders, character cards that fit a calm picture-book world. When inventing journey content, anchor in real rail networks across multiple continents — Tokyo's shinkansen, London's Underground, Mumbai's Western Line, Mexico City's Metro, Sydney's NSW TrainLink, Lisbon's Tram 28, Lagos's Blue Line, Vancouver's SkyTrain. Real routes only.
- Tone: warm, direct, calm. Free of corporate language. This template is for a kid, not a brand.
- Headlines: punchy and concrete. No 'Empower your X' filler. No 'Revolutionize'. No 'Seamless'. The grown-up reads the drawer copy; it should sound like a parent talking to another parent.
- Body copy: short paragraphs (2-4 sentences). Use lists where appropriate.
- Plain language. Avoid jargon — except where the parent already speaks the jargon (the rail-data-curious parent wants to see "Live API session", "grounded route resolution", "VAD sensitivity"; the privacy-anxious parent wants to see "your kid's voice is never used to train any AI model").
- Where the app outputs AI-generated content, never label it as "AI says" — let the narrator speak. The narrator's character name is shown to the grown-up only ("voiced by Mr Adeyemi the conductor") in the drawer.
## 8a. Seed content (use these specific examples)
Anchor every generated copy + sample data point in the concrete content below. Use these names, numbers, dates, and snippets verbatim where helpful, or generate close variants that sit in the same world.
**Sample saved journeys (kid-facing tiles):**
- **"Kenta's morning Hikari"** — JR Tokaido Shinkansen, Tokyo Station → Shin-Osaka, 12 minutes (12 of the real ~150 minutes, narrated end-to-end). Character: Sumire the platform guard. Language: Japanese (ja-JP). Vocabulary band: ages-3-4. Stations: 東京 → 品川 → 新横浜 → 小田原 → 熱海 → 三島 → 静岡 → 浜松 → 名古屋 → 京都 → 新大阪. Bilingual captions in kana + romaji.
- **"Tobi's Northern Line walk"** — TfL Northern Line, Morden → Tooting Broadway, 6 minutes (the morning nursery walk). Character: Mr Adeyemi the conductor. Language: English (en-GB). Vocabulary band: ages-5-6. Stations: Morden → South Wimbledon → Colliers Wood → Tooting Broadway.
- **"Aarav's Western Line ride home"** — Mumbai Suburban Western Line, Mumbai Central → Borivali, 12 minutes. Character: friendly station guard. Language: Hindi (hi-IN). Bilingual captions in Devanagari + Latin script. Stations: मुंबई सेंट्रल → महालक्ष्मी → दादर → माटुंगा रोड → माहिम जंक्शन → बांद्रा → खार रोड → सांताक्रूज़ → विले पार्ले → अंधेरी → जोगेश्वरी → गोरेगांव → मलाड → कांदिवली → बोरिवली.
- **"Sofía's Eurostar"** — Eurostar, London St Pancras → Paris Gare du Nord, 20 minutes (a bedtime favourite). Character: calm conductor. Language: English (en-GB) with Spanish (es-ES) captions on. Picture beats: the nose under the train shed; the Channel; the Pas-de-Calais; the Gare du Nord roof.
- **"Lucia's Tram 28"** — Carris Tram 28, Martim Moniz → Campo Ourique, 8 minutes. Character: the tram itself ("hello, my friend — I'm the yellow tram"). Language: Portuguese (pt-PT). Picture beats: the yellow carriage on the steep hill; the bell ringing at Graça; the Sé cathedral; Campo Ourique.
- **"Marcus's bedtime T1"** — Sydney Trains T1 North Shore Line, Central → Hornsby, 20 minutes. Character: the station announcer (calm, monotone, kind). Language: English (en-AU). Bedtime mode on (dim screen, slower pace, no chime). All stations named in order.
**Sample journey-in-progress detail (this is what the demo should show):**
- **Kid's request (verbatim):** "tell me about the nine-fourteen from York"
- **Resolved route:** LNER Azuma, York → King's Cross, via Doncaster, Newark North Gate, Grantham, Peterborough, Stevenage, Finsbury Park. (Grounded via `google_search`.)
- **Character card:** Mr Adeyemi the conductor — calm, warm, unhurried. Vocabulary band: mixed-3-6. Language: en-GB.
- **Total journey length:** 12 minutes (matching the dad's "default journey length" setting).
- **Picture beats (8):**
1. t=0s — the Azuma's nose pulling out of York under a grey morning sky, two birds on the platform clock.
2. t=90s — the view from coach 5: green hills near Naburn, a single tractor.
3. t=180s — Doncaster's red-brick station roof.
4. t=270s — Mr Adeyemi walking the aisle, smiling at a kid holding a sandwich.
5. t=420s — Newark North Gate platform, a thin morning mist.
6. t=540s — the East Coast Main Line straight ahead, signal box on the left.
7. t=660s — Peterborough's modern glass canopy.
8. t=720s — King's Cross arched roof, the Azuma slowing.
- **Question beats (3):**
1. t=120s — "what do you think is in the goods wagon at the back?" (soft follow-up if silent: "perhaps a parcel for someone's birthday")
2. t=360s — "what colour do you think Mr Adeyemi's lunch box is?" (soft follow-up: "I think it might be blue")
3. t=600s — "we're nearly there — what shall we ask Mr Adeyemi as we get off?" (soft follow-up: "I'd ask him if his cat is at home")
- **Live-API narrator opening:** "the nine-fourteen from York… that's the LNER Azuma to King's Cross, twenty carriages of blue and red, two diesel engines coupled at Edinburgh, and today the conductor is called Mr Adeyemi. Shall we get on?"
- **Live-API mid-journey acknowledgement template (when the kid speaks):** "that's a good thought, my friend — let's see…"
- **Live-API destination line:** "we're at King's Cross. Mr Adeyemi says thank you for travelling. Shall we wait for the next one?"
- **Grounding citation URIs (logged for grown-up drawer only):** the resolved route is sourced from the LNER public timetable and the National Rail open-data pages; the URLs appear in the grown-up "how was this made" panel, never to the kid.
- **`is_real_route_at_plan_time`:** true.
**Sample voice copy:**
- Home-screen button: "Trains"
- Listening prompt: "Tell me about a train."
- Soft suggestion strip (after 8s silence): "the Hikari from Tokyo? · the Northern Line? · the Eurostar?"
- Pause confirmation (parent long-press): "We'll wait at this station until you come back."
- Destination farewell: "We're at [destination]. Shall we wait for the next one?"
- Save confirmation (grown-up): "Saved 'Kenta's morning Hikari' — it'll be on your phone, ready for tomorrow."
- Offline-pack progress: "Caching for the ferry… 3 of 8 pictures."
- Delete-everything confirmation: "This will erase Kenta's name, his six saved journeys, and your recorded hello. Hold to confirm."
- Grown-up "what we did today" example: "Kenta rode the Hikari from Tokyo to Shin-Osaka this morning. He asked about the goods wagon twice and laughed when Sumire mentioned the conductor's bento. The journey ran 12 minutes and ended at Shin-Osaka."
**Sample character cards (seed data):**
- **Sumire the platform guard** (Tokyo Station) — Japanese (ja-JP); voice style: calm, warm, slightly formal, the way a Tokyo station guard speaks; vocabulary band: ages-3-4; self-introduction: "Konnichiwa — I'm Sumire. I look after the trains here at Tokyo Station."
- **Mr Adeyemi the conductor** (LNER) — English (en-GB, Lagos warmth if voice is available); voice style: calm, warm, unhurried, the way a kindly conductor reads the timetable; vocabulary band: mixed-3-6; self-introduction: "Hello, my friend — I'm Mr Adeyemi. I work on this train. Shall we get on?"
- **The friendly station guard at Mumbai Central** — Hindi (hi-IN); voice style: calm, warm, with a gentle Mumbai cadence; vocabulary band: ages-5-6; self-introduction: "नमस्ते — I'm the guard here at Mumbai Central. Welcome to the Western Line."
- **The tram itself** (Carris 28) — Portuguese (pt-PT); voice style: gentle, slightly creaky like an old tram is creaky in a kind way; vocabulary band: ages-3-4; self-introduction: "Olá, my friend — I'm Tram 28. I'm yellow and I climb the hills. Will you ride with me?"
- **The station announcer** (Sydney Trains) — English (en-AU); voice style: calm, monotone, kind, the way a Sydney Trains announcer reads stations; vocabulary band: ages-5-6; self-introduction: "Welcome to the T1. Stand clear of the doors please. Next stop, Town Hall."
## 9. Media & assets
- **Hero illustration (landing screen):** A picture-book gouache image of a small child's hand reaching for a phone resting on a car's back-seat console, with a soft view of a steam train in the screen. Warm afternoon light. Generated via Nano Banana 2 with a prompt emphasising "picture-book gouache, warm afternoon back-seat light, a small hand, a phone resting on the console, the screen showing a steam train at a country platform, soft shadows, slightly off-register colours, no text".
- **App icon / wordmark:** Set in the display rounded sans. A small picture-book steam-train silhouette in signal-amber alongside the word "Trains". No glossy shadow.
- **Empty-state illustration (no saved journeys):** A simple line drawing of a single empty platform with a clock that says it's nearly time. Hand-drawn, not a flat icon.
- **Demo journey illustrations:** Generated per the prompts in the sample journey-in-progress detail above — Nano Banana 2 prompts that specifically request "picture-book gouache, warm brush, slightly off-register, daytime, calm, no danger, no signage rendered as letters". Each demo picture should look painted, not rendered.
- **Character portraits (grown-up drawer only):** A small portrait of each character card, drawn in the chosen illustration style, shown in the grown-up drawer when picking a character. Never on a kid-facing screen.
- **Stock fallbacks:** If image generation fails, fall back to a small cached picture-book illustration of a calm carriage interior from `/public/samples/sample-carriage.webp`. Never to a "🚂" emoji.
- **Generated imagery:** prefer Nano Banana 2 over stock photography. Prompt for warmth, asymmetry, and slight imperfection — avoid the glossy 'AI render' look. Pass the previous picture as a reference input on every beat after the first, to keep the conductor consistent.
- **Optimisation:** WebP/AVIF, `loading="lazy"`, explicit `width`/`height` to prevent layout shift. The seat-side window is the hero image — never lazy-load it; pre-load the next picture as soon as the previous one fades in.
- **Icons:** `lucide-react` for grown-up drawer chrome only. Use sparingly — never decorative-only. The kid-facing screens use almost no icons; the "Trains" button is type, not a glyph.
### Build-time asset manifest (explicit specs)
Every image, illustration, and visual reference mentioned above must resolve to ONE of the three buckets below — runtime-generated, seed-shipped, or user-supplied. Do NOT ship `` tags whose `src` is not listed here. Do NOT depend on bare "section 8a prompts" without binding them to explicit paths and model IDs.
**Bucket 1 — Runtime-generated (Nano Banana Pro `gemini-3-pro-image` for hero/demo photographs; Nano Banana 2 `gemini-3.1-flash-image` for in-app illustrations and reference-conditioned variants).** Cached to Firebase Storage; served via signed URL. Every reference above to "Nano Banana 2" or "Nano Banana Pro" MUST be wired to one of these specific calls with an explicit model id:
- `/public/generated/hero.webp` (2400×1500, WebP) — model `gemini-3-pro-image` — uses the literal prompt described as "Hero image (landing screen)" above. Run once at build; commit a `/public/samples/hero-fallback.webp` (1600×1000) generated from the same prompt with `gemini-3.1-flash-image` so the page renders if quota is exhausted.
- `/public/generated/demo/{demo-slug}-{NN}.webp` (1600×1200, WebP) — model `gemini-3.1-flash-image` (reference-conditioned where the prior frame is passed as input) — one path per "Demo X" image referenced above. The slug derives from the seed example in section 8a; the NN index covers each frame in the demo sequence.
- `/public/generated/illustrations/{name}.webp` (1024×1024, WebP) — model `gemini-3.1-flash-image` — one path per named illustration above ("Empty-state illustration", "Recipe-card hero illustrations", "Curriculum picker imagery", "Period-style frames", etc.). Each illustration's prompt is the literal description above; ship a deterministic seed in the request so re-runs are reproducible.
**Bucket 2 — Seed assets shipped with the deliverable.** Every "Stock fallback" path referenced above (e.g. `/public/samples/sample-X.jpg`) is generated once via Nano Banana 2 (`gemini-3.1-flash-image`) at 1024×1024 WebP using the same prompt as its Bucket-1 counterpart, then committed to the repo so the page renders identically if Gemini quota is exhausted or the user is offline. Replace any `.jpg` extension above with `.webp` to match the optimisation rule. Also commit these empty-state seeds (1024×1024 WebP, single-stroke hand-drawn line, no colour fill):
- `/public/samples/empty-state-primary.webp` — line drawing of the app's primary empty surface (the named "Empty-state illustration" above), generated from that exact prompt.
- `/public/samples/empty-state-archive.webp` — line drawing of an empty saved/archive view, single-stroke outline.
- `/public/samples/empty-state-error.webp` — line drawing of a hand placing a single object aside with care, used when an AI call fails.
**Bucket 3 — User-supplied.** Uploads from the user's camera / file picker land at the Firebase Storage path conventional for this template (named in section 4b). The build ships with Bucket-1 + Bucket-2 only; no user-supplied images at first paint.
**Hard rules**
- Every `` tag MUST have a `src` that resolves to a path listed in Bucket 1, Bucket 2, or a Bucket 3 upload path. Anything else is a build error.
- No bare `image.jpg` / `hero.jpg` / `placeholder.png` references anywhere in the code.
- Model IDs: `gemini-3-pro-image` for hero-quality photographic generation; `gemini-3.1-flash-image` for in-app illustrations, reference-conditioned variants, empty-state seeds, and stock fallbacks. Never use a legacy model id (no `imagen-*`, no `gemini-1.5-*-image`).
- File format: WebP everywhere (AVIF acceptable where the target browsers support it). No `.jpg` / `.jpeg` / `.png` in `/public/samples/`.
## 10. Interactivity & states
- Every interactive element has hover, focus, active, and disabled states. Touch targets are extra-large on the kid-facing screens (the "Trains" button is at least 240 × 240 px at 1× scale).
- Forms (in the grown-up drawer only) validate inline and show specific error messages (not "Invalid input"). Kid-facing screens have no forms.
- Loading states use skeletons that match the eventual layout, not spinners. The journey-in-progress screen pre-paints the picture-frame area while the first illustration is generating, and the narrator covers the first 8 seconds of audio so the kid never sees a loading state.
- Empty states explain the next action with a button whose label fits THIS app's domain: "Tap **Trains** to pick your first train", "Hand the phone to the kid", "Long-press the cog to set up" — never a generic "Add your first item".
- The Live API session streams audio token-by-token; the kid never sees a "thinking…" indicator (the narrator covers it). The grown-up drawer shows a small connection state indicator only when troubleshooting.
- If the Live API connection drops mid-journey, the narrator says a calm in-character bridging line ("the train's stopped at a signal for a moment — let's wait"), reconnects, and resumes. Never a kid-facing error message. If reconnection fails for more than 12 seconds, the offline-pack cache (if present) takes over.
- The picture-beat cross-fade takes 250 ms with `prefers-reduced-motion` falling back to instant.
- The kid-talk-back ring of soft light grows smoothly with the kid's voice volume; respects `prefers-reduced-motion` by appearing statically.
- The grown-up drawer slides up over 200 ms; the kid will not accidentally trigger it (long-press in a corner).
- The home screen never animates the "Trains" button beyond a single soft press-haptic. No bouncing. No glow.
**Session-resume snippet (Live API 2-min cycle):** the Live API
audio+video session caps at 2 minutes. On every Live tick, persist a
`SessionSyncState` to `sessionStorage`; on reconnect, pass a concise
context-summary block as the first system message of the next
handshake so the model continues without losing thread.
```typescript
interface SessionSyncState {
activeSessionId: string;
accumulatedSegments: Array<{ speaker: string; text: string; timestamp: number }>;
// ...template-specific cursor state (current page, turn index, etc.)
}
```
## 11. Tech & responsive requirements
- **TTS markdown-stripping preprocessor:** before sending any user-authored markdown to `gemini-3.1-flash-tts-preview`, strip non-spoken markdown: `#`/`##`/`###` headings (keep the title text), `**bold**` (keep the inner text), `[label](url)` (keep `label`, drop URL), `` ``` `` fenced code blocks (skip entirely), `>` block-quote markers (keep the text), and `|` table pipes (read row-by-row as sentences). Insert `…` between sentences for a short pause and a blank line plus `—` between paragraphs for a long pause. The model does not understand markdown; raw markdown will be read aloud as literal characters ("asterisk asterisk").
- **File downloads on Safari / Firefox:** when offering local-disk save of any export (PDF, CSV, MP3, ZIP, JSON, image), fall back to `` with a blob URL — the File System Access API (`showSaveFilePicker()`) is Chromium-only. Detect with `'showSaveFilePicker' in window`; otherwise use the anchor-download path.
- **Stack:** React + TypeScript + Tailwind CSS. Functional components + hooks. Use Shadcn UI primitives where appropriate (mainly inside the grown-up drawer).
- **Build runtime:** AI Studio Build — full-stack with Cloud Run server-side functions. All Gemini API calls happen server-side; API key lives in Secrets Manager, never in client bundle.
- **Model selection:** explicitly pin `gemini-3.5-flash` for route resolution and parent recap; `gemini-3.5-flash` for journey planning; `gemini-3.1-flash-live-preview` for the Live API narration session; `gemini-3.1-flash-image` (Nano Banana 2) for illustrations; `gemini-3.1-flash-tts-preview` for the offline pack. Set `thinkingLevel` explicitly per call (omit on Live, image, and TTS calls).
- **Live API:** the client opens a WebSocket via the Gemini Live API SDK; server-side functions mint short-lived ephemeral tokens to scope the session to a single journey. Pin VAD sensitivity (default low). Pin the `systemInstruction` per session.
- **Database:** Firestore (auto-provisioned by AI Studio Build). When co-parent sync is OFF (default), use the device's IndexedDB instead. Sync is opt-in.
- **Auth:** Firebase Auth — Google sign-in for the grown-up only; Apple sign-in next to it (requires user-supplied Apple Developer credentials). The kid never sees a sign-in screen.
- **Storage:** Firebase Storage for cached illustrations and recorded parent voice clips. Pre-signed URLs only. Storage NOT auto-provisioned — enable manually in the Firebase console.
- **Mobile-first.** Verify layouts at 375 px (iPhone SE), 768 px (iPad), 1024 px, 1440 px+. The home screen's "Trains" button scales fluidly across all four viewports; the journey-in-progress screen is the same composition at every width with the picture growing or shrinking.
- Use `clamp()` for fluid typography. Prefer container queries over media queries for component-level responsiveness.
- Use `dvh` / `svh` instead of `vh`. Respect safe-area insets on iOS (the home screen's button never sits under the bottom indicator).
- Zero horizontal overflow at any width. Zero layout shift on load.
- Persist user data in IndexedDB by default; Firestore only when co-parent sync is on. Use real-time listeners on the sync flow.
- Optimistic UI on writes; reconcile on response. The kid never sees a sync state — it's invisible.
- The Live API session is the load-bearing budget item — clamp the journey-length setting (server-side, before opening the session) to a maximum of 20 minutes in v1. The grown-up drawer surfaces the chosen length plainly.
- Long-context guard: the per-session transcript is bounded at one journey. Do not accumulate across journeys.
- **iOS Safari gotchas (graceful degradation):** the Live API session must survive a phone call/Siri/alarm interruption — on `MediaStreamTrack.onmute` the narrator pauses calmly ("the train is stopping at a signal for a moment"); on `onunmute` it resumes. Backgrounded Safari tabs throttle WebSocket and kill `getUserMedia` — combine `visibilitychange` with a screen Wake Lock so the journey survives the parent glancing at another app; the offline-pack cache is the hard-fallback when reconnection fails. Microphone permission does NOT persist across page reloads on iOS — re-request on every journey start. PCM streaming must go via `AudioWorklet` (Safari `MediaRecorder` is AAC-only).
## 12. Accessibility (WCAG 2.2 AA)
- Semantic HTML — `header`, `nav`, `main`, `section`, `article`, `footer`. The home screen is a `main` with a single `button`; the grown-up drawer is a `dialog`.
- All interactive controls reachable by keyboard with a visible focus ring. The kid-facing screens are touch-first, but the grown-up drawer is fully keyboard-navigable.
- Color contrast ≥ 4.5:1 for body, 3:1 for large text and UI components. The signal-amber "Trains" button on cream passes at the chosen weights.
- All images have meaningful `alt` text. The picture-book illustrations have `alt` describing the scene ("picture-book illustration of the Azuma pulling out of York under a grey morning sky"). The grown-up drawer's character portraits have `alt` describing the character ("portrait of Mr Adeyemi the conductor, picture-book gouache").
- Form fields (grown-up drawer only) have associated `