# 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. --- # Trip in a Tab ## 1. Project **Trip in a Tab** is the app you open the moment you've decided to take a trip — *Lisbon long weekend*, *Tokyo for a week with kids*, *road trip to Big Sur with the dog* — and want one tab that produces a real itinerary in 60 seconds instead of seventeen browser tabs over three hours. Type the destination, the dates, the people, what you like and what you hate. The app comes back with a day-by-day plan: seven specific places (each with current opening hours, address, and a one-line "why this one"), a transit sketch, a weather snapshot, a "what to do if it rains" backup, and a "fica" — the one place you'd regret missing — anchored in real, cited sources. This is the app you use the night before you book the flight, when you want to see whether the place sounds like *you*. It is also the app you keep open on your phone on the train from the airport, when you have forty minutes to learn which neighbourhood the cocktail bar is in and whether the bookshop is still open at nine on Sunday. The demo that proves the magic: type *"Lisbon, Friday evening to Monday morning, two adults, like cocktail bars and quiet bookshops, hate queues"*, tap **Plan my trip**, and in under sixty seconds the page fills in. Friday evening: a quiet Príncipe Real wine bar with the opening time as of today, the address, a cited one-liner "why this one". Saturday morning: a bookshop in Alfama with current Saturday hours date-stamped "as of {today}", a fallback if it's closed for August holidays, a transit hint ("8-min walk uphill — wear shoes"). Sunday evening: the *fica*, labelled clearly, with the rationale ("you said quiet bookshops; this one stays open until midnight and has a cocktail bar inside"). The whole itinerary is grounded. Every place comes with a citation URL. The hours are date-stamped. The closures are flagged. The "if it rains" backup is a real place, not a vibes-only suggestion. And the user can tap **Save to Google Maps** and the whole list appears in their Maps starred list within a second — no OAuth gymnastics, because as of I/O 2026 the Workspace integration in AI Studio Build no longer needs them. *(The no-OAuth Workspace integration is rolling out to AI Studio Build accounts; if it isn't enabled on the deploying account yet, the app falls back to a CSV download the user can import into Maps manually.)* **Tagline:** *Type the trip. Get the itinerary in sixty seconds — every place sourced, every opening hour date-stamped, every closure flagged.* ## 2. Target audience - The decided-but-not-planned traveller — has committed to the trip in their head, does not want to open seventeen browser tabs comparing Tripadvisor, Reddit, the city's tourist board, three Substacks, and Maps reviews - The "I land in four hours" traveller — phone-out at the gate, wants an itinerary by the time the seatbelt sign comes off - The taste-led traveller — knows their preferences ("cocktail bars and quiet bookshops", "ramen and record stores") and wants the plan filtered through them, not through the top-10 listicle - The family planner — partner and kids, wants a museum with a playground, a quiet lunch with a noisy gelato run - The accessibility-led traveller — needs step-free transit, lifts in metro stations, accessible bathrooms; wants those constraints respected as hard filters, not soft footnotes - The "different from the obvious list" traveller — has been to the city before, wants the less-touristed version on the second toggle - The business-trip with one evening free — flies in Wednesday, has Thursday night between meetings, wants seven hours of plan that does not waste the evening on a queue at the famous place - The local-with-a-visitor — already lives in the city, has a friend coming for two days, wants help making a plan that does not repeat the same five places they always show people ## 3. Core value propositions Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons users pick this app. - **Never invents a restaurant.** Every place, address, and opening hour comes from grounded search at request time. If the model cannot find sourced hours, the place gets a "hours unknown — call ahead" badge instead of a confident lie. Honest sourcing over perfect coverage. - **Date-stamped opening hours.** Every "open Saturday from 10:00" carries a tiny "as of {today}" suffix. Sources older than 30 days turn the badge amber; over 90 days, red. - **Flags closures honestly.** August holidays, Golden Week, "closed for renovation until November" — the grounded search surfaces these and the itinerary routes around them rather than sending a tired traveller to a locked door. - **One tab, sixty seconds.** From "Plan my trip" tap to a filled itinerary in under sixty seconds. Gemini 3.5 Flash grounded search is the load-bearing intelligence; the rest is a calm wrapper. - **"Different from the obvious list" toggle.** Two outputs from one prompt — the first-time-here version and the resident- sourced version. Both grounded, both honest, one tap apart. - **The "fica" — the one you'd regret missing.** Exactly one place per itinerary, prominently labelled, with rationale citing the user's stated preferences verbatim. *Fica* is Portuguese for "stay" — the app's signature for "this is the one". - **Transit, weather, and rain plan, one card per day.** Each day shows the transit sketch, the forecast, and a grounded "if it rains" backup — never a vague "go to a museum". - **Save to Google Maps in one tap.** Post-I/O 2026 Workspace integration: no OAuth handshake, the user's Google sign-in is enough. The seven places appear in their Maps starred list within seconds. - **Accessibility as a first-class lens.** Step-free transit, dog-friendly, kid-friendly, etc. are hard filters not soft preferences. Places that fail list below the day grid with the exclusion reason and a per-place override. - **Never moralises.** No comment on destination choice, travel frequency, carbon, or politics. It is a planner, not a critic. ## 4. Features to build - One-line trip-prompt input — free-text, parsed server-side - Structured trip-builder fallback — destination, dates, party, likes, dislikes, accessibility filters as discrete fields - The 60-second itinerary — day-by-day grid (Friday evening, Saturday morning, …), 7 places across the trip, each with a one-line "why this one" - Place cards — name, date-stamped opening hours, address, a cited "why this one", a 1-tap "open in Google Maps", a 1-tap citation chip - The "fica" — one prominent card per itinerary labelled *"fica — don't miss this"* with the cited-preference rationale - "Different from the obvious list" toggle — second grounded call preferring resident sources - Transit sketch per day — Metro/bus lines, walking segments, total walking distance - Weather snapshot per day — high, low, precipitation chance, one-line summary - "If it rains" backup per day — grounded alternate place - Accessibility filters as HARD filters — step-free transit, kid/dog/wheelchair-friendly, low-light-friendly, quiet, dietary - Closure flagging — summer/winter holiday, public holiday, renovation, permanent — with source URL + date - Save to Google Maps — one-tap export via Workspace no-OAuth pathway (post-I/O 2026); CSV fallback on integration failure - Save as PDF — Nano Banana Pro cover + day grid + citations - Share via link — magic-link, revocable, per-trip - Trip library — every planned trip preserved - Replan-this-day — regenerate one day without disturbing others - Citation viewer — stacked citations icon per place - Hours-staleness sentinel — fresh / amber (30-90d) / red (90d+) - Honest empty states — "we didn't find anything we'd vouch for — try widening the preference" instead of fabricated padding ## 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) - **Grounded itinerary generation** (Gemini 3.5 Flash + Google Search grounding) — the load-bearing call. Takes the parsed trip request and emits the full day-by-day itinerary. Every place name, address, and opening hour is sourced from Google Search at request time; citation URLs come from `groundingMetadata.groundingChunks[].web.uri`. Thinking level `low` — Gemini 3.5 Flash with grounded search is fast enough that the user-perceived latency stays under sixty seconds for a 3-day trip on a normal home connection. - **Trip-prompt parsing** (Gemini 3.5 Flash, low thinking) — takes the free-text trip prompt and emits a structured `TripRequest` object. Pulls destination, dates, party composition, stated likes and dislikes, accessibility filters, and the "different from the obvious list" preference. Uses `responseSchema` because this call does NOT need grounding — it is pure NLU on the user's text. - **The "different from the obvious list" rephrase** (Gemini 3.5 Flash + Google Search grounding) — when the user toggles "off-the-obvious-list", a second grounded call regenerates the itinerary with an explicit prompt instruction to *avoid* the top-10-listicle places and prefer places that appear in neighbourhood-specific or resident-focused sources. Citations still required. - **Closure and holiday detection** (folded into the grounded itinerary call) — the system instruction explicitly asks the model to surface and flag known closures, August holidays, public holidays in the destination during the trip dates, renovation notices, and permanent closures. The structured output schema has an explicit `closure_flag` field per place. - **Hours-staleness sentinel** (server-side post-processing) — for every place returned, the server looks at the citation URL's publish date if exposed via `groundingMetadata.searchEntryPoint`; if not exposed, the server runs a follow-up `gemini-3.5-flash` grounded query asking for the "as of when" date of the source. Hours older than 30 days → amber chip; older than 90 days → red. - **Transit sketch generation** (Gemini 3.5 Flash + Google Search grounding) — per-day, takes the day's places and emits a small ordered list of transit segments with line names, walking minutes, and total walking distance. Cited where the transit line names come from official transit authority sources. - **Weather snapshot** (Gemini 3.5 Flash + Google Search grounding) — one call per trip, returns per-day high/low/precipitation/summary for the trip dates from a forecast source. Cached client-side for the duration of the trip. - **Hero illustration (optional)** (Nano Banana Pro, `gemini-3-pro-image`) — generates a 4K postcard-style poster for the trip with the city name rendered in legible typography (the post-I/O 2026 Nano Banana Pro can do legible in-image text at 4K). Used as the hero on the saved-PDF cover page and as the share-card thumbnail. - **Print-ready PDF assembly** (server-side, no Gemini call) — the PDF is assembled from the structured itinerary plus the Nano Banana Pro hero. No model call needed for the assembly itself. - **Thinking levels** — `low` everywhere in v1. None of the calls in this template benefit from `medium` or `high` thinking; the grounded search is doing the heavy lifting and Gemini 3.5 Flash at low thinking is fast and current. Gemini 3.5 Pro is announced but not GA as of 2026-06-01 — do not wire it. ### Backend services - **Auth — Required.** Firebase Auth with Google sign-in (auto- provisioned). Google sign-in is also the auth for **Save to Google Maps** — the post-I/O 2026 Workspace integration uses the same identity, no separate OAuth. **Apple sign-in** optional (Apple Developer account required). Magic-link email for **Share via link** needs the sender domain authorised in Firebase Auth. - **Database — Required.** Firestore for `users`, `trips`, `places`, `citations`, `shared_links`. Auto-provisioned. - **File storage — Optional.** Firebase Storage only if you cache Nano Banana Pro hero images server-side. NOT auto-provisioned — enable in the Firebase console if needed. - **Cloud Run server functions — Required.** Every Gemini call server-side; API key in Secrets Manager. AI Studio Build gives the first two Cloud Run apps free of credit card (post-I/O 2026). - **Workspace integration — Required for Save to Google Maps.** Post-I/O 2026 no-OAuth pathway. Fallback to CSV download on edge-case failures. - **Email — Required (transactional).** Magic-link share emails; sender domain authorised in Firebase Auth. - **Payments — Not needed for v1.** - **External APIs:** Gemini API only. **Environment variables:** all secrets in env vars, never in client bundle. Include `.env.example`. **Privacy reminders:** never log secrets · HTTPS everywhere · honour 'delete my account' · explicit opt-in for analytics · the user's trip prompts and saved itineraries are 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) · share links per-trip and revocable. **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. 3. **Seed the JSON Schema as a fenced TypeScript / Zod block** in the system instruction (for grounded calls) or `responseSchema` field (for non-grounded calls). The literal schemas are 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. **Critical rule on grounding + schemas:** `responseSchema` and `google_search` grounding are **mutually exclusive in one Gemini call**. For every grounded call in this template, the system instruction includes the JSON schema verbatim and instructs the model to emit valid JSON in the text body; the server parses the JSON from `response.text`. Citation URLs come from `response.groundingMetadata.groundingChunks[].web.uri`. **Do not ask the model to include URLs inside the JSON body** — the model will hallucinate them; use grounding metadata instead. 5. **Pin the system instruction separately** from user input. Use the `systemInstruction` field for persona + behavioural rules; use `contents` for user input. Never concatenate. 6. **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. 7. **State negative constraints explicitly** — they are listed below. They are NOT "be careful" suggestions; they are hard rules the model must follow. 8. **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; } ``` 9. **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 arrays and removes regex patterns before serializing. Validate those constraints in middleware AFTER parsing. 10. **Files API uses `files/*` resource names, not `gs://` URIs.** The AI Studio Build runtime uses the Gemini Developer API (`@google/genai` SDK). Files API `upload` returns a resource name like `files/abc123xyz`, passed via `fileData: { fileUri, mimeType }`. `gs://` URIs belong to Vertex AI / Cloud Storage — a different surface, not accepted here. ### Per-call model + tools matrix | Call | Model | thinkingLevel | Tools enabled | |------|-------|---------------|---------------| | Trip prompt → `TripRequest` (parse free-text) | `gemini-3.5-flash` | low | (none) — uses `responseSchema` | | Grounded itinerary → `Itinerary` (JSON in text body) | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema` — see note) | | "Off-the-obvious-list" regeneration | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema`) | | Replan a single day | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema`) | | Transit sketch per day | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema`) | | Weather snapshot for the trip dates | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema`) | | Hours-staleness check follow-up | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema`) | | Hero / cover image for saved PDF | `gemini-3-pro-image` (Nano Banana Pro) | n/a | n/a | | Share-card thumbnail | `gemini-3.1-flash-image` (Nano Banana 2) | n/a | n/a | *Note for builders:* on image-generation calls (Nano Banana Pro and Nano Banana 2), omit `thinkingConfig` entirely — the field is not supported on those models. The `n/a` cells in this matrix are documentation only; do not serialise them into the request body. Grounded search calls emit JSON in the text body — `responseSchema` and `google_search` cannot be combined in the same Gemini call; parse the JSON server-side and read citation URLs from `response.groundingMetadata.groundingChunks[].web.uri`. Long-context guardrail: a 7-day trip with off-the-obvious-list toggled produces at most ~80k tokens of grounded result text; well under Gemini 3.5 Flash's 1M context. If a future "trip library digest" feature reads across >50 saved trips at once, chunk to keep payloads under 500k tokens. ### Primary structured-output schemas (seed verbatim in the prompt) ```typescript import { z } from "zod"; const PartyComposition = z.object({ adults: z.number().min(1).max(20), children: z.number().min(0).max(20), children_ages: z.array(z.number().min(0).max(17)), dogs: z.number().min(0).max(10), other_pets: z.string().nullable(), // "elderly cat, no flying" notes: z.string().nullable(), // free-text for edge cases }); const AccessibilityFilters = z.object({ step_free_transit: z.boolean(), wheelchair_accessible_venues_only: z.boolean(), kid_friendly_only: z.boolean(), dog_friendly_only: z.boolean(), low_light_friendly: z.boolean(), // for migraine / autism quiet_only: z.boolean(), // no loud bars / clubs vegan_only: z.boolean(), vegetarian_only: z.boolean(), halal_only: z.boolean(), kosher_only: z.boolean(), gluten_free_only: z.boolean(), alcohol_free_only: z.boolean(), }); const TripRequest = z.object({ request_id: z.string(), destination_verbatim: z.string(), // "Lisbon" or "Lisbon, Portugal" destination_normalised: z.string().nullable(), start_date_iso: z.string().nullable(), // "2026-06-12" end_date_iso: z.string().nullable(), start_time_of_day: z.enum([ "morning", "midday", "afternoon", "evening", "night", "unknown", ]), end_time_of_day: z.enum([ "morning", "midday", "afternoon", "evening", "night", "unknown", ]), party: PartyComposition, likes_verbatim: z.array(z.string()), // ["cocktail bars", "quiet bookshops"] dislikes_verbatim: z.array(z.string()), // ["queues", "loud clubs"] accessibility: AccessibilityFilters, off_the_obvious_list: z.boolean(), budget_band: z.enum([ "shoestring", "moderate", "comfortable", "premium", "unspecified", ]), language_hint: z.string().nullable(), // BCP-47 of the user's UI locale parse_confidence: z.number().min(0).max(1), flagged_for_user_review: z.array(z.object({ field_path: z.string(), reason: z.string(), })), }); const OpeningHours = z.object({ weekday: z.enum([ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ]), opens_local: z.string().nullable(), // "10:00" or null if closed closes_local: z.string().nullable(), // "23:30" or null if closed closed_all_day: z.boolean(), notes: z.string().nullable(), // "kitchen closes at 22:00" }); const ClosureFlag = z.object({ flag_type: z.enum([ "summer_holiday", "winter_holiday", "public_holiday", "renovation", "permanent_closure", "owner_break", "hours_uncertain", "verify_before_you_go", ]), reason_one_line: z.string(), source_publish_date_iso: z.string().nullable(), source_url_index: z.number().nullable(), // index into groundingChunks }); const Place = z.object({ place_id: z.string(), // app-local UUID google_maps_place_name: z.string(), // exact name to search Maps with display_name: z.string(), // human-readable, with neighbourhood category: z.enum([ "cocktail_bar", "wine_bar", "cafe", "bakery", "restaurant", "bookshop", "record_shop", "vintage_shop", "boutique", "museum", "gallery", "park", "garden", "viewpoint", "beach", "transit_landmark", "walking_route", "cinema", "venue", "market", "other", ]), address_one_line: z.string(), neighbourhood: z.string().nullable(), why_this_one: z.string(), // 1 sentence anchored in a citation why_this_one_citation_index: z.number().nullable(), opening_hours_for_trip_dates: z.array(OpeningHours), hours_source_publish_date_iso: z.string().nullable(), hours_staleness_band: z.enum([ "fresh_under_30_days", "amber_30_to_90_days", "red_over_90_days", "unknown", ]), closure_flag: ClosureFlag.nullable(), accessibility_notes: z.string().nullable(), passes_accessibility_filters: z.boolean(), estimated_visit_duration_minutes: z.number().min(15).max(360), estimated_cost_per_person_eur: z.number().nullable(), citation_chunk_indices: z.array(z.number()), }); const TransitSegment = z.object({ from_place_id: z.string().nullable(), // null for first segment to_place_id: z.string(), mode: z.enum([ "walk", "metro", "bus", "tram", "train", "ferry", "taxi", "rideshare", "bike", "car", ]), line_or_route_name: z.string().nullable(), // "Metro Blue line", "Tram 28" duration_minutes: z.number(), walking_distance_metres: z.number().nullable(), step_free: z.boolean(), notes: z.string().nullable(), }); const DaySlot = z.object({ day_label: z.string(), // "Friday evening", "Saturday morning" date_iso: z.string(), slot: z.enum([ "morning", "midday", "afternoon", "evening", "night", ]), weather: z.object({ high_celsius: z.number().nullable(), low_celsius: z.number().nullable(), precipitation_chance_percent: z.number().min(0).max(100).nullable(), summary_one_line: z.string().nullable(), }), places: z.array(Place), transit_segments: z.array(TransitSegment), if_it_rains_backup: Place.nullable(), day_total_walking_metres: z.number(), }); const Fica = z.object({ place_id: z.string(), // points to a place in one of the days rationale_two_lines: z.string(), // cites user prefs verbatim user_preference_cited: z.string(), // the like the model anchored on confidence: z.number().min(0).max(1), }); const Itinerary = z.object({ itinerary_id: z.string(), trip_request_id: z.string(), off_the_obvious_list: z.boolean(), destination_normalised: z.string(), days: z.array(DaySlot), total_places_count: z.number(), fica: Fica, generated_at_iso: z.string(), citations_count: z.number(), itinerary_warnings: z.array(z.string()), // "August closures expected for half the wine bars" itinerary_confidence: z.number().min(0).max(1), }); type TripRequest = z.infer; type Itinerary = z.infer; type Place = z.infer; ``` ### Common failure modes (and how to avoid them) - **`responseSchema` + `google_search` in one call** — the Gemini API returns `INVALID_ARGUMENT`. For grounded calls, put the schema in `systemInstruction`; parse JSON from `response.text`; read citations from `response.groundingMetadata.groundingChunks[].web.uri`. - **Model fabricates a restaurant + a citation URL inside JSON** — the URL looks real but 404s. Forbid URLs inside the JSON body; validate every place's `citation_chunk_indices` are valid indices into `groundingChunks[]`; drop places with zero valid citations and log the violation. - **Hallucinated opening hours from training-data memory** — a bar that closed in 2024 gets a confident "open until 02:00". Require every opening-hours field to be anchored in a citation chunk validated server-side; drop the place if grounding is empty. - **Off-the-obvious-list returns listicle pages** — defeats the purpose. The system instruction explicitly prefers resident sources and instructs "return fewer places rather than padding with listicle picks". - **Hours-staleness sentinel hallucinates source dates** — read the publish date from grounding metadata; if unavailable, the band is `"unknown"` and the UI shows "call ahead", not a fake confidence. - **Trip-prompt parser confuses likes / dislikes** — when the user writes them in one sentence. Require isolation by positive verbs ("like", "love", "want") vs negative ("hate", "avoid", "no"). - **Accessibility filter silently ignored** — require the grounded call to receive filters as a hard constraint; set `passes_accessibility_filters` per place; the server drops failing places before render. - **Closure flag mis-classified** — permanent_closure shown as "verify". Use the `permanent_closure` enum when the source is clear; UI shows permanent closures struck-through, not as verify-before-you-go. - **The "fica" cites a preference the user did not state** — `user_preference_cited` must be a verbatim string from `likes_verbatim`; server validates set membership; re-call if it fails. - **Workspace Save-to-Maps fails silently** — the no-OAuth pathway is still rolling out for some accounts. Catch the error, show "couldn't reach Google Maps right now — download the list as a CSV instead", surface the CSV download. ### Negative constraints (hard rules) - Do NOT invent places. Every place must have valid citation chunk indices; server drops places with no valid grounding. - Do NOT include URLs inside the JSON body of grounded calls. The model will hallucinate them. Citation URLs come from `response.groundingMetadata.groundingChunks[].web.uri`. - Do NOT combine `responseSchema` and `google_search` grounding in the same Gemini call. Put the schema in `systemInstruction`, emit JSON in the text body, parse server-side. - Do NOT claim "open now" without a source publish date. If unknown, show "hours unknown — call ahead". - Do NOT moralise about destination, travel frequency, carbon, or politics. This app is a planner. - Do NOT include permanently-closed places in the day grid. - Do NOT pad the itinerary with listicle places when off-the-obvious-list is on. Return fewer places. - Do NOT translate place names. - Do NOT auto-publish itineraries. Sharing is explicit and revocable. - Do NOT use the user's trip prompts or saved itineraries to train any model. Use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. The capabilities-info panel says this in plain English. - Do NOT show a confident plan when grounded search returned no usable results. Show the calm "we didn't find anything we'd vouch for" empty state. - Do NOT re-do the OAuth handshake for Save-to-Maps if the Workspace integration is healthy. Only fall back to OAuth (or CSV) on `WORKSPACE_INTEGRATION_UNAVAILABLE`. ### 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: Trip prompt → `TripRequest` (parse free-text) Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none) · Uses `responseSchema` ``` Parse a single-line trip prompt into a TripRequest JSON object. Example prompts: - "Lisbon, Friday evening to Monday morning, two adults, like cocktail bars and quiet bookshops, hate queues" - "Tokyo for a week with kids — ages 6 and 9, like ramen and parks, hate long museum tours" - "Big Sur road trip with the dog, 4 nights, dog-friendly cafes and quiet beaches only" Hard rules: - Isolate likes from dislikes by clause. Positive verbs: "like", "love", "into", "want", "looking for". Negative verbs: "hate", "avoid", "no", "without", "skip". Ambiguous clauses → flag for user review. - Preserve the user's verbatim phrasing in likes_verbatim / dislikes_verbatim. Do NOT normalise "cocktail bars" to "bars" — the "fica" call needs the verbatim string. - Parse dates loosely. No year given → next occurrence; flag the assumed year. - Party: if "two adults" with no kids, children: 0. If "with kids" with no ages, children: 1 and flag. - Accessibility filters default false; set true only on explicit user statement. - off_the_obvious_list defaults false; set true on cues like "not the typical tourist list", "the local version", "I've been before", "off the beaten path". - budget_band: only set if user states it; otherwise unspecified. - language_hint: copy from caller-supplied UI locale; do not guess from the prompt language. - parse_confidence < 0.7 → flag for user review. Output ONLY the TripRequest JSON. JSON only. ``` --- ### Call: Grounded itinerary → `Itinerary` (JSON in text body) Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding · Does NOT use `responseSchema` ``` You generate a complete travel itinerary for a user's trip, grounded in Google Search. Every place, address, and opening hour must be sourced from search results — never from training-data memory. INPUTS: - A parsed TripRequest object (destination, dates, party, preferences, accessibility filters, off_the_obvious_list flag). - Today's date in the user's local timezone. OUTPUT FORMAT: - Emit a valid Itinerary JSON object in your text body matching the Zod schema defined in this template (TripRequest, AccessibilityFilters, PartyComposition, OpeningHours, ClosureFlag, Place, TransitSegment, DaySlot, Fica, Itinerary). The schema is the source of truth for field names and types. - Do NOT include any URLs in the JSON body. Citations come from groundingMetadata.groundingChunks server-side. - Each place's citation_chunk_indices must list integer indices into the groundingChunks[] array — only indices for chunks you actually relied on for that place's name, address, hours, or "why this one" sentence. HARD RULES: - Sources first. Every place must be backed by at least one citation chunk. If you cannot find a citation, do NOT include the place. - Date-stamp every opening-hours block. If the source publish date is older than 30 days, set hours_staleness_band to "amber_30_to_90_days" or "red_over_90_days" accordingly. - Flag closures honestly. Set closure_flag with the correct flag_type when a source indicates summer/winter holiday, public holiday, renovation, or permanent closure. Do NOT include permanently-closed places in days[]. - Accessibility filters are HARD. Set passes_accessibility_filters honestly per place; a place that fails the filter must not appear in days[] (the server will reject it). - The "fica" is EXACTLY ONE place — the single one you judge the user would most regret missing. user_preference_cited MUST be a verbatim string from likes_verbatim in the TripRequest. - off_the_obvious_list: when true, prefer neighbourhood blogs, resident newsletters, small Substacks, and independent guides; avoid top-10 listicles and the city's tourist-board pages. If the only sources are listicles, return FEWER places rather than padding. - Transit segments name the actual transit line ("Metro Blue line", "Tram 28") where a source identifies it; otherwise "walk" and a duration estimate. - Weather: forecast for trips within 10 days; climatological averages otherwise, with summary_one_line saying so. - If grounded search returns no usable results, return an Itinerary with day shells (date, weather) and empty places[]; set itinerary_warnings explaining why. Do NOT pad with invented places. NEGATIVE CONSTRAINTS: - Do NOT include URLs in the JSON body. - Do NOT use responseSchema. - Do NOT translate place names. - Do NOT moralise about the destination, travel frequency, carbon, or politics. - Do NOT include a place you could not source. JSON ONLY. No commentary outside the JSON object. ``` --- ### Call: Off-the-obvious-list regeneration Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding · Does NOT use `responseSchema` ``` Generate a SECOND Itinerary for the same TripRequest with off_the_obvious_list: true — noticeably different from the standard version, preferring resident-focused sources. Sourcing strategy: - PREFER: neighbourhood Substacks, resident blogs, small local newsletters, independent food critics, hyperlocal newspapers, city-dweller personal blogs. - AVOID: top-10 listicles, the city's official tourist board, "best of " SEO pages, content farms. - REJECT results whose title contains "top 10", "must-see", "best of", "ultimate guide" UNLESS the byline is clearly a resident or expert with neighbourhood specificity. If only listicles are available, return FEWER places rather than padding. 4 genuinely off-the-obvious picks beat 7 listicle picks. Every other rule from the standard grounded itinerary call still applies. Same Itinerary schema. JSON in text body. No URLs in the JSON. JSON only. ``` --- ### Call: Replan a single day Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding · Does NOT use `responseSchema` ``` Regenerate a single day of an existing itinerary, leaving other days untouched. Used when the user discovers a place is closed, the weather shifts, or they want to retry. INPUTS: original TripRequest; original Itinerary (all days); day_label + date_iso to replan; optional reason ("rainy", "place is closed", "tired of seafood"). OUTPUT: a single DaySlot JSON object. The server replaces the day. Hard rules: - Respect the original itinerary's accessibility filters. - The replanned day's places must DIFFER from any place already in the other days (the server passes you names to avoid). - The "fica" stays in its original day unless the user replans that day. If the replanned day contained the fica, propose a new fica_candidate field in your output — the server confirms with the user before swapping. - Same sourcing rules: every place cited, hours date-stamped, closures flagged. JSON ONLY. ``` --- ### Call: Transit sketch per day Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding · Does NOT use `responseSchema` ``` Produce an ordered list of TransitSegment objects connecting the day's places in visit order. Prefer official transit-authority sources (the city's metro authority, transit-app data feeds, official transport sites). INPUTS: the day's ordered places with addresses; the user's accessibility filters (esp. step_free_transit and wheelchair_accessible_venues_only); destination city. OUTPUT: JSON array of TransitSegment objects matching the schema (from_place_id, to_place_id, mode, line_or_route_name, duration_minutes, walking_distance_metres, step_free, notes). Hard rules: - If step_free_transit: true, every metro/tram/bus segment must be step_free: true OR use walk/taxi instead. Do NOT include a segment that violates the filter. - Name the transit line when the source supports it ("Metro Blue line", "Tram 28", "Bus 728"). - Walking distances > 1500 m get a notes string mentioning the climb if relevant ("uphill — ~80 m elevation gain"). - If transit info unsourceable, return a single "walk" segment with a notes string saying so. JSON ONLY. ``` --- ### Call: Weather snapshot for the trip dates Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding · Does NOT use `responseSchema` ``` Produce a per-day weather snapshot for the trip dates — one JSON object per day in date order. INPUTS: destination city + country; array of date_iso strings; user's local timezone. OUTPUT: JSON array, one object per day with fields date_iso, high_celsius, low_celsius, precipitation_chance_percent, summary_one_line, source_kind ("forecast" | "seasonal_average"). Hard rules: - Trip within 10 days → forecast source, source_kind: "forecast". - Trip > 10 days out → climatological averages for that city + month, source_kind: "seasonal_average". Say so in summary_one_line ("seasonal averages — actual forecast not yet available"). - summary_one_line is human, not jargon ("warm and dry, light breeze in the evening" not "partly cloudy with 24% pop"). - Do NOT invent precipitation chances for seasonal averages — set precipitation_chance_percent: null. JSON ONLY. ``` --- ### Call: Hours-staleness check follow-up Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding · Does NOT use `responseSchema` ``` Determine the publish date of a source URL when grounding metadata did not expose it. Supports the staleness sentinel. INPUTS: a single URL; associated place name and city. OUTPUT: { url_publish_date_iso, confidence (0..1), alternative_date_seen }. Hard rules: - Do NOT guess. If the publish date is not visible in search results → url_publish_date_iso: null, confidence: 0. - If alternative_date_seen exists (e.g. "Updated 2026-03-12"), prefer the most recent date. - Null is better than a confident wrong date — the UI handles null with a calm "call ahead" chip. JSON ONLY. ``` --- ### Call: Hero / cover image for saved PDF Model: `gemini-3-pro-image` (Nano Banana Pro) · n/a · n/a ``` Generate a single 4K postcard-style cover for the saved-PDF. Nano Banana Pro renders legible typography at 4K — include the destination name as crisp text on the image. INPUTS: destination name; trip dates (smaller below); short style brief ("warm afternoon light, tiled pavement, no people"). Style anchors: - Postcard composition. Single dominant subject. 3:2. - Warm, sunlit, lived-in feel. Avoid glossy travel-mag look. - Destination name in a soft serif; date range smaller in a clean grotesque. - No commercial branding (restaurant signs, hotel logos, airline marks). - No people in frame unless the user prompt asks. - Slight paper-grain overlay, soft shadow falloff at the edges. Hard rules: - Typography crisp at 4K (Pro is chosen for this). - Single image, no multi-panel. - No URLs, no dates beyond the trip dates, no prices on the image. - Aspect ratio 3:2. ``` --- ### Call: Share-card thumbnail Model: `gemini-3.1-flash-image` (Nano Banana 2) · n/a · n/a ``` Generate a 1:1 social-share thumbnail for an itinerary share-link OG image — smaller and stylistically different from the postcard. INPUTS: destination name; fica place name. Style: square, strong composition, one subject. Minimal type: destination name + fica place name in small caps below. Warm sunlight, travel-feeling but specific. No commercial branding. Slight paper-grain texture. Hard rules: - Aspect ratio 1:1. - Type readable at thumbnail size (small caps, generous letter- spacing). - No people unless the destination is famously associated with one and the user explicitly asks. ``` ## 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. - **Lisbon long weekend.** "Lisbon, Friday evening to Monday morning, two adults, like cocktail bars and quiet bookshops, hate queues". In 60 seconds the page fills: a quiet Príncipe Real wine bar Friday evening (hours fresh), an Alfama bookshop Saturday morning (amber chip — source 47 days old), a viewpoint with a tucked-away café Saturday afternoon, a museum the user can enter via the side door, and a late-night bookshop-with-cocktail-bar on Sunday evening as the "fica". Every place cited. - **Tokyo for a week with kids.** "Tokyo, June 10–17, two adults plus kids ages 6 and 9, ramen and parks, hate long museum tours". Each day mixes a ramen lunch with a playground; two long-museum days are swapped for a science centre and a riverboat. kid_friendly_only is honoured; walking per day stays under 3 km. - **Road trip to Big Sur with the dog.** "Big Sur, 4 nights, dog-friendly cafes and quiet beaches only". Every place sourced as dog-allowed; off-leash beach hours date-stamped; each day's "if it rains" backup is a dog-friendly indoor café; the "fica" is a coastal viewpoint with a dog-water station. - **The accessibility-led trip.** User toggles step_free_transit and wheelchair_accessible_venues_only. Failing transit segments become walking or taxi; failing venues are dropped. Close-but- not-quite venues (step-free entrance, no accessible bathroom) appear below the day grid with the reason for exclusion and an override toggle. - **The "different from the obvious list" toggle.** Same prompt, two outputs. Standard list and resident-sourced list, visibly different neighbourhoods and sources. Both saveable. - **The closure-flag trip.** August in Lisbon — half the wine bars are closed for staff holidays. itinerary_warnings names the pattern, and the day grid routes around them honestly. - **The "we found nothing we'd vouch for" case.** "Beja, Portugal, two adults, alcohol-free, kosher, vegan cocktail bars open after midnight". The app returns a calm "we didn't find anything we'd vouch for — try widening the preference" message and does NOT pad with invented places. - **Save to Google Maps.** Tap **Save 7 places to Google Maps** → the seven places appear in the user's Maps starred list within seconds via the post-I/O 2026 no-OAuth Workspace pathway. - **CSV fallback.** When the Workspace pathway returns `WORKSPACE_INTEGRATION_UNAVAILABLE`, the app offers a one-tap CSV download and a clear "open Maps → Your places → Lists → Import" two-step. ## 6. Page structure Build the following screens / sections in this order. Adjust copy to fit the voice, but keep the structural intent. 1. **Landing.** Nano Banana Pro hero: sunlit café table, paper map of a Mediterranean city, coffee cup, notebook with handwritten entries. One paragraph: "Type the trip. Get the itinerary in 60 seconds — every place sourced, every opening hour date-stamped, every closure flagged." Big input field: *"Where to, when, who's coming, what do you like and hate?"* Primary button **Plan my trip**. Below: "Try a sample" link. 2. **Trip prompt input.** The big input expands. An "advanced" disclosure opens the structured fields (destination, dates, party, likes, dislikes, accessibility filters, off_the_obvious _list toggle). Free-write and structured both flow to the same parser call. 3. **Plan-in-progress state.** Calm full-page state with a single line of streaming text ("Reading your preferences…" / "Searching for cocktail bars in Príncipe Real…" / "Checking opening hours for your dates…" / "Plotting transit between Alfama and Belém…") and a slim progress bar. Never a generic spinner. 4. **Itinerary page (the main view).** The day-by-day grid. Each day is a horizontal card with the day label and date on the left, the weather snapshot, places stacked, transit segments as thin connectors, and the "if it rains" backup as a muted card below. The "fica" floats above the day grid as a prominent card labelled in soft handwritten script *"fica — don't miss this"*, with rationale and user_preference_cited. 5. **Place card detail (sheet).** Slides up with full detail: name, address, neighbourhood, "why this one" sentence with cited source linked, opening hours with the staleness chip, accessibility notes, estimated duration and cost, citations stack, **Open in Google Maps** button, **Add/Remove from itinerary** toggle. 6. **Off-the-obvious-list toggle.** Persistent in the top-right. Default: standard list. Toggling shows the reassurance ("Pulling from neighbourhood Substacks and resident sources only — this can take an extra 20 seconds…") and slides in the alternate. 7. **Replan-day flow.** Each day's card has a "Replan this day" link. Tap → sheet asks why ("rainy", "place is closed", "tired of seafood") and runs the replan call. New DaySlot fades into place; 30 s undo. 8. **Accessibility filter bar.** Slim persistent bar above the day grid showing active filters as chips. Tap to remove; "+ Add filter" opens the full list. Removing a filter triggers a scoped replan call only for affected places. 9. **"Verify before you go" tail section.** Below the day grid, places that were close-but-not-quite (amber closures, hours uncertain, partial accessibility) with reason and per-place override toggle. 10. **Save / share / export.** Three primary actions at the itinerary bottom: **Save to Google Maps** (one tap), **Save as PDF** (Nano Banana Pro hero + day grid laid out), **Share via link** (magic-link, revocable). Save-to-Maps first-use tooltip: "Adds the seven places to your Maps starred list. No OAuth — uses your Google sign-in (new since I/O 2026)." 11. **Trip library.** All previous trips, with date, prompt, destination, and a tiny postcard thumbnail. Tap to reopen. 12. **Settings & privacy.** Unit / clock prefs, default accessibility filters, notification prefs (none by default), export-to-JSON, delete-trip / delete-account with 60-second cool-off. Privacy panel restates the not-trained-on policy. 13. **Footer.** "Made for the trip you've already decided to take." Privacy: "Your trip prompts and itineraries are yours. We never train on them." Capabilities `(i)` icon in header. ## 6b. First-visit onboarding Show a first-visit onboarding (detect via `localStorage`; do not show on return visits). Three slides, dismissible. Re-entry via a `?` icon in the header. **Slide 1 — What this is.** Headline: "Welcome to Trip in a Tab." Subhead: "Type the trip. Get the itinerary in 60 seconds — every place sourced, every opening hour date-stamped, every closure flagged." One paragraph (≤60 words) explaining what makes it different from generic AI travel apps: every place is grounded at request time, hours are date-stamped, closures are flagged, and there is an off-the-obvious-list toggle. Visual: an annotated day- card illustration labelling the staleness chip, the citation icon, the fica card, the "if it rains" backup. **Slide 2 — Try it now.** Short prompt: "Try the Lisbon long weekend". Pre-loaded demo input. 1-2 sentences pointing at the page elements where Gemini magic happens (the grounded call running visibly, the per-place citation chips, the fica rationale). **Slide 3 — How to remix this.** Headline: "Make this yours." Bullets: swap sample prompts in `/data/seed-trips/`; adjust `systemInstruction` strings in `/server/prompts/`; wire your Gemini API key + Firebase project via the capabilities-panel env-var list. Primary CTA: "Use this template" (AI Studio Build remix link). Secondary: "Just exploring — close" (sets localStorage flag). **Accessibility:** focus trap, `Esc`, `role="dialog"`, `aria-modal`, `aria-labelledby`, restored focus, reduced-motion. **Don't:** gate content behind the modal; auto-reshow on return; include unrelated CTAs. ## 6c. Capabilities info button (persistent in header) Persistent `(i)` icon in the top-right of the header. Click → opens a modal/panel titled **"What powers this app"**. **Gemini capabilities used (the hero list):** - **Gemini 3.5 Flash with Google Search grounding** — the load- bearing intelligence; every place name, address, and opening hour comes from search at request time. Citations come from `groundingMetadata.groundingChunks[].web.uri`. Gemini 3.5 Flash is the new default flagship (GA 2026-05-19, post-I/O 2026); it beats Gemini 3.1 Pro on most benchmarks at a fraction of the cost. - **Gemini 3.5 Flash (structured output)** — parses the free-text trip prompt into a typed `TripRequest` via `responseSchema` (this call does not need grounding). - **Grounded "off-the-obvious-list" regeneration** — second grounded call preferring neighbourhood blogs and resident newsletters over listicles. - **Nano Banana Pro (`gemini-3-pro-image`)** — 4K postcard cover for the saved-PDF. The first image model that renders legible typography at 4K (post-I/O 2026). - **Nano Banana 2 (`gemini-3.1-flash-image`)** — 1:1 share-card thumbnail for chat-app previews. - **Workspace integration (no OAuth)** — Firebase Google sign-in is enough to authorise **Save to Google Maps** (post-I/O 2026). - **Firebase Auth, Firestore, Cloud Run** — auth, persistence, server-side API calls. AI Studio Build deploys the first two Cloud Run apps free of credit card. - **Cost note** — see 6d. ~$0.12 per 3-day itinerary; +$0.07 per off-the-obvious-list toggle; +$0.04 for the saved-PDF cover. - **Privacy note** — your trip prompts and saved itineraries are private. 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. - **Honesty note** — every place comes from grounded search at request time. The app does NOT invent restaurants from training- data memory. If grounded search finds nothing for your preferences, the app says so. **Backend services + env vars:** see section 4b. Required: `GEMINI_API_KEY`, `FIREBASE_PROJECT_ID`, `FIREBASE_SERVICE_ACCOUNT`. Optional: `WORKSPACE_INTEGRATION_FALLBACK_ENABLED` to enable the CSV fallback for gated accounts. **Documentation links:** AI Studio Build docs · Gemini API grounded search (the `responseSchema`-vs-`google_search` constraint) · Gemini API multimodal image (Nano Banana Pro + Nano Banana 2) · Firebase Auth, Firestore · Workspace no-OAuth pathway via AI Studio Build. **Accessibility + behaviour:** focus trap, `Esc`, ARIA, restored focus. Always available; mobile opens as a full-screen sheet. Never says "AI" without naming the specific Gemini model. ## 6d. Detailed cost breakdown (deployer reads this BEFORE shipping) Pricing basis: Gemini 3.5 Flash $1.50/M input, $9/M output. - **Trip prompt → TripRequest** (~2k in / 600 out) → ~$0.008 per parse. Once per trip. - **Grounded itinerary** (3-day, ~12k in / 6k out incl. search- result context) → ~$0.072. Once per trip; once per off-the-obvious-list toggle. - **Off-the-obvious-list regeneration** — same shape as grounded itinerary, ~$0.072. - **Replan a single day** (~4k in / 1.5k out) → ~$0.020. - **Transit sketch per day** (~1.5k in / 500 out) → ~$0.007 each. 3 days → ~$0.021. - **Weather snapshot** (~1k in / 400 out) → ~$0.005. Once per trip. - **Hours-staleness follow-up** (~600 in / 100 out) → ~$0.002 per check; ~half of places need it → ~$0.007 per trip. - **Nano Banana Pro cover image** — approximate ~$0.04 per image (Google has not pinned an exact public per-image figure; verify before shipping). Generated only on Save-as-PDF, cached client-side. - **Nano Banana 2 share-card thumbnail** — approximate ~$0.005 per image (verify before shipping). Only on Share via link. - **Per-trip total (typical):** ~$0.12 — parse + grounded itinerary + transit + weather + staleness. +$0.07 per off-the-obvious-list toggle. +$0.04 if Save-as-PDF. - **Power user — 20 trips/year:** ~$2.40 Gemini spend. - **Storage:** Firestore writes are pennies per trip. Optional Firebase Storage cache: ~$0.001 per cached hero per month. - **Cloud Run:** first two apps free; fits comfortably for the first thousand trips per month. ## 7. Design language - **Mood:** A calm planner's desk at the moment before a trip — a paper map open on a café table, the coffee cup, a notebook with a few handwritten lines. Not a SaaS dashboard, not a marketing splash. A working surface the user reads at the gate. - **Typography:** Clean grotesque for app chrome (Inter or Geist). Display serif for the destination name and the "fica" label (Source Serif Pro or Fraunces). A soft handwritten accent (sparingly) for the "fica" tag and the user's verbatim trip- prompt preview at the top of the itinerary. - **Palette:** Warm paper `#F7F2E8` page, deep ink `#1A1814` body, postcard ochre `#A6783A` headings/day labels, lisbon-tile blue `#2E5A7A` for citation chips and transit lines, sun-warmed terracotta `#C0593A` for the fica accent. Amber `#D4A24A` for the 30–90-day hours-staleness chip; faded red `#A33A2C` for the 90+ day chip and closure warnings. - **Imagery:** Photographic. Sunlit café tables, paper maps, real pavement tile, a metro ticket beside a coffee cup. Nano Banana Pro for the saved-PDF hero (4K legible typography); Nano Banana 2 for share-card thumbnails. No travel-app stock-photo sunsets. - **Hand-feel touches:** Day cards have a thin paper-shadow like postcards pinned to a corkboard. Citation chips lift on hover. The "fica" card has a soft watercolour-wash background. The hours-staleness chip carries a tiny "as of {date}" tag. - **Spacing:** consistent 4-px base. Generous whitespace. - **Radius:** consistent token set (6 / 12 / 20 px). Place cards 6; fica card 12; welcome card 20. - **Shadows:** subtle, layered, warm-tinted. - **Motion:** purposeful — entrance fades, hover lifts. Respect `prefers-reduced-motion`. The off-the-obvious-list toggle slides the new itinerary into place over 400 ms; reduced-motion jumps. No bouncing splash animations. - **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons not spinners where possible. Empty states have specific next-action guidance. ## 8. Content generation rules - Write **realistic, specific copy**. NO Lorem Ipsum, NO 'Your tagline here' placeholders. - Use plausible itinerary copy from the seed content in 8a. Seed content uses *placeholder* place names like "the Príncipe Real wine bar" — the deployer wires the grounded-search call before shipping; the live app never shows the placeholders. - Tone: warm, direct, free of corporate language. For a traveller, not a travel company. - Headlines: punchy, concrete. No 'Empower', 'Revolutionize', 'Seamless', 'AI-powered'. - Body copy: short paragraphs (2-4 sentences). Lists where useful. - Plain language; no jargon. - Never label model output as "AI says" — let it speak naturally. Uncertainty cues only where epistemic honesty requires (the hours-staleness chip, the "we didn't find anything we'd vouch for" empty state). ## 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. Note: place names below are placeholders — the real deployed app fills them from grounded search at request time. **Sample trip prompts (sidebar):** - "Lisbon, Friday evening to Monday morning, two adults, like cocktail bars and quiet bookshops, hate queues" - "Tokyo, June 10–17, two adults plus kids ages 6 and 9, like ramen and parks, hate long museum tours" - "Big Sur, 4 nights, dog-friendly cafes and quiet beaches only" - "Berlin, one evening between meetings, like good wine and quiet places, Thursday from 19:00 to midnight" - "Manchester, two days, my friend likes vintage shops and good coffee, off the beaten path — I'm already here" - "Paris with my mum, three days, like quiet gardens and good bread, step-free transit only" **Sample itinerary in detail view (placeholders — the real app fills these from grounded search):** - **Trip:** Lisbon long weekend · **Dates:** Fri 2026-06-12 eve → Mon 2026-06-15 morn · **Party:** 2 adults · **Likes:** cocktail bars, quiet bookshops · **Dislikes:** queues · **Off-the-obvious-list:** false - **Fri evening:** A small wine bar in Príncipe Real, opens 18:00 closes 01:00 (hours as of 2026-06-01, fresh chip). Why this one: "favoured by Lisbon locals for the natural-wine list and the quiet courtyard, per a recent resident-written guide". 90 min. - **Sat morning:** A bookshop in Alfama, opens 10:00 Saturdays (source dated 2026-04-25 → amber chip). Why this one: "a quiet ground-floor shop with new Portuguese fiction and a tiny window seat". 8 min walk uphill from the wine bar. 60 min. - **Sat afternoon:** A river viewpoint with a small café underneath (opens 14:00). 120 min total. - **Sat evening:** A second cocktail bar tucked behind a noisier square. Hours fresh. 90 min. - **Sun morning:** A small museum the queue-hating user can enter via side door past 11:00. Hours date-stamped. 90 min. - **Sun afternoon:** A pastry shop with a sunny terrace. - **Sun evening (FICA):** A late-night bookshop with a cocktail bar inside, open until 00:00 Sundays. rationale_two_lines: "You said you like cocktail bars and quiet bookshops. This place is both, in one room, and stays open late enough to drop in after dinner without feeling rushed." user_preference_cited: "quiet bookshops". confidence: 0.86. - **Mon morning:** A pastry on the walk to the airport bus. 30 min. - **itinerary_warnings:** "Two of the originally-considered wine bars (Bairro Alto) are closed for staff holiday during your dates, per their most recent social posts. We have routed around them." - **itinerary_confidence:** 0.78 - **"If it rains" backups:** covered market (Sat morning); record- and-coffee shop in Cais do Sodré (Sat afternoon). **Sample voice copy:** - Onboarding: "Type the trip. Get the itinerary in 60 seconds." - Empty input: "Where to, when, who's coming, what do you like and hate? Free-write anything, we'll figure out the structure." - Processing: "Reading your preferences…" / "Searching for cocktail bars in Príncipe Real…" / "Checking opening hours…" / "Plotting transit…" / "Finding the one you'd regret missing…" - Save confirmation: "Saved your Lisbon long weekend to your trip library." - Confidence summary: "We're confident in 6 of the 7 places. The bookshop's hours are from 47 days ago — call ahead if you're cutting it close." - Closure note: "Two of the originally-considered wine bars are closed for staff holiday during your dates. We've routed around them." - Hours-staleness chip: "as of 2026-04-25" (band colour matches). - Fica card heading: "fica — don't miss this". - Off-the-obvious-list hint: "Pulling from neighbourhood Substacks and resident sources only — this can take an extra 20 seconds." - Empty-results message: "We didn't find any places we'd vouch for at this combination of constraints. Try relaxing 'cocktail bars' to 'cafés' or widening the area." - Save-to-Maps button: "Save 7 places to Google Maps". - Save-to-Maps fallback: "Couldn't reach Google Maps right now — download the list as a CSV and import it in your Maps app (Menu → Your places → Lists → Import)." ## 9. Media & assets - **Hero image (landing):** Nano Banana Pro photograph — café table in soft afternoon light, paper map open, espresso cup, notebook with handwritten entries. Prompt anchors: "warm afternoon light through a café window, paper map on marble café table, small white espresso cup, notebook open with handwritten itinerary lines, no people, no commercial branding". Aspect ratio 3:2. - **App icon / wordmark:** display serif on worn-paper texture. No airplane/globe/suitcase iconography — just type. - **Empty-state illustration:** simple line drawing of a postcard with a stamp and three handwritten lines. Generate once at build time via Nano Banana 2 (`gemini-3.1-flash-image`), 1:1 WebP at 1024×1024, prompt: "single hand-drawn ink line illustration of a rectangular postcard at a slight tilt, a square stamp in the top-right corner, three short squiggle lines suggesting handwriting in the address area, off-white paper background, slight pen imperfection, no shading, no colour fill, no real text, no commercial branding". Ship as a seed asset at `/public/samples/empty-state-postcard.webp`. - **Saved-PDF cover postcards (Nano Banana Pro `gemini-3-pro-image`):** 4K with the destination name set in legible serif typography on the image. Runtime-generated per saved trip; prompt uses the trip's destination string. Stored in Firebase Storage at `users/{uid}/trips/{tripId}/cover.png`. Post-I/O 2026's legible-text-at-4K is the key capability. - **Share-card thumbnails (Nano Banana 2 `gemini-3.1-flash-image`):** 1:1 squares for chat-app preview cards. Runtime-generated per share link, prompted with the destination name and the dominant palette colour. Stored at `users/{uid}/trips/{tripId}/share.webp`. - **Stock fallbacks:** if image generation fails, fall back to `/public/samples/sample-postcard.jpg` (3:2 WebP, 2048×1365 — ship as a seed asset; recreate via Nano Banana Pro (`gemini-3-pro-image`) with the prompt: "photographic vintage paper postcard laid flat on a marble café table, warm afternoon light, a small white espresso cup at the right edge, a square stamp in the top-right of the postcard with a generic non-existent fictional country wordmark rendered legibly at 4K, three lines of handwritten itinerary in ink on the postcard's address half, real paper texture, no people, no real country names, no commercial branding"). Never to a default flat icon. - **Optimisation:** WebP/AVIF, `loading="lazy"`, explicit `width`/`height` to prevent layout shift. - **Icons:** `lucide-react`, sparingly, never decorative-only. ## 10. Interactivity & states - Every interactive element has hover, focus, active, disabled. - Forms validate inline with specific errors ("We couldn't parse the dates — did you mean Fri 2026-06-12 to Mon 2026-06-15?"). - Loading uses streaming text naming the model's current phase ("Searching for cocktail bars in Príncipe Real…"), never a generic spinner. - Empty states have domain-specific next-action labels ("Type your destination, dates, and preferences"; "Tap a place to see its sources"; "Try widening the preference"). No "Add your first item". - AI content streams token-by-token where supported. - AI-call failures show calm, specific errors ("We couldn't reach Gemini just now — retry or load the Lisbon sample to see how it behaves") with retry. - The hours-staleness chip is non-dismissable, three bands: green/neutral (fresh), amber (30–90 days), red (90+ days). Hover reveals the source publish date verbatim. - The closure flag surfaces as a coloured chip with the closure type, reason, and source link. - The off-the-obvious-list toggle slides the new itinerary in over 400 ms; reduced-motion jumps. - The Save-to-Maps button shows a per-call status (spinner → green check) and falls back to CSV guidance on `WORKSPACE_INTEGRATION_UNAVAILABLE`, not retried silently. - The fica card is the visually-most-emphasised element after the day grid; soft paper-flip on first appear, fade under reduced-motion. ## 11. Tech & responsive requirements - **Stack:** React + TypeScript + Tailwind CSS. Functional components + hooks. Shadcn UI primitives. The transit-sketch map is a lightweight custom SVG (no heavy mapping SDK in v1). - **Build runtime:** AI Studio Build full-stack with Cloud Run server functions. Every Gemini call server-side; API key in Secrets Manager. First two Cloud Run apps free (post-I/O 2026). - **Model selection:** pin `gemini-3.5-flash` for every text call; `gemini-3-pro-image` (Nano Banana Pro) for the cover; `gemini-3.1-flash-image` (Nano Banana 2) for the share thumb. `thinkingLevel: "low"` everywhere. Do NOT pin `gemini-3.5-flash` — announced but not GA as of 2026-06-01. - **Grounded search calls:** every grounded call uses `tools: [{ googleSearch: {} }]` and omits `responseSchema`. The schema lives in `systemInstruction`; the model emits JSON in `response.text`; citation URLs from `response.groundingMetadata.groundingChunks[].web.uri`. - **Database / Auth:** Firestore (auto-provisioned); Firebase Auth with Google sign-in default, Apple optional, magic-link for share links. - **Workspace integration:** the Firebase Google sign-in is the only auth needed for Save-to-Google-Maps (post-I/O 2026 no-OAuth pathway). Fall back to CSV download on `WORKSPACE_INTEGRATION_UNAVAILABLE`. - **Long-context guardrail:** a 3-day grounded itinerary stays under 50k tokens. Future trip-library digests should chunk to keep payloads under 500k tokens. - **Mobile-first.** Verify layouts at 375 / 768 / 1024 / 1440+ px. `clamp()` for fluid typography; container queries over media queries; `dvh` / `svh` not `vh`; safe-area insets on iOS. Zero horizontal overflow, zero layout shift. - **Persistence:** Firestore real-time listeners on the active itinerary view; optimistic UI on writes. - **Offline:** the most recent itinerary is cached client-side for offline reading; generation requires connectivity. - **Local save fallback (FSA non-Chromium):** the "Save as PDF" affordance uses `showSaveFilePicker()` on Chromium; on Safari / Firefox, fall back to an `` element pointed at a blob URL — FSA `showSaveFilePicker()` is not supported there. ## 12. Accessibility (WCAG 2.2 AA) - Semantic HTML — `header`, `nav`, `main`, `section`, `article`, `footer`. Every interactive control keyboard-reachable with a visible focus ring. Contrast ≥ 4.5:1 body / 3:1 large + UI. - All images have meaningful `alt`. The Nano Banana Pro postcard hero `alt` describes the destination ("postcard-style image with the word 'Lisbon' set in serif type over a warm afternoon photograph of a tiled square"). - Form fields have associated `