================ ONE-SHOT BUILD CONTRACT (read first) ================
Build this in Google AI Studio "Build" in ONE shot — a complete, working app,
no follow-up turns. These are hard rules, not suggestions:
1. TARGET = Full-Stack Web (Node server runtime, secrets, Firebase allowed).
If you target Native Android instead, you MUST drop all server/DB/Workspace/
secrets and persist locally (Room / SharedPreferences) only.
2. PIN EVERY MODEL CALL — never let the agent auto-select (it downgrades on quota):
- Reasoning / text -> gemini-3.5-flash (thinkingLevel: minimal|low|medium|high)
- 4K image + legible text -> gemini-3-pro-image (image_size "4K", up to 14 refs)
- High-volume image -> gemini-3.1-flash-image
- Expressive TTS -> gemini-3.1-flash-tts-preview (inline tags e.g. [whispers])
- Realtime audio/video (WebSocket) -> gemini-3.1-flash-live-preview
- Sandboxed agent -> antigravity-preview-05-2026
3. DIVISION OF LABOR — the model ONLY parses/extracts to a strict responseSchema.
ALL math, money (store currency as integer minor units / cents), sorting,
balancing and graph logic run in deterministic TypeScript/Python. The model
must never compute totals, splits or balances itself.
4. responseSchema sanitation — no regex patterns, no fixed-length tuples, no
format validators in the schema (they crash the OpenAPI engine). Enforce those
in server-side code AFTER parsing the JSON.
5. responseSchema and google_search grounding are MUTUALLY EXCLUSIVE in one call.
6. CODEGEN — split large output into modular, single-responsibility files so no
file is truncated by the output-token cap.
7. Every external call gets a graceful fallback (e.g. manual paste if a Workspace
read fails). Never a silent dead end.
8. ROBUST STORAGE & CANVAS — Wrap all `localStorage`/`sessionStorage` operations (especially JSON parsing and writes) in `try-catch` blocks to prevent crashes in private windows or quota overflows. Canvas drawing elements must dynamically handle window resize and scale pixel density (`window.devicePixelRatio`) to avoid blurry graphics on retina displays.
=====================================================================
# MUST OBEY — Mobile-first build requirements
This app's PRIMARY surface is a mobile phone. Build it impeccably on mobile FIRST, then verify on tablet and desktop. Treat the rules below as non-negotiable hard constraints, not suggestions.
## Viewports to verify (every screen, every state)
- 320 px, 360 px, 375 px, 390 px, 414 px, 480 px
- 768 px, 834 px (iPad portrait / Pro 11)
- 1024 px, 1280 px, 1440 px, 1920 px, 2560 px
- Plus: 200% browser zoom, landscape orientation on every mobile width, iPhone with safe-area insets visible
## Hard layout rules
- Mobile-first CSS. Default styles target mobile; `@media (min-width: ...)` for larger viewports.
- Use `dvh` and `svh` instead of `vh` for full-height surfaces (iOS Safari URL-bar bug).
- Use `clamp()` for fluid typography across all viewports.
- Prefer container queries (`@container`) over media queries for component-level responsiveness.
- Use `min(100%, ...)` widths so content never overflows. Zero horizontal overflow at any viewport.
- Add `` to every page.
- Apply `padding: max(safe-area-inset-X, fallback)` on every edge-bleeding container so notched iPhones in landscape never clip content.
- Wide tables and code blocks scroll INSIDE their container (`overflow-x: auto`), never push the body.
- Use `background-attachment: scroll` on mobile, not `fixed` (iOS Safari repaint bug).
- Avoid `backdrop-filter` on animated elements. Use it sparingly on static surfaces only.
- **Canvas Scaling**: Canvases must dynamically scale with window resize events and properly handle high-DPI screens (`window.devicePixelRatio`). Set physical dimensions (`canvas.width`/`canvas.height`) using pixel ratio and render relative to this grid, using CSS to control responsive viewport scaling.
- **Robust Storage**: Every access to `localStorage`/`sessionStorage` (especially `JSON.parse` of loaded state or writes) MUST be wrapped in a `try-catch` block to handle disabled storage, private browsing mode, quota limits, or corrupted JSON gracefully. Fall back to a robust in-memory object store.
## Touch & accessibility
- Tap targets ≥ 44 × 44 px on touch (Apple HIG). Increase to 48 px under `@media (hover: none) and (pointer: coarse)`.
- All interactive controls reachable by keyboard with a visible focus ring; respect `:focus-visible`.
- Color contrast ≥ 4.5:1 for body text, 3:1 for UI components.
- All images have meaningful `alt`. Decorative images use `alt=""`.
- Respect `prefers-reduced-motion: reduce` — zero animation durations under that query.
- Forms validate inline; error messages are specific, not "Invalid input".
- Modals: focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, focus restored on close.
## Performance bar (Lighthouse mobile, throttled 3G/4G)
- LCP < 2.5 s · INP < 200 ms · CLS < 0.1
- JS bundle gzip < 200 KB mobile-first; lazy-load non-critical screens via `React.lazy` / dynamic imports.
- No render-blocking resources above the fold.
- Images: WebP/AVIF preferred, `loading="lazy"`, explicit `width`/`height` attributes (zero CLS), `srcset` for retina.
- Videos: `preload="metadata"`, low-resolution poster, max 720p mobile fallback. Never autoplay with audio.
- Fonts: `font-display: swap`; preload only the one used above the fold.
- Smooth scroll honoured via CSS `scroll-behavior: smooth` with reduced-motion fallback.
## Pre-ship mobile checklist (the deployer MUST verify before declaring done)
1. Open at 375 px in DevTools — every screen scrolls vertically only; zero horizontal scroll.
2. Browser zoom 200% — layout reflows without overlap.
3. iPhone Safari with the URL bar visible AND landscape — no content under the home indicator; no notch clipping.
4. iPad portrait (768 px) and landscape (1024 px) — no awkward gaps; tablet-specific breakpoints land cleanly.
5. Tap every interactive element with a thumb at real-device size — every target is easy to hit.
6. `prefers-reduced-motion: reduce` — every transition / animation skips cleanly, scroll-behavior becomes instant.
7. Lighthouse mobile score ≥ 90 across all 4 categories.
8. Zero `console.error` and zero CLS shift in real-device testing on a mid-tier Android (e.g. Pixel 6a) and an iPhone SE.
---
The original template starts below. All rules above apply on TOP of whatever this template specifies.
---
# Lost-Pet Net
## 1. Project
**Lost-Pet Net** is a neighbourhood-scale matcher for the moment one
person picks up a stray and another person, somewhere within a few
miles, is refreshing their phone hoping for news. The finder takes a
photograph of the animal, the app reads the photograph against every
opt-in missing-pet post in a geofenced radius, and — if a face, coat,
or marking matches — places a single button on the screen: *Call this
person now*. If nothing matches, the same screen offers *Post as
found*, and the post is fanned out only to neighbours who have
opted in to receive lost-and-found alerts in that radius. The
matching is multimodal: a tilted phone-camera photo of a shaking
mongrel under a bus stop is compared against the daylight studio
portrait the owner posted three days ago, plus the colour notes
("white sock front-left, brown patch over the right eye"), plus
the microchip number if the finder can read it from a tag.
This is the kind of app a dog walker in Brixton opens when a wet
collie crosses three lanes of South London traffic and follows him
home, no collar, no tag, shivering hard enough that he can feel the
ribs through the coat. It is also the kind of app a teenager in
Quezon City opens when a brown aspin trots out from under a jeepney
during a brownout and won't leave her side, and a delivery rider in
Lagos opens when a Boerboel puppy with a fresh bite on its ear runs
into the petrol station forecourt where he's filling his Bajaj. Same
shape of moment, different climate, different scale of city.
The single demo that proves the magic: a finder snaps one photograph
of the stray under whatever light they have — a flickering street
lamp, the cold blue of a 7-Eleven, the dim fluorescent of an underground
car park. In fifteen seconds the app rank-orders the missing-pet posts
within a 3-mile radius by visual + textual similarity, surfaces the
top match with a confidence band, and shows the owner's preferred
contact method as a single tap target: *Call Maya now.* If no match
clears the threshold, the screen flips to *Post this dog as found —
your neighbours within 3 miles will see it.* The finder writes one
line ("under the railway bridge on Atlantic Road, dry but shivering")
and the post is live in twelve seconds. No public timeline, no
shareable URL, no broadcast — just the people in the geofence who
asked to be notified.
And in the harder cases — animals without microchips, animals whose
photographs are old, animals whose owners are not on the internet —
the app reads what is in the picture: the missing canine tooth on
the upper-left, the kink in the tail, the freckle on the inside of
the ear. It compares those forensic markers against the descriptions
owners typed when they posted, not only the photographs they uploaded.
A stray that looks like ten other strays often has one distinguishing
mark, and the app treats that mark as the signal.
**Tagline:** _Reunite a found pet with their family — in any neighbourhood, any climate, with one photograph and one tap._
## 2. Target audience
- Dog walkers, runners, and delivery riders who routinely encounter strays mid-route — Brixton, Quezon City, Lagos, Mumbai, Mexico City, Istanbul, São Paulo
- Owners of indoor cats and dogs who just realised the front door was left ajar at 4:47 pm and are about to start refreshing every channel they know
- Veterinary receptionists fielding a daily stream of walk-ins with no collar — they need a single search tool, not seven Facebook groups
- Animal shelter volunteers triaging incoming intakes and matching them against open lost reports before the seven-day hold ends
- Block captains, building concierges, and apartment-complex superintendents who become the de-facto stray clearing-house for their block
- Diaspora and immigrant families whose missing-pet posts on neighbourhood Facebook groups go unread because they were written in Tagalog, Vietnamese, Punjabi, or Amharic
- Senior owners who do not use Instagram, Nextdoor, or any social network, and whose lost-pet flyer would otherwise be a paper one taped to a lamppost
- Bilingual neighbours who become the unpaid translators when a non-English-speaking owner posts a lost-pet notice on the wrong channel
- Cycling couriers, taxi drivers, and night-shift bus drivers — the people most likely to see an animal moving across a city at 3 am
## 3. Core value propositions
Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons users pick this app.
- **One photograph, one tap to the owner.** A finder shouldn't have to know which neighbourhood Facebook group, which Nextdoor sub-area, which Reddit subforum. They take one photograph; the app does the matching and surfaces the contact action. Time-critical animals don't survive a forty-minute search across seven platforms.
- **Multimodal similarity, not just face match.** Gemini 3.5 Flash compares the photograph against every opt-in lost-pet post in the radius — the owner's photographs, the colour descriptions, the distinguishing-mark notes, the microchip number if visible on a tag, the breed estimate. Forensic markers (the freckle on the ear, the missing canine tooth, the kink in the tail) are weighted higher than coat colour, because coat colour is shared across thousands of mixed-breed dogs in any city.
- **Opt-in posts only.** The app never scrapes Instagram, Facebook, Nextdoor, or any social network. The corpus is exactly the posts owners chose to put in this app. This rule is not a feature; it is the foundation. A finder cannot match a stray against a stranger's photographs that were never posted as "lost".
- **Geofenced by default.** A finder in Brixton sees missing-pet posts within 3 miles of where the photograph was taken. A post created in Brixton is fanned out to opted-in receivers within 3 miles. The radius is adjustable upward when a search has gone cold, never downward in a way that hides posts from a finder who is close.
- **Data is reunited away.** When an owner marks a pet "reunited", the post is archived to their account only and removed from the geofenced corpus within twenty-four hours. The photographs and notes do not persist as a global lost-and-found archive. The app is a flare gun, not a database.
- **Works in any language the neighbourhood speaks.** A lost-pet post in Tagalog matches a finder's description in English; a poster in Punjabi reaches a finder in Urdu; a poster in Amharic reaches a finder in Swahili. Translation is internal to the match, never visible as marketing copy. The owner sees their original; the finder sees theirs.
- **One-tap contact respects the owner's preference.** Some owners want the phone to ring. Some want a WhatsApp text. Some want a relay number that hides their personal phone. The app honours what the owner picked when they posted.
- **For owners who never came online.** A paper flyer taped to a lamppost can be photographed by a neighbour into the app. The app's flyer-OCR step extracts the description, the contact number, and the date posted, and turns it into a searchable lost-pet entry for as long as the flyer is up — with the owner-credited "added by [neighbour first name]" provenance.
## 4. Features to build
- Camera capture optimised for low light, motion, and animals that won't sit still — a single tap takes a 5-frame burst and uses the sharpest frame
- Upload from photo library, including video — the app extracts the highest-similarity still from up to 8 seconds of footage
- Geolocation prompt on capture (with reverse-geocoding to a neighbourhood name the finder recognises) — the finder can correct the location before searching
- Multimodal match — Gemini 3.5 Flash compares the captured photograph against every opt-in lost-pet post within the radius, weighing forensic markers above coat colour
- Confidence bands, not percentages — "Very likely match", "Possible match — please verify", "No strong match in this radius"
- Owner-preferred contact action surfaced as a single button — *Call*, *WhatsApp*, *Relay number*, or *Message inside this app* depending on what the owner chose when they posted
- *Post as found* flow — one short location note, one photograph, optional description; auto-translated to the neighbourhood's languages
- *Post as lost* flow — for owners, with explicit fields for distinguishing markers, microchip number, last-seen location, last-seen time, and a paragraph in the owner's own words about the animal's temperament
- Flyer-OCR step — a finder or volunteer can photograph a paper flyer taped to a lamppost; the app extracts the description and contact, marks the entry as "added by [finder] — owner not yet verified", and keeps it searchable until the flyer is dated as gone
- Geofenced fan-out — every "lost" post is delivered only to opt-in receivers within the configured radius (default 3 miles, owner-adjustable upward)
- Push notifications for receivers — opt-in per pet species (dogs / cats / rabbits / parrots / other) and per radius — no surprise broadcasts
- "Found by another finder" thread — if two finders post the same stray within a short window, the app merges them and surfaces both sightings to the owner
- Reunion flow — owner taps "Reunited", the post is removed from the corpus within 24 hours, the finder receives an in-app thank-you and (if both consent) a one-line note ("Maya wanted you to know: Beni is home, asleep on her foot.")
- Privacy preview — before any post goes live, the owner sees exactly which radius it will fan out to and the number of receivers in that radius (e.g., "247 neighbours have opted in within 3 miles")
- Microchip lookup — if a finder can read a microchip number from a tag, the app offers a one-tap link to the official national microchip registry (Petlog UK, AAHA US, PhilGuard PH, etc.); the app does not store the chip number
- Vet & shelter receiving mode — verified veterinary receptionists and shelter intake desks can search the lost-pet corpus from a single panel and contact owners on behalf of the animal in their care
- Animal welfare safety — if an image suggests injury, hypothermia, or other emergency, the finder is shown the nearest 24-hour vet and emergency animal services for the geofence, before the contact-owner CTA
- Pet temperament tag from owner — *friendly with strangers*, *nervous — let her come to you*, *do not chase*, *food-motivated*, *deaf — approach from the front* — surfaced prominently to a finder before the call CTA
- Time-to-reunion stats per neighbourhood (aggregate only) — "median reunion time in your area: 9 hours" — never per-pet, never per-owner
## 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)
- **Multimodal image input** (Gemini 3.5 Flash) — reads a tilted phone-camera photograph of a moving animal under whatever light is available. Extracts coat colour, coat pattern, ear shape, tail length, body proportions, breed estimate (with a hedge band), and a list of forensic markers (the freckle on the inside of the right ear, the missing canine on the upper-left, the kink in the tail two-thirds of the way down). One call per finder photograph. The same call reads the owner's photographs the same way at the moment the owner posts, producing a comparable feature vector + a comparable forensic-marker list.
- **Structured output / JSON Schema** — both finder and owner photographs return the `PetSighting` and `PetLostPost` schemas below. The match call returns a `MatchResult` with a confidence band, a per-marker reasoning trail, and the owner's preferred contact action.
- **Multilingual reasoning** (built into Gemini 3.5 Flash) — a Tagalog post matches an English photo; a Punjabi description matches a Tamil sighting note; an Amharic post matches a Swahili finder description. Gemini reads both, compares them, and surfaces matches without exposing translation as a feature.
- **Long context (1M tokens)** — once the radius has 200+ open lost-pet posts (dense urban evening, summer storms, fireworks night), the match call sees every open post at once for a single finder photograph. **Guardrail**: a parsed PetLostPost averages ~1,500 tokens (description + forensic markers + 3 photo summaries); 500 posts ≈ ~750k tokens (within the 1M ceiling). Above 500, narrow by species, then by coat colour band, before the full multimodal compare.
- **Search grounding** — for emergency veterinary lookups in the geofence ("nearest 24-hour vet within 2 miles of this dropped pin"), and for the official microchip-registry redirect by jurisdiction. The owner's contact info is never grounded; it comes from the owner's own post.
- **Nano Banana 2** (`gemini-3.1-flash-image`) — generates the printable "found this dog" flyer (with the finder's photograph, the contact action, the location of the find, and a QR code to the in-app post) that a finder can hang at the local shop, vet, or lamppost. One image generation per flyer. Also generates the optional "match brief" image a vet/shelter receptionist can show the animal's family on arrival.
- **Gemini TTS** (`gemini-3.1-flash-tts-preview`) — narrates a lost-pet post aloud in the owner's preferred language when receivers tap "listen to this post" — for users with low vision, for users at the wheel of a car, and for users whose first language is not the post's written language. The receiver's app picks a TTS voice whose `languageCode` matches the post's `source_language`.
- **Thinking levels** — `medium` for the primary match call (visual compare + multilingual description compare + forensic-marker disambiguation). `low` for the photograph-parse calls on capture and on post. Surface `thoughtSummary` only when the finder asks "why this match?" via the per-match (i) icon.
### Backend services
- **Auth — Required.** Firebase Auth with Google sign-in (auto-provisioned by AI Studio Build). Phone-number sign-in is required for the *Call this person now* CTA to verify the call recipient is the owner; phone-number sign-in is auto-provisioned by Firebase Auth but the user-config step requires SMS verification quotas. **Apple sign-in is optional but user-configured**: it requires an Apple Developer account, Service ID, Key ID, and private key wired into the Firebase Auth console. **Magic-link email** (for owners who post via email and never sign in) requires sender-domain authorisation in Firebase Auth.
- **Database — Required.** Firestore for `users`, `pets`, `lost_posts`, `found_sightings`, `match_threads`, `reunions`, `geofence_subscriptions`, `flyer_ocr_entries`, `vet_partners`, `shelter_partners`.
- **File storage — Required.** Firebase Storage for finder photographs and owner photographs (preserved at upload resolution for the lifetime of the post, then archived to the owner's account only on reunion). **Storage is NOT auto-provisioned by AI Studio Build today** — enable it in the Firebase console and wire the bucket name into the AIS Build project before first photograph upload. Pre-signed URLs only; photographs are never publicly addressable, even after reunion.
- **Email — Required (transactional).** Owner email confirmation on post; finder receipt on submission; vet/shelter intake digests. Never used for marketing.
- **Push — Required.** Geofenced push notifications via Firebase Cloud Messaging. Receivers opt in per species + per radius and can revoke from a single screen.
- **SMS — Optional (relay number).** Twilio (or equivalent) is required if the *Relay number* contact option is enabled — the owner's real phone number is replaced with a temporary number that proxies the call for 72 hours after a match. If Twilio is not configured, the *Relay number* option is hidden from the post composer and only direct contact methods are offered.
- **Payments — Not needed for v1.** Free for personal use. A possible future "premium poster radius up to 10 miles" tier is out of scope. A future printed-flyer-mailed-to-vets-and-shelters tier could pipe to a print-and-mail partner; charge for the physical artefact only.
- **External APIs:** Gemini API for all intelligence; Mapbox or MapTiler for the geofence map; optional Twilio for the relay-number option; optional microchip registry redirects (Petlog, AAHA, PhilGuard, etc.) — never an API integration, just a redirect link.
**Environment variables:** every secret (Gemini API key, Firebase service-account JSON, Mapbox token, Twilio account + auth token, FCM server key) lives in environment variables — never in client bundle. Include a `.env.example`.
**Auth + data privacy reminders:** never log secrets · never store passwords in plain text · use HTTPS everywhere · honour 'delete my account' inside the UI · explicit opt-in for any analytics · the user's photographs and the animal photographs 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) · the owner's phone number is never shown to a finder unless the owner picked *Call directly* as the contact action · the finder's exact location is never shown to a receiver (only the neighbourhood name; coordinates are coarsened to the nearest 500 m for the radius compare).
**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 or `responseSchema` field. The literal schema is below. **Convert the Zod schema to Gemini's `Schema` type via the SDK helper** before passing to `responseSchema` — do NOT pass raw Zod. **Numeric `min`/`max` constraints are documentation only inside `responseSchema`; clamp on the server after the response arrives.**
4. **Pin the system instruction separately** from user input. Use the `systemInstruction` field for persona + behavioural rules; use `contents` for user input. Never concatenate.
5. **Pre-declare tools as an enable/disable list** per call. The matrix below names which tools are enabled per call. Tools NOT listed for a call should be disabled.
6. **State negative constraints explicitly** — they are listed below. They are NOT "be careful" suggestions; they are hard rules the model must follow.
7. **Grounded responses can wrap JSON in ```json fences or add prose preamble.** Server-side, strip fences and brace-extract:
```typescript
function safeExtractJSON(raw: string): T {
const clean = raw.replace(/```json\s*|```/gi, '').trim();
const s = clean.indexOf('{'); const e = clean.lastIndexOf('}');
if (s === -1 || e === -1) throw new Error('No JSON boundaries in grounded response');
return JSON.parse(clean.slice(s, e + 1)) as T;
}
```
8. **Strip unsupported Zod modifiers before passing to `responseSchema`** — Gemini's OpenAPI subset rejects `.regex()` / `pattern`, fixed-length `z.tuple()`, and other custom validators. Use a sanitizer that flattens tuples to length-2 arrays and removes regex patterns before serializing. Validate those constraints in middleware AFTER parsing.
### Per-call model + tools matrix
| Call | Model | thinkingLevel | Tools enabled |
|------|-------|---------------|---------------|
| Parse finder photograph → `PetSighting` schema | `gemini-3.5-flash` | low | (none) |
| Parse owner photograph + description → `PetLostPost` schema | `gemini-3.5-flash` | low | (none) |
| Compare finder sighting against open lost posts in radius → `MatchResult` | `gemini-3.5-flash` | medium | (none) — long context over the open-post corpus |
| Parse flyer-OCR (lamppost flyer photographed by neighbour) | `gemini-3.5-flash` | low | (none) |
| Nearest 24-hour emergency vet lookup for geofence | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema` on this call — see note) |
| Generate "found this dog" printable flyer image | `gemini-3.1-flash-image` | n/a | n/a |
| TTS narration of lost-pet post in owner's language | `gemini-3.1-flash-tts-preview` | n/a | n/a |
*Note for builders:* on TTS and image-generation calls, 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.
### Primary structured-output schema (seed this verbatim in the prompt)
```typescript
import { z } from "zod";
const ForensicMarker = z.object({
marker_type: z.enum([
"missing-tooth",
"scar",
"freckle-or-spot",
"tail-kink",
"ear-tear-or-notch",
"heterochromia",
"limp-or-gait",
"amputated-limb",
"tattoo",
"collar-or-tag-present",
"microchip-tag-visible",
"coat-whorl",
"other",
]),
body_region: z.enum([
"head-face", "ears", "mouth-teeth", "neck-throat",
"shoulders", "chest", "back", "flanks",
"front-left-leg", "front-right-leg", "back-left-leg", "back-right-leg",
"paws", "tail", "belly", "rear", "whole-body",
]),
description_short: z.string(), // "freckle on inside of right ear"
description_verbatim_if_text_visible: z.string().nullable(), // tag text, microchip number etc.
confidence: z.number().min(0).max(1),
});
const CoatProfile = z.object({
primary_colour: z.enum([
"white", "cream", "tan", "fawn", "red",
"brown", "chocolate", "black", "grey", "blue-grey",
"merle", "brindle", "tricolour", "tortoiseshell", "calico",
"tabby", "pointed", "other",
]),
secondary_colours: z.array(z.string()),
pattern: z.enum([
"solid", "patched", "spotted", "ticked",
"brindle", "merle", "tabby-mackerel", "tabby-classic",
"tabby-spotted", "pointed", "particoloured", "tuxedo",
"saddle", "tortoiseshell", "other",
]),
coat_length: z.enum(["short", "medium", "long", "curly", "wire", "hairless"]),
distinguishing_coat_notes: z.string().nullable(),
});
const SpeciesEstimate = z.object({
species: z.enum(["dog", "cat", "rabbit", "parrot", "tortoise", "other"]),
breed_estimate_primary: z.string().nullable(), // "likely Aspin (Philippine native dog)"
breed_estimate_secondary: z.string().nullable(),
breed_confidence: z.number().min(0).max(1),
age_estimate: z.enum(["puppy-or-kitten", "young", "adult", "senior", "unknown"]),
size_estimate: z.enum(["toy", "small", "medium", "large", "giant", "unknown"]),
body_condition: z.enum([
"underweight-severe",
"underweight",
"ideal",
"overweight",
"unknown",
]),
});
const WelfareFlag = z.object({
flag: z.enum([
"visible-injury",
"hypothermia-signs",
"heat-stress-signs",
"dehydration-signs",
"limping",
"bleeding",
"old-fracture-shape",
"embedded-collar",
"appears-pregnant-or-nursing",
"appears-elderly-frail",
"appears-aggressive-or-fearful",
"no-immediate-welfare-concern",
]),
severity: z.enum(["low", "medium", "high", "emergency"]),
reasoning_short: z.string(),
});
const PetSighting = z.object({
sighting_id: z.string(),
finder_user_id: z.string(),
capture_image_uris: z.array(z.string()), // up to 5 burst frames + uploads
capture_timestamp_iso: z.string(),
capture_location_lat: z.number(),
capture_location_lng: z.number(),
capture_location_neighbourhood: z.string(), // reverse-geocoded
capture_location_accuracy_m: z.number().int(), // GPS uncertainty
capture_lighting_condition: z.enum([
"bright-daylight", "overcast-daylight",
"indoor-fluorescent", "indoor-incandescent",
"street-lamp-sodium", "street-lamp-led",
"dusk-dawn", "near-dark", "mixed", "unknown",
]),
species_estimate: SpeciesEstimate,
coat_profile: CoatProfile,
forensic_markers: z.array(ForensicMarker),
welfare_flags: z.array(WelfareFlag),
collar_or_tag_text_verbatim: z.string().nullable(), // if a tag is readable
microchip_tag_number_verbatim: z.string().nullable(), // never stored beyond match
approachability_note: z.string().nullable(), // "approached me willingly"
free_text_note_from_finder: z.string().nullable(),
reading_confidence: z.number().min(0).max(1),
flagged_for_user_review: z.array(z.object({
field_path: z.string(),
reason: z.string(),
})),
});
const ContactPreference = z.object({
method: z.enum(["call", "whatsapp", "sms", "relay-number", "in-app-message"]),
display_label: z.string(), // "Call Maya now"
preferred_hours_local: z.string().nullable(), // "any time" / "before 22:00"
language_preferred: z.string(), // BCP-47, "tl-PH"
});
const PetLostPost = z.object({
post_id: z.string(),
owner_user_id: z.string(),
pet_name: z.string(), // "Beni"
pet_name_pronunciation_note: z.string().nullable(), // "Beh-nee"
source_language: z.string(), // BCP-47 of the post body
post_body_verbatim: z.string(), // owner's words, untouched
post_body_translations: z.record(z.string(), z.string()), // BCP-47 → translation
photo_image_uris: z.array(z.string()), // owner's photographs
photo_coat_profile: CoatProfile,
photo_species_estimate: SpeciesEstimate,
photo_forensic_markers: z.array(ForensicMarker),
description_forensic_markers: z.array(ForensicMarker), // markers the owner *typed*
last_seen_lat: z.number(),
last_seen_lng: z.number(),
last_seen_neighbourhood: z.string(),
last_seen_timestamp_iso: z.string(),
geofence_radius_miles: z.number().min(0.5).max(10),
microchip_present: z.boolean(),
microchip_registry_jurisdiction: z.string().nullable(),
temperament_tags: z.array(z.enum([
"friendly-with-strangers",
"nervous-let-them-come",
"do-not-chase",
"food-motivated",
"deaf-approach-from-front",
"blind-speak-first",
"reactive-to-other-dogs",
"reactive-to-other-cats",
"afraid-of-men",
"afraid-of-women",
"afraid-of-children",
"loves-children",
"responds-to-name",
"knows-recall",
"other",
])),
approach_instructions_from_owner: z.string().nullable(),
contact_preference: ContactPreference,
status: z.enum(["open", "reunited", "archived", "withdrawn"]),
created_at_iso: z.string(),
reunited_at_iso: z.string().nullable(),
flyer_ocr_provenance: z.object({ // null for owner-created posts
added_by_user_id: z.string(),
added_by_first_name: z.string(),
flyer_photo_uri: z.string(),
owner_verified: z.boolean(),
}).nullable(),
});
const MatchResult = z.object({
finder_sighting_id: z.string(),
candidates: z.array(z.object({
lost_post_id: z.string(),
confidence_band: z.enum([
"very-likely",
"possible-please-verify",
"weak",
"no-match",
]),
match_score_0_1: z.number().min(0).max(1),
distance_miles: z.number(),
matched_markers: z.array(z.object({
marker_type: z.string(),
body_region: z.string(),
finder_description: z.string(),
owner_description: z.string(),
marker_confidence: z.number().min(0).max(1),
})),
unmatched_markers_to_verify: z.array(z.string()), // markers on one side not the other
coat_compare_summary: z.string(), // one sentence
species_compare_summary: z.string(),
reasoning_one_paragraph: z.string(), // for the (i) "why this match" panel
})),
top_band: z.enum([
"very-likely",
"possible-please-verify",
"weak",
"no-match",
]),
no_match_recommended_action: z.enum([
"post-as-found",
"widen-radius",
"wait-and-re-check",
"contact-shelter-direct",
]).nullable(),
});
type PetSighting = z.infer;
type PetLostPost = z.infer;
type MatchResult = z.infer;
```
### Common failure modes (and how to avoid them)
- Agent silently downgrades `thinkingLevel` on the the match call call to save quota — pin `gemini-3.5-flash` with the matrix-specified `thinkingLevel` explicitly. Flash misses forensic markers and over-weights coat colour, so a brown mongrel matches every other brown mongrel in the radius.
- Match call returns a percentage like "87%" — never expose raw scores in the UI. Map to the closed-enum confidence band ("Very likely", "Possible — please verify", "Weak", "No strong match"). Percentages create false precision and make finders act on weak matches.
- Coat-colour dominates the match — Brixton on a wet afternoon has 80 black-and-white collies. Hard rule: at least one forensic marker must align before any candidate reaches "very-likely". Without a marker, the ceiling is "possible — please verify".
- Owner photograph from three years ago — the dog has aged. The age estimate compare must allow a one-band drift (puppy → young, adult → senior) without dropping the candidate.
- Indoor cat with a dramatic studio portrait vs. a finder photo under a sodium streetlight — colour temperature throws the colour compare. The capture lighting condition is captured precisely so the compare call can normalise.
- Microchip tag number read incorrectly from a blurry photo and used to "confirm" a match — never auto-confirm via OCR'd chip numbers. The chip number is a redirect to the registry, where the registry confirms the owner; the app does not store or assert.
- Welfare emergency surfaced after the contact CTA — the visible-injury, hypothermia, and heat-stress flags must surface BEFORE the contact CTA. A shivering dog at 2 °C needs the nearest vet before the finder spends ten minutes waiting for the owner to answer.
- Match call run against the full global corpus — must be geofenced first. The compare runs only over open posts inside the radius, and the radius is the finder's geofence (3 miles default), not the owner's posting radius.
- A second finder posts the same stray within twenty minutes — the app silently creates a duplicate. Merge sightings whose locations are within 200 m and whose forensic markers overlap, and surface both sightings to the owner as one thread.
- Owner's preferred language is Tagalog, finder writes the sighting note in English, fan-out delivers the post body without translating the finder's note — both sides must be translated for the receivers' chosen language and shown to each user in their preferred language.
- TTS reads a Tagalog post in an English voice — pin TTS voice to a native voice whose `languageCode` matches the post's `source_language`.
- Flyer-OCR auto-publishes a stranger's phone number — flyer entries are flagged "owner not yet verified" and a one-tap "I'm the owner — claim this post" path is offered. The contact CTA on an unverified flyer is *Message inside this app* only; the phone number from the flyer is shown but is never auto-dialled.
### Negative constraints (hard rules)
- Do NOT scrape any social network. No Facebook, Instagram, Nextdoor, Reddit, Mastodon, TikTok, or X. The corpus is only opt-in posts inside this app and flyer-OCR entries explicitly added by neighbours.
- Do NOT auto-publish a finder's photograph. The "post as found" action is explicit and the finder previews the fan-out radius before confirming.
- Do NOT auto-publish the owner's phone number. The contact action obeys the owner's `contact_preference`. If the owner chose *Relay number*, the actual number is never shown to the finder.
- Do NOT extrapolate the animal's identity beyond what the model can see. A match call returning "very-likely" requires at least one aligned forensic marker. Without one, the ceiling is "possible — please verify". The finder always has the final word.
- Do NOT geocode beyond the finder's neighbourhood when fanning a sighting out to receivers. Coordinates are coarsened to 500 m. A finder's precise location is never exposed.
- Do NOT widen the geofence without an explicit opt-in. A receiver subscribed to 1 mile does not get a post from 5 miles away unless they opted in to that radius.
- Do NOT translate the owner's verbatim post body — store it as written, translate alongside. The owner's words are sacred; the translation sits beside them. A receiver in another language sees both, with the verbatim original collapsed by default.
- Do NOT persist photographs after reunion. On reunion, the finder's photograph and the owner's post-attached photographs are removed from the active corpus within twenty-four hours. The owner may keep their own photographs in their account; the finder's photograph is deleted unless the finder chose to keep it.
- Do NOT show a welfare emergency only as a banner. Surface the nearest 24-hour vet and the owner's pet-temperament tag BEFORE the contact CTA when any welfare flag has severity ≥ medium.
- Do NOT use the user's photographs to train or fine-tune 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.
### 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: Parse finder photograph → `PetSighting` schema
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You are reading a photograph that a person just took of a stray
animal they have encountered. The photograph may be tilted, taken
in low light (street lamp, indoor fluorescent, dusk, near-dark),
slightly motion-blurred, or framed mid-action. The animal may be
wet, dirty, frightened, or partially obscured.
Your task is to extract structured information about the animal
that can be compared against open missing-pet posts in the same
neighbourhood. Output ONLY the PetSighting JSON matching the
provided schema.
Hard rules:
- Identify species correctly. Dogs, cats, rabbits, parrots,
tortoises, and "other" (livestock, raptors, reptiles). Do not
guess a species you cannot see; set to "other" with a free-text
note if uncertain.
- Estimate breed only when confident. Many strays are mixes; use
the primary slot for the most plausible parent breed and the
secondary slot for the next. For Aspin (Philippine native dog),
Sato (Caribbean), Indie (South Asian street dog), Baladi
(Egyptian street dog), Africanis (Sub-Saharan), Soi (Thai
street dog), Calle (Latin American street), name the regional
type explicitly — these are recognised landrace types and the
owner often described them that way too.
- Forensic markers are weighted more than coat colour in the
match call later. Enumerate every visible marker you can see —
missing tooth, scar, freckle, tail kink, ear notch,
heterochromia, limp, amputation, tattoo, collar text, visible
microchip tag. For each, give body region, a short verbatim
description, and a confidence.
- For each marker, populate description_verbatim_if_text_visible
only when text is actually readable on a tag — do not invent
text. If a chip number is partially readable, set what you can
read verbatim followed by "[illeg.]".
- Surface every welfare flag visible: visible injury, hypothermia
signs (shivering, hunched posture, cold paws if implied),
heat-stress signs (panting heavily, drooling, collapse),
dehydration signs, limping, bleeding, embedded collar, pregnant
or nursing appearance, elderly/frail appearance, aggressive or
fearful posture. Severity is your honest call from the photo.
- If you see no welfare concern, output ONE welfare_flag entry
with flag = "no-immediate-welfare-concern" and severity "low".
Do not leave the array empty.
- capture_lighting_condition matters for the later compare — be
honest about street-lamp sodium vs LED vs indoor fluorescent
vs dusk-dawn. If unsure, set to "unknown".
- If reading_confidence is below 0.7, populate
flagged_for_user_review with a one-sentence reason per field
the finder should verify.
- Do NOT invent a name, an owner, or a backstory. The finder will
add a free-text note separately.
- Do NOT guess the microchip number if you cannot read it.
No commentary. JSON only.
```
---
### Call: Parse owner photograph + description → `PetLostPost` schema
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You are reading the photographs and the typed description that a
pet's owner has just submitted to report their pet missing. The
photographs are usually well-lit (sometimes a daylight studio
portrait taken weeks or months before the loss). The description
is in the owner's preferred language and is verbatim — do not
clean it up, do not normalise it.
Your task is to populate the PetLostPost schema (everything except
identifier fields the server assigns).
Hard rules:
- photo_coat_profile, photo_species_estimate, photo_forensic_markers
come from the photographs the owner uploaded.
- description_forensic_markers come from the text the owner typed.
If the owner wrote "she has a small scar above her left eye",
that is a forensic marker in description_forensic_markers even
if it is not visible in the photographs. The compare call later
uses both arrays.
- post_body_verbatim is the owner's words exactly as typed. Do not
edit. Do not translate. Do not paraphrase.
- post_body_translations is a record keyed by BCP-47 language tag,
with one translation per active language in the geofence. At
post time, populate translations for the top three languages
spoken by opted-in receivers in the radius — the server lists
these languages alongside the call.
- source_language is BCP-47 ("tl-PH", "pa-IN", "am-ET", "yo-NG").
- temperament_tags come from the closed enum. Pick every tag that
applies based on the owner's words. Do NOT invent a temperament.
- approach_instructions_from_owner is the owner's specific guidance
on how to safely approach the animal — quote and lightly
paraphrase the owner's words; do not invent.
- pet_name_pronunciation_note is populated only if the owner gave
one (e.g., "Beh-nee" for "Beni") or if the name is a common
loan word whose pronunciation differs across the geofence's
languages.
- microchip_present is true only if the owner ticked the
microchip box on the form; do not infer from the photographs.
- Do NOT fabricate a forensic marker that the owner did not
describe and that you cannot see in the photographs.
Output ONLY the PetLostPost JSON. No commentary.
```
---
### Call: Compare finder sighting against open lost posts in radius → `MatchResult`
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none, long context)
```
You receive ONE PetSighting (the finder's photograph and parse) and
ALL open PetLostPost records within the finder's geofence radius
(default 3 miles; the server has already filtered). Your task is
to rank-order the lost posts by similarity and output a
MatchResult per the schema.
The compare is multimodal: the finder's photograph(s), the
finder's parsed coat + forensic markers, and the owner's
photographs + parsed markers + typed markers. The owner's
typed forensic markers are sometimes the only signal (markers
not visible in either photograph but described in text).
Hard rules:
- Forensic markers are weighted more than coat colour. A weighted
alignment of forensic markers anchors the match.
- A "very-likely" band requires at least ONE aligned forensic
marker (e.g., the finder sees a missing upper-left canine; the
owner described "missing upper canine on the left side"). Coat
+ species + size alone CANNOT reach "very-likely". Without a
forensic marker, the ceiling is "possible — please verify".
- A one-band drift in age estimate is allowed (puppy → young;
adult → senior). The owner photograph is often two or three
years old.
- Lighting differences must be normalised. A finder photo under
sodium street lamp will show a warmer/oranger coat than the
owner's daylight portrait. capture_lighting_condition on the
PetSighting tells you which way to discount.
- Coat-pattern (brindle, merle, tabby type) is a high-information
feature when present and aligned — weight it more than primary
colour.
- If the finder reads a collar tag or chip number that matches a
posted chip number, raise the confidence — but never auto-set
"very-likely" on chip number alone (OCR errors). The chip
confirmation must happen at the registry, not in this app.
- Welfare flags are NOT used to score the match. They are
surfaced separately to the finder UI. A welfare emergency
does not become a stronger match.
- Distance in miles is a soft factor only — proximity does not
imply identity. A post 0.2 miles away is not a stronger match
than a post 2.8 miles away if the markers don't align.
- Output candidates sorted by match_score_0_1 descending. Include
up to 5 candidates per response, even if all are weak.
- top_band reflects the best candidate's band, not an average.
- If top_band is "no-match", populate no_match_recommended_action
with the most useful next step. "post-as-found" is the default
unless the welfare flags suggest direct shelter contact is
faster.
- reasoning_one_paragraph for each candidate is what shows under
the per-match (i) icon. It must name the specific markers that
matched, the specific markers that did not, and the lighting
discount applied. The user reads this when deciding whether to
call.
- Do NOT invent markers that are not in either source.
- Do NOT auto-confirm a match. The finder, looking at the side-by-
side photographs the UI will render, is the final arbiter.
Output ONLY the MatchResult JSON. No commentary.
```
---
### Call: Parse flyer-OCR (lamppost flyer photographed by neighbour)
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You are reading a photograph of a paper flyer — taped to a
lamppost, a community noticeboard, a vet's window, a corner shop
counter — that announces a missing pet. Flyers are designed in
many styles: typed, handwritten, printed colour photo + caption,
hand-drawn with a marker. The flyer may be weathered, half-torn,
or partially obscured.
Your task is to extract the same fields as a PetLostPost — pet
name, species, coat profile, description, contact preference,
last-seen location — and mark the entry as "added by [finder
first name] — owner not yet verified".
Hard rules:
- post_body_verbatim is the flyer text exactly as printed, line
breaks preserved. Do NOT correct spelling. Do NOT translate.
- source_language is BCP-47 of the flyer text.
- contact_preference: parse the contact method from the flyer
(phone number → "call"; WhatsApp icon → "whatsapp"; email →
"in-app-message" placeholder until the owner claims; relay
number is never inferred — the owner must opt in).
- last_seen_location is parsed from the flyer's address, area
name, or "lost near X" line. If only a vague locality is
printed, set last_seen_lat/lng to the centre of that locality
and last_seen_neighbourhood to the name.
- microchip_present is true only if the flyer prints "microchipped"
or shows a chip number.
- temperament_tags only from explicit flyer text (e.g., "friendly
with strangers", "do not chase"); do NOT infer.
- flyer_ocr_provenance is populated with the finder's user id,
the finder's first name, the photographed flyer URI, and
owner_verified set to false.
- If the flyer is in a language you do not recognise, populate
source_language as "und" (undetermined) and leave
post_body_translations empty; surface to the receiver
unaltered.
- Do NOT invent a phone number or contact method if it is not
legible on the flyer. Mark the field null and flag for review.
Output ONLY the PetLostPost JSON, with flyer_ocr_provenance
populated. No commentary.
```
---
### Call: Nearest 24-hour emergency vet lookup for geofence
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: search grounding
```
You receive a latitude/longitude pair and a country/jurisdiction
hint. Your task is to surface the nearest 24-hour emergency
veterinary clinic to that location, with one citation URL per
result from grounded search.
Return up to 3 results, each with:
- vet_name
- street_address
- phone_number_for_emergency
- distance_miles (from the supplied coordinates)
- hours_today (verbatim, e.g., "24 hours" or "Open until 22:00")
- one citation URL from grounded search
Hard rules:
- Use `google_search` grounding. Trust the most recent published
listing; if the most recent listing is more than 12 months old,
flag it in a "last_verified" field with the date you found.
- Prefer official sources: national veterinary council registers,
national chains' "find a clinic" pages, the clinic's own
website. Avoid third-party aggregator pages.
- If no 24-hour clinic is within a reasonable radius (urban: 5
miles; rural: 30 miles), return the nearest clinic that is
currently open, with hours_today noted.
- For the Philippines, India, Nigeria, Brazil, Indonesia, and
other markets where 24-hour vet coverage is patchy, surface
the nearest animal welfare NGO that handles emergency intake
if no 24-hour clinic exists.
- Do NOT invent a phone number. If you cannot find one with a
citation, return null for phone_number_for_emergency.
Output the response as JSON in the text body (NOT via
`responseSchema` — `responseSchema` and `google_search` cannot be
combined in the same Gemini call today). Server-side: parse the
JSON, then read citation URLs from the response's
`groundingMetadata.groundingChunks[].web.uri` — do NOT ask the
model to include URLs in the JSON body; it will hallucinate them.
No commentary outside the JSON.
```
---
### Call: Generate "found this dog" printable flyer image
Model: `gemini-3.1-flash-image` · n/a · n/a
```
You generate a single printable flyer image — A4 portrait, 300 dpi
equivalent — for a finder who wants to put a "found this dog"
notice up at the local vet, shop, or lamppost.
Inputs:
- the finder's best photograph of the animal (as image input)
- the species and short coat description (text)
- the location of the find ("found near Atlantic Road, Brixton,
Saturday evening")
- the QR-code-rendered URL to the in-app post (passed as a small
image input that should be placed lower-right, ≥ 25 mm wide)
Style direction:
- warm, clear, scannable from 3 m
- one large photograph of the animal at the top half
- a single line headline: "Found — do you know this dog?"
- below: species + short description in 24 pt sans-serif
- below: location and date of find in 18 pt
- bottom strip: "Scan to message the finder" with the QR code
- do NOT include the finder's phone number or address in the
printed flyer
- do NOT include any logo or branding; the flyer is neighbour-
to-neighbour, not branded
- white background, generous margins (≥ 15 mm), high contrast
No commentary; one image output.
```
---
### Call: TTS narration of lost-pet post in owner's language
Model: `gemini-3.1-flash-tts-preview` · n/a · n/a
```
Voice: warm, clear, neighbourly. Pick the Gemini 2.5 Flash TTS
voice whose `languageCode` matches the post's `source_language`
exactly — pronunciation will follow that locale automatically.
Pet names are pronounced naturally for the source language; do
not anglicise.
Pre-process the post body before sending it to TTS:
- Read from `post_body_verbatim` (the owner's exact words).
- At each line break, insert a single ellipsis (`…`) so the TTS
model produces a natural pause. At paragraph breaks, insert a
blank line plus an em-dash (`—`). Gemini 2.5 TTS does not
support SSML `` — these textual cues are how you
signal pace.
- Skip the GPS pin and last_seen_timestamp_iso (they are visual
metadata). Speak the human last-seen line ("last seen near
Atlantic Road on Saturday evening") instead.
- Mid-call voice switching is not supported. If the post body
contains a phrase in a second language (a Tagalog post with an
English pet name, an Urdu post with an Arabic prayer
formula), keep the whole post in one voice; do not switch.
- Target rate: ~135 words per minute — neighbourly speaking pace,
not announcement pace.
Style direction: prepend ONE short directive sentence to the
text input, exactly like: "Read warmly and clearly, as one
neighbour reading another's lost-pet notice aloud in a
community group. …". There is no separate `style` API field on
Gemini 2.5 TTS; the directive sentence inside the input is how
style is conveyed.
Phoneme overrides are NOT exposed by Gemini 2.5 TTS — no SSML
`` tag. Pronunciation comes from the chosen voice's
native locale. For names known to be tricky in the chosen
locale, the owner can populate pet_name_pronunciation_note and
the server can substitute the spelled-out pronunciation into the
input text once, the first time the name appears.
```
## 5. Use cases & content to include
Build dedicated UI sections or flows for each of these — they tell you what content the app must support.
- **The dog walker in Brixton.** Late afternoon. A wet collie crosses Atlantic Road and trots up to a forty-something dog walker who has just dropped off his last client. No collar. He kneels, lets the dog smell his palm, lifts his phone. The shutter takes a five-frame burst under failing daylight. Fifteen seconds later: *Very likely match — call Maya now.* He taps. She picks up, four minutes away on Effra Road, in tears.
- **The teenager in Quezon City.** Brownout. A small brown aspin trots out from under a jeepney during a power cut and follows her down the sidewalk. She photographs the dog by the dim blue of a 7-Eleven sign. The app finds no strong match within 3 miles, then widens to 5 with her permission and surfaces a *possible match — please verify*: a poster from two days ago in Cubao, in Tagalog, with a description that mentions a small white sock on the front-left paw. The aspin has it.
- **The delivery rider in Lagos.** A Boerboel puppy with a fresh bite on its ear runs into the petrol-station forecourt where he's filling his Bajaj. He shoots the photograph one-handed, still holding the nozzle. The app surfaces no strong match in 3 miles but reads the welfare flag (recent injury, possibly hours old) and shows the nearest emergency vet first — Vom Animal Clinic, 0.8 miles — before offering *post as found*. The owner posts in Yoruba an hour later, and the rider's post-as-found notice in English is read aloud to her by the in-app TTS in Yoruba.
- **The teenager in Manila with a paper flyer.** A teenager sees a hand-printed Tagalog flyer taped to a sari-sari store wall on EDSA — "Nawawalang aso, Beni, brown na may puting paa". She photographs the flyer. The app extracts the description, the phone number, the date posted (yesterday), marks the entry "added by Tin — owner not yet verified", and an hour later a finder thirty minutes away whose dog matches the description messages in the app. Beni's owner claims the post the next morning.
- **The shelter receptionist.** A small dog is dropped off at a Hackney shelter on a Saturday afternoon. The receptionist opens the vet/shelter receiving panel, photographs the dog, and the app surfaces three open lost posts within 8 miles. The top match has a "possible — please verify" band; she calls the owner, who arrives two hours later with a thumb-drive of family photographs that match.
- **The senior owner offline.** A retired man's terrier vanishes from his garden. His daughter, two cities away, learns three days later and opens the app on his behalf. She photographs the only picture he has of the dog, an old camera-roll photo from his daughter's wedding in 2019. The app warns her the photograph is dated and asks her to describe distinguishing markers in his own words; she records his description as a voice note that becomes the typed body of the post. A finder in the next street up posts a sighting an hour after the post goes live.
- **The bilingual neighbour translator.** A Sri Lankan woman in Tooting posts a lost-cat notice in Sinhala. A Tamil-speaking neighbour, who would never have seen a Facebook post in Sinhala, gets the in-app notification with the post translated into Tamil. He looks up and recognises the cat that has been sleeping on his shed for two days. He calls within ten minutes.
- **The non-emergency match across radius.** An indoor cat slips out of a Houston townhouse and the owner posts at radius 1 mile. Twenty-four hours pass with no match. The app prompts the owner: *Widen to 3 miles? 1,840 neighbours have opted in at that radius.* The owner taps yes; a finder posts a sighting from 2.3 miles away within twelve minutes.
- **The reunited message.** A reunited owner taps "Beni is home". The finder, who has been waiting by his phone with his coat still on, receives one in-app message: "Maya wanted you to know: Beni is home, asleep on her foot. Thank you." Twenty-four hours later, the post and the finder's photograph are removed from the active corpus; only the reunion record stays in their accounts.
## 6. Page structure
Build the following screens / sections in this order. Adjust copy to fit the voice, but keep the structural intent.
1. **Welcome / sign-in.** A photographed-looking image of a stranger's hand offering a folded blanket to a small wet dog under a bus shelter in evening light. One paragraph: "Lost-Pet Net reunites found pets with their families — one photograph, one tap." Google sign-in and Apple sign-in. Below: "Try with the sample neighbourhood" → loads the demo geofence in section 8a.
2. **Mode picker — "What do you want to do?"** Two big buttons: 🐾 *I found a pet* · 📍 *My pet is missing*. A smaller link below: "I'm a vet, shelter, or block captain — receiving mode".
3. **Finder flow — capture.** Live viewfinder filling the screen; a single large shutter button takes a five-frame burst. Above the shutter, a small chip: "Brixton, SW9 · 3 mile radius". Below, a thin secondary action: "Upload a photo or video". After capture, a confirmation screen previewing the sharpest frame with the option to retake or to add a free-text note ("under the railway bridge on Atlantic Road, dry but shivering").
4. **Match results.** A vertically scrolling list. The top match is a card showing: the owner's photograph (left) and the finder's photograph (right) side-by-side, a single confidence pill ("Very likely match"), a one-line summary ("missing upper-left canine matches"), a (i) "why this match?" link, and one large primary CTA tuned to the owner's `contact_preference` ("Call Maya now" / "WhatsApp Maya" / "Open relay number" / "Message Maya in-app"). Below: weaker candidates as smaller cards. At the bottom of the list: "None of these? Post as found — your neighbours within 3 miles will see it."
5. **Welfare interstitial (conditional).** When a `welfare_flag` of severity ≥ medium is detected, this screen appears BEFORE the match results. It shows the nearest 24-hour vet for the geofence, the vet's phone number, and a clear "Call the vet now" CTA. Beneath: "When you've taken her somewhere safe, here are the possible matches." The finder can dismiss to proceed to matches but the vet info pins to the top of the matches screen.
6. **Owner flow — post composer.** A single tall form. *Pet name* · *Pet's species* · *Upload 1-5 photographs* (drag, tap, or take new ones) · *Last seen — pick on the map or enter address* · *Last seen — date + time* · *Tell us about your pet — anything that would help a neighbour recognise them* (large free-text field, with placeholder copy in the user's preferred language) · *Temperament tags* (chip multi-select) · *Microchipped?* · *Contact preference* (radio: call, WhatsApp, SMS, relay number, in-app message) · *Geofence radius* (default 3 mi; slider 0.5–10) · *Preview fan-out* (shows "247 neighbours in this radius will see your post"). Primary CTA: "Post — start the search".
7. **Owner — my posts dashboard.** A vertical list of the owner's open posts (usually one). For each: status pill, number of sightings received, number of receivers in the radius, and three actions ("Edit", "Widen radius", "Reunited").
8. **Sightings inbox.** When sightings come in on a lost post, they appear here as a vertical list of finder cards. Each card shows: finder's photograph (large), finder's neighbourhood + time, the match confidence band, a one-line summary of why this might be the match, and a "Reply to finder" CTA. The owner can mark a sighting as "Not my pet" to dismiss it from the list without notifying the finder.
9. **Receiver — feed.** A clean vertical list of recent lost-pet posts within the receiver's chosen radius and species preferences. Each card shows the owner's photograph (large), pet name, species + breed, last-seen neighbourhood + relative time, and a single "I think I've seen this pet" CTA that opens the finder flow with the post pre-selected.
10. **Receiving partner (vet / shelter).** A different home screen for verified partners. A search field across the lost-pet corpus in their geofence; a "log an arrival" button that captures an intake photograph and runs the same match call.
11. **Reunion screen.** Owner-side: "Tell us when Beni is home" → marks the post `reunited`, archives the photographs to the owner's account, removes from the corpus within 24 hours. Optional one-line thank-you note to the finder.
12. **Settings.** Notification radius per species, language preference for in-app translations, contact-preference defaults for future posts, privacy controls (delete account → 60-second purge), capabilities-info link, blocked users, vet/shelter partner verification status.
13. **Footer.** "Made for the moment between found and home." Privacy: "Photographs are removed within 24 hours of reunion." Capabilities `(i)` icon in header.
## 6b. First-visit onboarding
Show a **first-visit onboarding** the first time a visitor lands on the app (detect via `localStorage` flag; do not show on return visits). Three slides, dismissible at any time. Persistent re-entry: a `?` icon in the header reopens it.
**Slide 1 — What this is.**
- Headline: "Welcome to Lost-Pet Net."
- Subhead: "Reunite a found pet with their family — one photograph, one tap, only the neighbours who opted in."
- One paragraph (≤ 60 words) explaining who this is for and what makes it different from a generic lost-and-found feed: it never scrapes social networks, it geofences every post by default, and it removes photographs once the pet is reunited. The corpus is exactly the posts owners chose to put in this app.
- Visual: a small annotated illustration of a finder's phone showing a side-by-side photo compare with one matched forensic marker labelled — not a generic paw-print logo.
**Slide 2 — Try it now.**
- One short prompt: "Try with the sample neighbourhood".
- A live demo input pre-loaded with three open lost-pet posts from the seed content in section 8a, and a finder photograph already staged.
- 1-2 sentences pointing at *the specific page elements* where the Gemini magic happens (the matched canine-tooth marker, the lighting-discount note in the (i) reasoning panel, the cross-language match between a Tagalog post and an English finder note).
**Slide 3 — How to remix this.**
- Headline: "Make this yours."
- Three short bullets:
- "Swap the sample neighbourhood in `/data/seed-geofence/` for your own city's bounding box."
- "Adjust the prompts in `/server/prompts/` to fit the species your community actually loses — many neighbourhoods need to add tortoises, parrots, or rabbits to the species enum."
- "Wire up your Gemini API key, Firebase project, and (optionally) Twilio relay-number config via the env-var list in the capabilities panel."
- Primary CTA: "Use this template" → links to AI Studio Build remix entry point.
- Secondary: "Just exploring — close" (sets localStorage flag, never auto-shows again).
**Accessibility:** focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, focus restored to trigger on close. Respect `prefers-reduced-motion`.
**Don't:**
- Don't gate content behind the modal. The page beneath must be fully usable.
- Don't auto-reshow on return visits. Use `localStorage['onboarding-seen-v1']`.
- Don't include unrelated CTAs (newsletter signup, social follow). Keep it about the template only.
## 6c. Capabilities info button (persistent in header)
Add a persistent `(i)` icon in the top-right of the header (next to the primary nav). Click → opens a modal/panel titled **"What powers this app"**.
**Panel contents (in this order):**
**Gemini capabilities used (the hero list):**
- **Gemini 3.5 Flash (multimodal)** — reads a tilted phone-camera photograph of a moving animal under any light. Extracts coat profile, species, breed estimate, body condition, and a list of forensic markers (missing tooth, ear notch, freckle, tail kink). One call per photograph.
- **Gemini 3.5 Flash (multilingual)** — compares an owner's Tagalog post against a finder's English description; matches a Punjabi flyer to a Tamil sighting note. Reads both, weighs both, never centres English.
- **Gemini 3.5 Flash (long context)** — when 200+ posts are open in a dense radius, the compare call sees every open post at once for a single finder photograph.
- **Gemini 3.5 Flash + grounded search** — finds the nearest 24-hour emergency vet for a finder whose welfare flag is severity ≥ medium, with a citation per result.
- **Nano Banana 2** — generates a printable "found this dog" flyer with the finder's photograph and a scannable QR code to the in-app post.
- **Gemini TTS** — reads a lost-pet post aloud in the owner's language so a receiver at the wheel of a car, with low vision, or whose first language is not the post's written language can still take it in.
- **Firebase Auth** — Google, Apple, and phone-number sign-in. Phone-number sign-in verifies the owner's phone before the *Call directly* CTA goes live.
- **Firestore** — stores lost posts, sightings, reunions, geofence subscriptions; syncs across devices in real time.
- **Firebase Storage** — keeps the finder's photograph and the owner's photographs at upload resolution until reunion. Photographs are removed from the active corpus within 24 hours of reunion.
- **Firebase Cloud Messaging** — geofenced push notifications. Receivers opt in per species and per radius.
- **Twilio (optional)** — if the *Relay number* contact option is enabled, Twilio proxies the call so the owner's real phone number is never shown to the finder. If Twilio is not configured, the *Relay number* option is hidden.
- **Cost note** — see the detailed breakdown in 6d. A typical finder match across a 3-mile geofence with 50 open posts costs about $0.05 of Gemini API spend per match. A typical owner post (parse + flyer image) costs about $0.06. A geofence of 5,000 receivers and ~30 active posts/week costs ~$10/week of Gemini spend, total.
- **Privacy note** — your photographs and your neighbourhood data are private to you, the people you contact directly, and the opted-in receivers in your radius. 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. The app never scrapes Facebook, Instagram, Nextdoor, Reddit, or any social network. Photographs are removed from the active corpus within 24 hours of reunion.
**Backend services this app depends on:**
- Auth: see section 4b
- Database: see section 4b
- Storage: see section 4b (NOT auto-provisioned — user configures in Firebase console before first upload)
- Email: see section 4b (magic links require sender-domain authorisation in Firebase Auth)
- Push: see section 4b (FCM auto-provisioned)
- SMS: see section 4b (Twilio is user-configured; *Relay number* hidden if not configured)
- Payments: see section 4b (not used in v1)
- External APIs: see section 4b
**Environment variables you'll need to configure:**
- `GEMINI_API_KEY` — your Google AI Studio API key
- `FIREBASE_PROJECT_ID` — your Firebase project id
- `FIREBASE_SERVICE_ACCOUNT` — service-account JSON (server-side only)
- `MAPBOX_TOKEN` — required for the geofence map
- `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` — optional, only if you want the *Relay number* contact option
- `FCM_SERVER_KEY` — Firebase Cloud Messaging server key for push notifications
**Cost + privacy notes:**
- One short paragraph per cost-sensitive capability: the per-finder match call is the load-bearing cost; a typical geofence of 50 open posts costs about $0.05 per match. Owner-side post creation costs about $0.06 (parse + flyer image). At 30 active posts/week with 5,000 receivers, monthly Gemini spend stays well under $50.
- One short paragraph on privacy: where the data lives (your Firebase project), how to delete it (Settings → "Delete this archive forever" — purged in 60 seconds), what is never sent for training, what is never scraped (no social-network scraping; ever).
**Documentation links:**
- AI Studio Build docs
- Gemini API multimodal, multilingual, long-context, TTS, Nano Banana 2 docs
- Firebase Auth, Firestore, Firebase Storage, FCM docs
- Twilio Programmable Voice docs (only if relay-number option is enabled)
- A short note on national microchip registries (Petlog UK, AAHA US, PhilGuard PH, Indian Veterinary Council, others)
**Accessibility:** same standards as the onboarding modal — focus trap, `Esc`, ARIA, restored focus.
**Behaviour:**
- Always available — single click from anywhere in the app.
- Tooltip on the `(i)` icon: "How this app is built".
- Mobile: opens as a full-screen sheet that slides up.
- Should be the most honest part of the app — never hand-wave service requirements; never say "AI" without naming the specific Gemini model and capability.
## 6d. Detailed cost breakdown (deployer reads this BEFORE shipping)
- **Parse finder photograph (Gemini 3.5 Flash, low thinking)** — typical 5-frame burst, the sharpest frame submitted; ~600 output tokens. ~$0.008/sighting.
- **Parse owner photograph + description (Gemini 3.5 Flash, low thinking)** — 1-5 photographs + description; ~900 output tokens. ~$0.012/post.
- **Compare sighting against open lost posts in radius (Gemini 3.5 Flash, medium thinking, long-context)** — input scales with the number of open posts in the radius; a typical 50-post radius ≈ 75k input tokens, ~1,500 output tokens. ~$0.04 per match attempt.
- **Parse flyer-OCR (Gemini 3.5 Flash, low thinking)** — one photograph; ~700 output tokens. ~$0.010/flyer.
- **Nearest 24-hour vet lookup (Gemini 3.5 Flash + grounded search)** — ~$0.001/lookup. Triggered only on a severity ≥ medium welfare flag.
- **Generate printable "found this dog" flyer image (Nano Banana 2)** — ~$0.03/image. Triggered only when the finder taps "make me a flyer".
- **TTS narration (Gemini 2.5 Flash TTS)** — billed per output token (~$10/M output tokens), effectively ~$0.000003/character. A 250-word post ≈ $0.004 per narration. Cached per post.
- **Expected cost per finder match attempt:** ~$0.05 (parse + compare). **Per owner post creation:** ~$0.012 parse + ~$0.03 flyer if generated. **Per geofence (5,000 receivers, ~30 active posts/week, ~10 finder matches/day):** ~$10/week of Gemini spend, total.
- **Image storage:** Firebase Storage standard tier, ~$0.026/GB/month. A high-res finder photograph is ~3 MB; an owner's typical 3-photo post is ~9 MB. A geofence with 50 open posts at any moment uses ~600 MB ≈ ~$0.02/month — negligible because photographs are removed within 24 hours of reunion.
- **SMS (Twilio relay-number, optional)** — ~$0.0085/min in the US, varies per country. Triggered only when the owner picks *Relay number* and a finder taps Call.
## 7. Design language
- **Mood:** A neighbourly bulletin board that happens to live on a phone. Not a feed. Not a marketplace. Not a tech product. The corner shop window where someone has taped a colour photo of a missing cat, with a hand-written note that says "she's deaf — speak first". Quiet, urgent, kind, local.
- **Typography:** Humanist sans-serif for the primary UI (Inter or Söhne), with a small slab-serif accent (PT Serif Caption) for the post titles only — to set them apart from app chrome and signal "this is a person's voice, not a system message". Pet names always set in the slab.
- **Palette:** Warm-white background `#FAF7F2` reminiscent of cork-board paper; deep ink `#1A1612` for body text; a single saturated coral `#E2553E` reserved for the primary contact CTA (and nothing else, so the eye lands on it without effort). A muted earthen green `#5D7B5A` for "match found" pills; a calm amber `#C28A2E` for "please verify" pills; a dimmed slate `#4A5560` for "no strong match"; a strong red `#B53A2C` reserved for welfare emergencies only. Borrowed from a community noticeboard, not from a delivery-app brand.
- **Imagery:** The photographs of the animals are the hero. Always large, always uncropped from what the finder or owner captured. Never replaced with breed-standard stock photos. Side-by-side finder/owner compares are framed with a thin warm-grey 1 px border on a paper-white card — like two Polaroids set down next to each other.
- **Hand-feel touches:** A barely-visible paper grain on the post-card background. The "show why this match" panel slides up from the bottom with a single short fade — like a sticky-note being lifted. The flyer-OCR mode has a subtle parallax that mimics looking at a wall flyer at a slight angle, but only on devices that pass `prefers-reduced-motion: no-preference`.
- **Spacing:** consistent 4-px base. Generous whitespace — every card breathes.
- **Radius:** consistent token set (e.g. 8 / 14 / 20 px). Post cards use 14; the contact CTA uses 20; the inline confidence pill uses 8 with extra horizontal padding.
- **Shadows:** subtle, single-layer, slightly warm. Avoid heavy drop-shadows. The welfare interstitial is the one place where a stronger shadow is allowed, to denote urgency.
- **Motion:** purposeful — entrance fades, hover lifts, page transitions. Respect `prefers-reduced-motion`. No bouncing splash animations. No theatrical hero animations. The match-result reveal (top card sliding in 250 ms after the compare call returns) is the one place where motion carries meaning; respect reduced-motion by appearing instantly.
- **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons not spinners where possible. Empty states have helpful next-action guidance ("Take a photograph of the pet to start a search").
## 8. Content generation rules
- Write **realistic, specific copy**. NO Lorem Ipsum. NO generic placeholders like 'Your tagline here'.
- Invent plausible names, dates, locations, breeds, post bodies that fit the domain (use the seed content in section 8a as a starting point). When inventing, lean on the actual textures of neighbourhood lost-and-found: people post in their own languages, write the way they speak, and care about the small markers the family will recognise (the chipped tooth, the white sock, the kink in the tail).
- Tone: warm, direct, free of corporate language. This template is for a neighbour, not a brand. The owner posting at midnight is not the target of conversion copy.
- Headlines: punchy and concrete. No 'Empower your X' filler. No 'Revolutionize'. No 'Seamless'.
- Body copy: short paragraphs (2-4 sentences). Use lists where appropriate.
- Plain language. Avoid jargon — except where the user already speaks the jargon (the vet receptionist wants to see "microchip number" in the receiving panel; the journalist user wants to see "geofenced fan-out" in the capabilities panel).
- Where the app outputs AI-generated content, never label it as "AI says" — let it speak naturally. Match confidence is a closed band ("Very likely", "Possible — please verify", "Weak", "No strong match"), never a percentage. The (i) "why this match?" reasoning is written in plain language: which markers aligned, which did not, what the lighting discount was.
## 8a. Seed content (use these specific examples)
Anchor every generated copy + sample data point in the concrete content below. Use these names, numbers, dates, and snippets verbatim where helpful, or generate close variants that sit in the same world.
**Sample geofences (sidebar):**
- "Brixton / Stockwell / Herne Hill" (London SW9 / SE24) — 3-mile radius from Atlantic Road; 1,847 opted-in receivers; 12 open lost posts this week
- "Quezon City — Cubao / Kamuning / New Manila" — 5-mile radius from EDSA/Aurora; 2,310 receivers; 18 open posts; primary languages Tagalog + English
- "Lagos — Ikoyi / Lekki Phase 1 / Victoria Island" — 4-mile radius; 1,402 receivers; 7 open posts; primary languages English + Yoruba
- "Houston — The Heights" — 2-mile default radius; 3,109 receivers; 9 open posts; primary languages English + Spanish
**Sample owner lost-pet post (this is what the owner side of the demo shows):**
- **Pet name:** "Beni"
- **Pet name pronunciation note:** "Beh-nee"
- **Source language:** Tagalog (tl-PH)
- **Post body verbatim:** "Nawawala si Beni mula kahapon ng hapon, malapit sa palengke sa Kamuning. Maliit na aso, kulay brown na may puting paa sa harapan-kaliwa, mga 5 kg lang. Mabait sa mga tao pero takot sa malalakas na ingay. Mawala ang isang ngipin sa itaas-kaliwa, kaya kapag ngumiti, makikita mo. Tumutugon sa pangalan niyang Beni. Pakipakawalan po ang impormasyon kung may makakita."
- **Post body translation to English:** "Beni has been missing since yesterday afternoon, near the market in Kamuning. Small dog, brown with a white front-left paw, around 5 kg. Friendly with people but afraid of loud noises. Missing a tooth on the upper-left, so when she smiles you can see it. Responds to her name, Beni. Please share information if anyone has seen her."
- **Photo coat profile:** primary "brown", secondary ["white"], pattern "particoloured", coat_length "short"
- **Photo species estimate:** species "dog", primary "Aspin (Philippine native dog) mix", confidence 0.72, age "young", size "small", body_condition "ideal"
- **Photo forensic markers (2):** "white sock on front-left paw" (front-left-leg, 0.95); "small light freckle on right side of muzzle" (head-face, 0.78)
- **Description forensic markers (2):** "missing canine on upper-left jaw" (mouth-teeth, owner-described, 0.95); "responds to recall" (whole-body, owner-described, 0.85)
- **Last seen:** Kamuning Market, Quezon City; 2026-05-26 16:30
- **Geofence radius:** 5 miles
- **Microchip present:** false
- **Temperament tags:** ["friendly-with-strangers", "afraid-of-loud-noises", "responds-to-name", "knows-recall"]
- **Approach instructions:** "Kneel and offer the back of your hand. She will come if she trusts you. Do not chase if she runs."
- **Contact preference:** method "whatsapp", display_label "WhatsApp Maya now", preferred_hours "any time", language_preferred "tl-PH"
- **Status:** "open"
**Sample finder sighting (this is what the finder side of the demo shows):**
- **Finder photograph context:** Cubao, Quezon City, near a 7-Eleven, evening, brownout — flickering blue light from the store sign
- **Capture lighting:** "near-dark"
- **Species estimate:** species "dog", primary "Aspin mix", confidence 0.81, age "young", size "small", body_condition "ideal"
- **Coat profile:** primary "brown", secondary ["white"], pattern "particoloured", coat_length "short"
- **Forensic markers (2):** "small white patch on front-left paw" (front-left-leg, 0.88); "missing tooth visible upper-left when mouth slightly open" (mouth-teeth, 0.71)
- **Welfare flags (1):** "no-immediate-welfare-concern" severity "low"
- **Collar or tag:** null
- **Approachability note:** "approached me willingly, sniffed my hand"
- **Free text note from finder:** "Followed me out from under a jeepney during the brownout. Small, brown, calm."
**Sample match result (this is what the match call returns for the demo):**
- **Top candidate post id:** Beni's post (above)
- **Confidence band:** "very-likely"
- **Match score:** 0.84
- **Distance:** 1.6 miles
- **Matched markers (2):** "white sock front-left paw" (owner 0.95, finder 0.88, marker confidence 0.92); "missing upper-left canine" (owner-described 0.95, finder visible 0.71, marker confidence 0.81)
- **Unmatched markers to verify:** ["small light freckle on right side of muzzle (owner-described, not visible in finder photo — possibly washed out by 7-Eleven light)"]
- **Coat compare summary:** "Brown-and-white particoloured short coat aligns; sodium-equivalent street light in the finder photo discounts the apparent warmth shift."
- **Species compare summary:** "Aspin mix on both sides; age and size align in the young/small bands."
- **Reasoning one paragraph:** "Two forensic markers align: the white sock on the front-left paw is visible in both photographs, and the missing canine on the upper-left jaw is visible in the finder's photograph and was described in the owner's post. A third marker — the small freckle on the right side of the muzzle — is not visible in the finder's photograph but may have been washed out by the 7-Eleven sign light; this is the one marker the finder should look for in person before calling. Coat, breed, age, and size all align. Distance 1.6 miles is consistent with an animal that has been missing about 18 hours from Kamuning."
**Sample seed flyer (for the flyer-OCR demo):**
- A handwritten Tagalog flyer on a sari-sari store wall on EDSA, photographed by a passing teenager. Text: "NAWAWALA — BENI — Maliit na aso, brown na may puting paa sa unahang kaliwa, may nawawalang ngipin. Mabait sa tao pero takot sa ingay. Kung makakita kayo, tumawag o mag-text: 0917-XXX-XXXX. Mama Maya, Kamuning Market."
- Parsed into a PetLostPost with `flyer_ocr_provenance.added_by_first_name = "Tin"` and `owner_verified: false`. The next morning, the actual owner (Maya) opens the app, sees "your flyer is in the app — claim it?", and claims the post, which sets `owner_verified: true`.
**Sample voice copy:**
- Onboarding: "Find a stray, photograph her, see if anyone within three miles is looking for her."
- Capture: "Hold the phone steady — we'll take a burst and pick the sharpest frame."
- Match result (very likely): "Maya in Kamuning posted Beni 18 hours ago. Two markers match — the white front-left sock and the missing upper-left canine."
- Match result (possible): "One owner in the radius is looking for a dog that could match. The coat and size align; we couldn't confirm a marker. Please take a closer look before you call."
- Match result (no match): "No strong match in three miles. Want to post Beni as found? Your neighbours within three miles will see it."
- Welfare flag (medium): "She's shivering. Maybe wrap her up first — the nearest 24-hour vet is on the next slide."
- Owner post composer placeholder (Tagalog): "Ilarawan si [pet's name]. Mga marka, ugali, kung paano lapitan — anumang makakatulong sa kapitbahay na makilala siya."
- Reunited message to finder: "Maya wanted you to know: Beni is home, asleep on her foot. Thank you."
**Sample owner-side error copy:**
- Couldn't read the photograph: "The photograph is a bit too dark for us to read details. Could you upload a clearer one too? We'll keep this one for the post."
- Microchip number not readable: "We couldn't read the chip number from that tag photo. You can type it in below — or skip; many of our reunions happen without it."
- No receivers in radius: "There are no opted-in receivers within 0.5 miles. Widen to 2 miles? 318 neighbours have opted in there."
**Sample shelter-receptionist UI copy:**
- Header: "Hackney Animal Shelter — receiving mode"
- Search field placeholder: "Describe what walked in — small black-and-white terrier mix, female, ~6 kg"
- Match result for a 'possible' candidate: "Owner Owen in Stoke Newington posted Pip 30 hours ago. Markers don't fully align — chipped tooth on the right is described but we can't see it from your photo. Want to call to confirm?"
## 9. Media & assets
- **Hero image (landing screen):** A photographed-looking shot of a stranger's hand offering a folded blanket to a small wet dog under a bus shelter in evening light. Generate via Nano Banana 2 with a prompt emphasising "shallow depth of field, warm streetlight, the dog's eyes catching the light, a hand visible at the edge of frame, no face, real worn pavement, slight rain on the shelter glass".
- **App icon / wordmark:** Set in humanist sans-serif. A small paper-grain texture behind it. No paw icon — just type and texture.
- **Empty-state illustration:** A simple line drawing of a corkboard with two paper flyers pinned to it, one corner curled. Hand-drawn aesthetic, not a flat icon.
- **Demo pet photographs:** Generated per the seed content in section 8a — Nano Banana 2 prompts that specifically request "phone-camera realism, slight motion blur, mixed light, the dog looking up at the photographer, no human face in frame". Each demo photograph should look phone-shot, not rendered.
- **Geofence map:** Mapbox "outdoors" or "streets-v12" style with a custom muted palette — the geofence radius is a soft warm-coral ring on a paper-white base. Receiver dots are not shown (privacy); only the radius and the count.
- **Stock fallbacks:** If image generation fails, fall back to the photographed sample stray from `/public/samples/sample-stray.jpg`. Never to a "🐶" emoji.
- **Generated imagery:** prefer Nano Banana 2 over stock photography. Prompt for warmth, asymmetry, and slight imperfection — avoid the glossy 'AI render' look.
- **Optimisation:** WebP/AVIF, `loading="lazy"`, explicit `width`/`height` to prevent layout shift.
- **Icons:** `lucide-react` for UI. Use sparingly — never decorative-only.
### Build-time asset manifest (explicit specs)
Every image, illustration, and visual reference mentioned above must resolve to ONE of the three buckets below — runtime-generated, seed-shipped, or user-supplied. Do NOT ship `` tags whose `src` is not listed here. Do NOT depend on bare "section 8a prompts" without binding them to explicit paths and model IDs.
**Bucket 1 — Runtime-generated (Nano Banana Pro `gemini-3-pro-image` for hero/demo photographs; Nano Banana 2 `gemini-3.1-flash-image` for in-app illustrations and reference-conditioned variants).** Cached to Firebase Storage; served via signed URL. Every reference above to "Nano Banana 2" or "Nano Banana Pro" MUST be wired to one of these specific calls with an explicit model id:
- `/public/generated/hero.webp` (2400×1500, WebP) — model `gemini-3-pro-image` — uses the literal prompt described as "Hero image (landing screen)" above. Run once at build; commit a `/public/samples/hero-fallback.webp` (1600×1000) generated from the same prompt with `gemini-3.1-flash-image` so the page renders if quota is exhausted.
- `/public/generated/demo/{demo-slug}-{NN}.webp` (1600×1200, WebP) — model `gemini-3.1-flash-image` (reference-conditioned where the prior frame is passed as input) — one path per "Demo X" image referenced above. The slug derives from the seed example in section 8a; the NN index covers each frame in the demo sequence.
- `/public/generated/illustrations/{name}.webp` (1024×1024, WebP) — model `gemini-3.1-flash-image` — one path per named illustration above ("Empty-state illustration", "Recipe-card hero illustrations", "Curriculum picker imagery", "Period-style frames", etc.). Each illustration's prompt is the literal description above; ship a deterministic seed in the request so re-runs are reproducible.
**Bucket 2 — Seed assets shipped with the deliverable.** Every "Stock fallback" path referenced above (e.g. `/public/samples/sample-X.jpg`) is generated once via Nano Banana 2 (`gemini-3.1-flash-image`) at 1024×1024 WebP using the same prompt as its Bucket-1 counterpart, then committed to the repo so the page renders identically if Gemini quota is exhausted or the user is offline. Replace any `.jpg` extension above with `.webp` to match the optimisation rule. Also commit these empty-state seeds (1024×1024 WebP, single-stroke hand-drawn line, no colour fill):
- `/public/samples/empty-state-primary.webp` — line drawing of the app's primary empty surface (the named "Empty-state illustration" above), generated from that exact prompt.
- `/public/samples/empty-state-archive.webp` — line drawing of an empty saved/archive view, single-stroke outline.
- `/public/samples/empty-state-error.webp` — line drawing of a hand placing a single object aside with care, used when an AI call fails.
**Bucket 3 — User-supplied.** Uploads from the user's camera / file picker land at the Firebase Storage path conventional for this template (named in section 4b). The build ships with Bucket-1 + Bucket-2 only; no user-supplied images at first paint.
**Hard rules**
- Every `` tag MUST have a `src` that resolves to a path listed in Bucket 1, Bucket 2, or a Bucket 3 upload path. Anything else is a build error.
- No bare `image.jpg` / `hero.jpg` / `placeholder.png` references anywhere in the code.
- Model IDs: `gemini-3-pro-image` for hero-quality photographic generation; `gemini-3.1-flash-image` for in-app illustrations, reference-conditioned variants, empty-state seeds, and stock fallbacks. Never use a legacy model id (no `imagen-*`, no `gemini-1.5-*-image`).
- File format: WebP everywhere (AVIF acceptable where the target browsers support it). No `.jpg` / `.jpeg` / `.png` in `/public/samples/`.
## 10. Interactivity & states
- Every interactive element has hover, focus, active, and disabled states.
- Forms validate inline and show specific error messages (not "Invalid input").
- Loading states use skeletons that match the eventual layout, not spinners.
- Empty states explain the next action with a button whose label fits THIS app's domain: "Take a photograph of the pet", "Tell us about your pet", "Open the receiver feed" — never a generic "Add your first item".
- Smooth scroll for in-page anchors.
- All AI-generated content streams in token-by-token where supported, with a clear "thinking…" indicator before content starts arriving. The match call shows a step progress (Reading the photograph… → Searching 3 miles… → Comparing 47 posts…) — each step takes 2-6 seconds.
- If an AI call fails, show a calm, specific error ("We couldn't read this photograph — try a clearer one, or describe the pet in a few words in the side panel?") and offer retry.
- The capture screen shows a single one-tap burst shutter; the user sees the sharpest frame within 800 ms and can retake.
- The match result reveal takes 250 ms with `prefers-reduced-motion` falling back to instant.
- The welfare interstitial does NOT auto-dismiss. The finder must tap "Continue to matches" or "Call the vet now".
## 11. Tech & responsive requirements
- **TTS markdown-stripping preprocessor:** before sending any user-authored markdown to `gemini-3.1-flash-tts-preview`, strip non-spoken markdown: `#`/`##`/`###` headings (keep the title text), `**bold**` (keep the inner text), `[label](url)` (keep `label`, drop URL), `` ``` `` fenced code blocks (skip entirely), `>` block-quote markers (keep the text), and `|` table pipes (read row-by-row as sentences). Insert `…` between sentences for a short pause and a blank line plus `—` between paragraphs for a long pause. The model does not understand markdown; raw markdown will be read aloud as literal characters ("asterisk asterisk").
- **File downloads on Safari / Firefox:** when offering local-disk save of any export (PDF, CSV, MP3, ZIP, JSON, image), fall back to `` with a blob URL — the File System Access API (`showSaveFilePicker()`) is Chromium-only. Detect with `'showSaveFilePicker' in window`; otherwise use the anchor-download path.
- **Stack:** React + TypeScript + Tailwind CSS. Functional components + hooks. Use Shadcn UI primitives where appropriate.
- **Build runtime:** AI Studio Build — full-stack with Cloud Run server-side functions. All Gemini API calls happen server-side; API key lives in Secrets Manager, never in client bundle.
- **Model selection:** explicitly pin `gemini-3.5-flash` for parse-sighting / parse-post / compare / flyer-OCR, `gemini-3.5-flash` for emergency-vet lookup, `gemini-3.1-flash-image` for printable flyer generation, `gemini-3.1-flash-tts-preview` for TTS. Set `thinkingLevel` explicitly per call.
- **Database:** Firestore (auto-provisioned by AI Studio Build). Show the sample geofence on first launch.
- **Auth:** Firebase Auth — Google sign-in by default; Apple sign-in next to it; phone-number sign-in for owners who want the *Call directly* CTA; magic-link email as fallback.
- **Storage:** Firebase Storage for photographs. Pre-signed URLs only. Photographs removed from active corpus within 24 hours of reunion.
- **Push:** Firebase Cloud Messaging for geofenced notifications. Receivers opt in per species + per radius.
- **Mobile-first.** Verify layouts at 375 px (iPhone SE), 768 px (iPad), 1024 px, 1440 px+. The capture viewfinder fills the screen on mobile.
- Use `clamp()` for fluid typography. Prefer container queries over media queries for component-level responsiveness.
- Use `dvh` / `svh` instead of `vh`. Respect safe-area insets on iOS.
- Zero horizontal overflow at any width. Zero layout shift on load.
- Persist user data in Firestore. Use real-time listeners on the sightings inbox and the receiver feed.
- Optimistic UI on writes; reconcile on response.
- Capture flow uses the Web Camera API with fixed focus/exposure where supported; falls back to native camera otherwise.
- **iOS Safari gotchas (graceful degradation):** camera and geolocation permissions do NOT persist across page reloads on iOS — re-request on every sighting upload; backgrounded tabs pause `getUserMedia` — re-acquire the stream on `visibilitychange`; on Low Power Mode iOS may downgrade GPS accuracy — surface a "rough location — drag the pin to refine" affordance instead of a hard fail; always offer `` as a fallback so a sighting photo still saves when WebRTC is denied.
- Geolocation prompt explains why ("to search within 3 miles of where you found the pet"); falls back to manual location entry if denied.
## 12. Accessibility (WCAG 2.2 AA)
- Semantic HTML — `header`, `nav`, `main`, `section`, `article`, `footer`.
- All interactive controls reachable by keyboard with a visible focus ring.
- Color contrast ≥ 4.5:1 for body, 3:1 for large text and UI components. The coral primary CTA is tested against the warm-white background at 4.7:1.
- All images have meaningful `alt` text. Finder and owner photographs have `alt` describing the artefact ("photograph of a small brown dog with a white front-left paw, taken outdoors at dusk").
- Form fields have associated `