# 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 `