================ 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.
---
# Public-Records Reader
## 1. Project
**Public-Records Reader** is a long-document comparison tool for the
people who read government paperwork for a living — local journalists,
neighbourhood-council watchdogs, freelance reporters, and citizen-budget
volunteers. The user uploads this year's city budget and last year's
city budget — two PDFs, four hundred pages each, hostile typesetting,
inconsistent column widths, scanned signature pages in the middle —
and the app produces a structured, numbered diff: every line item that
moved, by how much, who introduced it, where the appropriation sits in
the document, and the exact page numbers needed to cite the claim. The
original PDFs stay in the foreground the whole time; every assertion in
the diff links back to a highlighted region of a page.
This is the kind of app a community-paper reporter in Cleveland builds
on a Sunday night when the city posts next year's preliminary budget at
4pm Friday and the editor wants 1,200 words by Wednesday — three days
of skim-reading reduced to ninety seconds of generated diff plus an
afternoon of verification. It is also the kind of app a citizen-reporter
in Karachi uses to compare the union-council budget against the previous
one — same shape of moment, different language, different fiscal year,
different layer of government. Same problem: a four-hundred-page PDF
nobody has time to read line-by-line, and a public-interest question
that requires reading every line.
The single demo that proves the magic: drop `budget-fy2025.pdf` and
`budget-fy2026.pdf` into the upload zone → in about ninety seconds the
user sees a numbered diff list. Item 1: "Police overtime appropriation
rose from $14,200,000 to $18,640,000 (+31.3%), introduced as Amendment
3-B by Council Member Reyes; see FY2026 p. 217 and FY2025 p. 198."
Item 2: "Library acquisitions cut from $1,860,000 to $1,205,000
(-35.2%); see FY2026 p. 312 and FY2025 p. 291." Each item shows the
original-source-of-truth thumbnail of the relevant page, with the
specific line highlighted, and a "see in context" button that opens
the full page side-by-side. The dollar figures are never paraphrased;
they appear in the diff exactly as they appear on the page, to the
last digit, with the currency symbol preserved.
And in the harder cases — a metro health-authority budget where the
line items were restructured (last year's "Behavioral Health Programs"
became this year's "Mental Health Services" plus "Substance Use
Services"), a school-district budget where the fund accounting changed
mid-year, a state cultural-affairs budget where the page numbers are
in Roman numerals for the first 40 pages and Arabic after that, a
provincial budget in Spanish where the appropriations are denominated
in both nuevos soles and dollars — the app preserves the verbatim
language of the line item, marks the restructure as a "category
change" rather than a "cut" or "raise", and lets the journalist make
the editorial call.
**Tagline:** _Two big PDFs in, a cited line-by-line diff out — in any budget, any council, any language, every claim tied to a page number._
## 2. Target audience
- Local journalists at community papers and digital newsrooms who cover city / county / school-district budgets on tight deadlines (Cleveland, Manchester, Lyon, Nairobi, Lagos, Manila, Cebu, Lima, Curitiba)
- Citizen-reporter newsletters and substacks covering one jurisdiction in depth ("the [Karachi / Quezon City / Cape Town / Detroit] beat")
- Freelance investigative reporters chasing a tip across multiple budget years and multiple agencies
- Neighbourhood-council and parent-association watchdogs reading school-board, transit-authority, and water-district budgets
- Civic-tech volunteers and budget-school instructors who teach residents how to read appropriation documents
- Budget analysts at nonprofits and policy shops who track an agency line-by-line across cycles (housing, public safety, education, environment)
- Public-records librarians and FOIA-response professionals preparing comparative documents for requesters
- Election-time campaign researchers comparing an incumbent's record across multiple budget cycles
- Comparative-government researchers and graduate students working across multiple jurisdictions in multiple languages
## 3. Core value propositions
Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons users pick this app.
- **Reads the whole document, not a sample** — Gemini 3.5 Flash's long-context window holds two 400-page PDFs at once and compares them line-by-line. No "summarising the first 50 pages and hoping the rest follows the same pattern". The app guards against >1M-token inputs with a transparent chunking strategy, but on a typical municipal budget the long-context call is one round-trip.
- **Every claim cites a page number** — the diff list is unusable in a newsroom without sources. Every line item links back to a specific page number in a specific PDF, with the bounding box of the highlighted line preserved. Hover any number in the diff → see the page thumbnail. Click → land on that page in the source PDF, with the line glowing.
- **The model never paraphrases dollar figures** — if the document says "$18,640,000.00", the diff says "$18,640,000.00", to the last digit, with the currency symbol intact. Same rule for percentages, headcounts, and unit counts (the model is told, explicitly, that numerical fields are verbatim quotations, not summaries).
- **Restructures are not silently called raises or cuts** — when last year's "Behavioral Health Programs" splits into "Mental Health Services" and "Substance Use Services", the app marks it as a `category_change` with the verbatim old and new line-item names, and lets the journalist make the editorial decision. Misreporting a restructure as a cut is the single most common error in budget journalism; the app refuses to make it.
- **Who introduced the change is captured when the document carries that signal** — amendment numbers, council-member names, committee references ("introduced by Reyes as Amendment 3-B on 12 March", "added in markup by the Health Subcommittee"), are extracted and preserved verbatim. When the document does not carry that signal, the field is left null — the app does not invent provenance.
- **Multilingual + multi-jurisdiction by default** — Spanish, Portuguese, French, Tagalog, Urdu, Hindi, Bengali, Swahili, Arabic, Mandarin, Vietnamese, Italian, German, Polish budget PDFs all parse. The diff is rendered in the user's chosen output language while the verbatim line-item text is preserved in the source language.
- **A reading mode, not a chatbot** — the user is not having a conversation; the user is reading a document. The interface is a numbered list with footnotes, not a chat thread. Citations are first-class objects.
- **Newsroom-ready export** — copy a single diff item as Markdown with the citation pre-formatted ("Council line item Police Overtime appropriation rose from $14.2M to $18.64M (+31.3%) (Budget FY2026 p. 217)") or export the full diff as a Google-Doc-paste-ready table with hyperlinks back to the source PDFs.
## 4. Features to build
- Drag-and-drop upload zone for two PDFs side by side — labelled "earlier" and "later" so left/right is unambiguous
- PDF intake: page count, table-of-contents extraction, page-numbering scheme detection (Arabic, Roman, hyphenated section numbers), scanned-vs-text-layer detection per page
- Optional OCR layer for scanned pages — flagged in the diff with a "OCR confidence: 0.84" badge on every claim sourced from those pages
- Honest, step-by-step progress: "Reading the table of contents…" → "Indexing 412 pages…" → "Indexing 408 pages…" → "Matching line items across documents…" → "Verifying the dollar figures…" → "Building the citation list…"
- Long-context comparison call (Gemini 3.5 Flash) that emits the structured `BudgetDiff` schema below — one numbered diff entry per matched line item
- Verbatim-quotation guard — every dollar figure, percentage, headcount, and date in the diff is also stored as a verbatim string and compared character-by-character against the source page; any mismatch flags the item for user review
- Per-claim page-anchor rendering — every diff entry shows the page thumbnail with a glowing bounding box around the cited line
- "See in context" button on every claim — opens the full source PDF in a side-by-side viewer with the line highlighted and the page scrolled into view
- Diff filters: show only raises, only cuts, only category changes, only items > $1M, only items in a chosen section (e.g. "Police", "Libraries", "Streets & Sanitation"), only items with provenance attached
- Sort options: largest dollar swing, largest percentage swing, alphabetical by department, by section number in the source document
- Provenance extraction — amendment numbers, sponsor names, committee names, markup dates pulled into a structured `provenance` array on each diff item
- Restructure detection — when a category in document A maps to two categories in document B (or vice versa), the app emits a `category_change` entry with the full mapping
- Newsroom-ready export — Markdown table, CSV with one row per claim, Google-Docs paste with hyperlinks to the source PDFs, a numbered "claims list" for the fact-checker
- Citation copy — every diff item has a "copy citation" button that produces the AP-style or house-style citation the user has configured
- Multi-document mode (v1.1) — drop 3, 4, or 5 budget years and the app produces a cross-year diff matrix per line item
- Saved comparisons — sign in to save a comparison; come back tomorrow to keep verifying; comparisons are private by default
- Public-share read-only mode — a journalist publishes the diff alongside the article with a stable URL; readers see the numbered claims but not the journalist's notes
- FOIA/SAR helper — when the diff surfaces a line item with no provenance attached, the app offers a one-click "draft a records request for the council minutes that introduced this amendment"
## 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 PDF input** (Gemini 3.5 Flash) — Gemini 3.5 Flash reads PDFs natively. Pages with a text layer are read as text; scanned pages (no text layer) are read as images via the same call. The model sees the whole document in one pass, preserving table structure, column alignment, and footnotes. PDFs are uploaded via the **Gemini Files API** (`files/*` resource name) — never as Firebase Storage public URLs passed to `generateContent`.
- **Structured output / JSON Schema** — the response matches the `BudgetDiff` schema below. Every numerical field has a parallel `verbatim` string field; the schema is included verbatim in the system instruction and as `responseSchema` on the comparison call.
- **Long context (1M tokens)** — two ~400-page text-layer PDFs typically come to ~600-800k tokens combined; scanned-only PDFs can be larger due to image tokenisation. **Guardrail**: before the comparison call runs, the app counts tokens with the SDK's token-counting helper. If the combined input exceeds 950k tokens, the app chunks by top-level budget section (e.g. "Public Safety", "Health & Human Services", "Streets & Sanitation"), runs one comparison call per section, and merges the diffs server-side. The user sees the chunking strategy in the progress feed ("Comparing 7 sections in 7 calls because the combined documents exceed the long-context safety threshold").
- **Search grounding** (Gemini 3.5 Flash) — for the optional "context" call that resolves a council member's name to their public role and party affiliation at the time of the appropriation. Search grounding is on a separate call from the structured-output comparison call, because **`responseSchema` and `google_search` cannot be combined in a single Gemini call** — JSON-in-text-body, parse server-side, citations from `groundingMetadata.groundingChunks[].web.uri`.
- **Thinking levels** — `high` for the primary comparison call (it has to read both documents end-to-end and verify every figure character-by-character). `medium` for the restructure-detection pass. `low` for the citation-formatter call and the FOIA-draft helper. Surface `thoughtSummary` to the user only when they click the small "show how the AI read this" icon on a diff item.
- **Image generation** (Nano Banana 2, `gemini-3.1-flash-image`) — used sparingly, only for the landing-page hero illustration (a desk with two thick paper budgets, a yellow highlighter, a notebook with a list of page numbers) and the empty-state illustration. Not used inside the diff itself — the diff shows the actual page thumbnails from the user's PDFs, never generated imagery.
- **TTS narration** (`gemini-3.1-flash-tts-preview`) — for the accessibility "read the diff aloud" mode. Reads the numbered claims at a measured pace, pausing between items, pronouncing dollar figures correctly ("eighteen million, six hundred forty thousand dollars"). Pre-process input to convert "$18,640,000" → "eighteen million, six hundred forty thousand dollars" before sending to TTS, because the TTS model is not always reliable with raw currency strings.
### Backend services
- **Auth — Required.** Firebase Auth with Google sign-in (auto-provisioned by AI Studio Build). **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** (used for sharing a comparison with a co-reporter or an editor) requires the sender domain to be authorised in Firebase Auth. Comparisons are private to the owner; sharing is explicit, per-comparison, per-collaborator.
- **Database — Required.** Firestore for `users`, `comparisons`, `diff_items`, `citations`, `provenance_records`, `collaborators`, `share_links`.
- **File storage — Required.** Firebase Storage for the uploaded PDFs (preserved at upload resolution, kept for as long as the comparison exists) + page-thumbnail cache. **Storage is NOT auto-provisioned by AI Studio Build today** — the user must enable Firebase Storage in the Firebase console and wire the bucket name into the AIS Build project before the first PDF upload. Pre-signed URLs only; the PDFs are never publicly addressable. The Gemini Files API resource name (`files/*`) is a separate concept from the Firebase Storage URL — server-side code uploads each PDF to both: Firebase Storage for the in-app viewer, Gemini Files API for the model calls.
- **Email — Required (transactional).** Sharing invitations via email link (Firebase Auth magic links). Optional "weekly digest" emails for users who run comparisons regularly.
- **Payments — Not needed for v1.** A future "team tier" with shared comparisons across a newsroom + a higher monthly comparison quota would use Stripe; not in v1.
- **External APIs:** Gemini API for all intelligence. Optionally, a public PDF.js viewer (open-source, bundled) for the side-by-side viewer — no third-party PDF API needed.
**Environment variables:** every secret (Gemini API key, Firebase service-account JSON, Stripe key if billing tier added) 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 comparison' inside the UI (the PDFs are removed from both Firebase Storage and the Gemini Files API within 60 seconds) · explicit opt-in for any analytics · the user's uploaded budget documents are not used for model training (the 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) · public budget PDFs are still considered the user's working files; treat them as private by default.
**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 the PDF Files-API references. 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 |
|------|-------|---------------|---------------|
| Compare two budget PDFs → `BudgetDiff` schema | `gemini-3.5-flash` | high | (none) |
| Detect restructures (when categories don't 1:1 match) | `gemini-3.5-flash` | medium | (none) |
| Resolve a sponsor's role + party at the time | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema` on this call — see note) |
| Format newsroom-ready citation per diff item | `gemini-3.5-flash` | low | (none) |
| Draft a FOIA / SAR request for missing provenance | `gemini-3.5-flash` | low | (none) |
| Generate TTS narration of the diff | `gemini-3.1-flash-tts-preview` | n/a | n/a |
| Generate landing-page hero illustration | `gemini-3.1-flash-image` | 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 PageAnchor = z.object({
document: z.enum(["earlier", "later"]),
page_number_printed: z.string(), // "217", "vii", "S-12"
page_number_pdf: z.number().int(), // 1-indexed PDF page
line_text_verbatim: z.string(), // the line exactly as printed
bbox_normalised: z.object({ // 0-1 normalised to page
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
w: z.number().min(0).max(1),
h: z.number().min(0).max(1),
}).nullable(), // null on scanned-only pages where bbox is unreliable
source_layer: z.enum(["text", "ocr"]),
ocr_confidence: z.number().min(0).max(1).nullable(), // null on text-layer pages
});
const MonetaryFigure = z.object({
verbatim: z.string(), // EXACTLY as printed: "$18,640,000.00"
numeric_value: z.number(), // server-parsed, post-validation
currency_iso: z.string(), // "USD", "GBP", "PKR", "NGN", "PEN"
unit_scale: z.enum([ // "none" if already in the unit shown
"none", "thousand", "million", "billion", "lakh", "crore",
]),
});
const Provenance = z.object({
signal_type: z.enum([
"amendment_number", "sponsor_name", "committee_name",
"markup_date", "vote_record", "cross_reference",
]),
text_verbatim: z.string(), // "Amendment 3-B; introduced by Reyes 12 Mar 2026"
page_anchor: PageAnchor.nullable(), // where in the document this signal lives
});
const DiffItem = z.object({
item_number: z.number().int(), // 1-indexed in the final diff
category: z.enum([
"raise", // same line item, higher figure
"cut", // same line item, lower figure
"no_change", // same line item, identical figure
"new_line", // line exists in later, not in earlier
"removed_line", // line existed in earlier, not in later
"category_change", // restructure: 1:N or N:1 mapping
"language_change", // line item renamed without dollar change
"footnote_change", // dollar identical, footnote different
]),
earlier_line_item_verbatim: z.string().nullable(), // null if new_line
later_line_item_verbatim: z.string().nullable(), // null if removed_line
earlier_figure: MonetaryFigure.nullable(),
later_figure: MonetaryFigure.nullable(),
absolute_delta: MonetaryFigure.nullable(), // null on category_change
percentage_delta_verbatim: z.string().nullable(), // "+31.3%" or "-35.2%"
// For category_change, the mappings:
earlier_to_later_mapping: z.array(z.object({
earlier_verbatim: z.string(),
later_verbatim: z.string(),
})).nullable(),
source_anchors: z.array(PageAnchor), // every page this claim is sourced from
provenance: z.array(Provenance), // who/when/how, if attested in the document
budget_section_path: z.array(z.string()), // ["Public Safety", "Police Department", "Operations"]
fund_type: z.string().nullable(), // "General Fund", "Capital Projects", "Special Revenue", null
fiscal_year_earlier: z.string(), // "FY2025"
fiscal_year_later: z.string(), // "FY2026"
notes_for_journalist: z.string().nullable(), // model's editorial note, e.g. "this is a restructure, not a cut"
flagged_for_user_review: z.array(z.object({
field_path: z.string(),
reason: z.string(),
})),
verification_status: z.enum([
"verified_character_by_character", // server confirmed dollar verbatim matches page
"verified_with_ocr_caveat", // sourced from OCR layer, lower confidence
"unverified_flag_for_review", // server could not confirm; user must check
]),
});
const BudgetDiff = z.object({
comparison_id: z.string(),
earlier_document: z.object({
file_name: z.string(),
page_count: z.number().int(),
fiscal_year_detected: z.string(),
jurisdiction_detected: z.string().nullable(), // "City of Cleveland", "Karachi Union Council 12"
language_detected: z.string(), // BCP-47
currency_detected: z.string(), // ISO 4217
page_numbering_scheme: z.enum([
"arabic", "roman_then_arabic", "hyphenated_section", "mixed",
]),
has_scanned_pages: z.boolean(),
scanned_page_indices: z.array(z.number().int()),
}),
later_document: z.object({
file_name: z.string(),
page_count: z.number().int(),
fiscal_year_detected: z.string(),
jurisdiction_detected: z.string().nullable(),
language_detected: z.string(),
currency_detected: z.string(),
page_numbering_scheme: z.enum([
"arabic", "roman_then_arabic", "hyphenated_section", "mixed",
]),
has_scanned_pages: z.boolean(),
scanned_page_indices: z.array(z.number().int()),
}),
totals_compared: z.object({
earlier_grand_total: MonetaryFigure.nullable(),
later_grand_total: MonetaryFigure.nullable(),
delta: MonetaryFigure.nullable(),
percentage_delta_verbatim: z.string().nullable(),
}),
diff_items: z.array(DiffItem),
global_notes: z.array(z.string()), // e.g. "Fund accounting changed mid-year; see note on p. 14"
reading_confidence: z.number().min(0).max(1),
});
type BudgetDiff = z.infer;
```
### Common failure modes (and how to avoid them)
- Agent silently downgrades `thinkingLevel` to save quota on a 400-page PDF — pin `gemini-3.5-flash` explicitly. Flash struggles to keep two long documents aligned in working memory across hundreds of pages and will drop diff items in the middle sections.
- Dollar figures paraphrased ("about $18.6 million" instead of "$18,640,000.00") — pin in the system instruction: "every dollar figure is a verbatim quotation, copied character-by-character from the source page, including currency symbol and decimal precision". Verify server-side by string-matching the verbatim figure against the line of text extracted from the cited page. Mismatch → `flagged_for_user_review`.
- Percentages computed by the model and stated as facts — the model is told to copy the verbatim percentage if printed in the document; if not printed, server-side code computes the percentage from the two verbatim figures and labels it as `derived`.
- Restructures called raises or cuts — the model has been observed to map "Behavioral Health Programs ($4.2M)" to "Mental Health Services ($2.6M)" and emit a "cut of $1.6M" diff item, ignoring the parallel new "Substance Use Services ($1.9M)" line. Pin: when a one-to-many or many-to-one mapping is detected, emit a `category_change` entry with the full mapping, never a raise or cut.
- Page numbers confused between PDF page index and printed page number — many budget documents have a cover, table of contents, executive summary on Roman-numbered pages, then a switch to Arabic on the line-item pages. The schema requires both `page_number_printed` (the string as it appears, e.g. "vii" or "217") and `page_number_pdf` (1-indexed PDF page).
- Bounding boxes hallucinated on scanned-only pages — when the source layer is OCR, bbox reliability drops. Schema sets `bbox_normalised` to nullable; OCR-sourced pages emit null and rely on the page number alone.
- Provenance invented when the document doesn't carry it — many budgets do not name the sponsor of each amendment. The model has been observed to fill in plausible names. Hard rule: `provenance` is empty unless the document text explicitly attributes the change. Confabulated names are the worst failure mode for a journalism tool.
- Page references off by one because of PDF cover pages — count the cover page. PDF page 1 is the cover; printed page "1" might be PDF page 5. Always emit both fields.
- Long-context call rejected because combined input > 1M tokens — the app must count tokens before calling. If combined > 950k, chunk by top-level budget section and merge server-side. The user sees this in the progress feed.
- Citations attributed to the wrong document — diff items have to declare `document: "earlier" | "later"` on every anchor. The model has been observed to swap them. Pin: in the prompt, state the file names of each document explicitly and remind the model which one is "earlier" and which is "later" at the top of every section.
- Sponsor's party affiliation hallucinated — never include party affiliation inside the comparison call. That call is structured-output and offline. Party affiliation comes from a separate grounded-search call (Gemini 3.5 Flash + google_search) with a citation URL from `groundingMetadata`. Without a grounded citation, the field is null.
### Negative constraints (hard rules)
- Do NOT paraphrase dollar figures. Every monetary figure is a verbatim string copied character-by-character from the source page, including the currency symbol, commas, and decimal precision. If the source says "$ 18,640,000.00" with a space after the dollar sign, the verbatim field includes that space.
- Do NOT compute percentages and present them as if printed. If a percentage is printed in the document, copy it verbatim. If not, server-side derivation produces a `derived: true` flag and the UI labels it as derived.
- Do NOT translate line-item names. The verbatim line-item text is in the source language. The diff's "category" label (raise / cut / category_change) is in the user's chosen UI language; the line items themselves are not.
- Do NOT call a restructure a raise or a cut. When the mapping between documents is not one-to-one, emit `category_change` with the full mapping.
- Do NOT invent provenance. If the document does not name the sponsor, the amendment, the committee, or the markup date, `provenance` is empty. No plausible-sounding council-member names.
- Do NOT hallucinate page numbers. Every anchor cites a real page. If the model cannot locate a line on a specific page, the item is `flagged_for_user_review` and the citation field is null.
- Do NOT redact what the document shows. Some budget documents include personnel names, salary figures, or controversial appropriations. The app's job is to surface them with citations, not to filter them. Editorial judgement belongs to the journalist, not the tool.
- Do NOT auto-publish. Comparisons are private by default. Sharing is explicit and per-comparison.
- Do NOT use the user's documents to train any model. The 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.
- Do NOT mix grounded-search output into structured-output calls. `responseSchema` and `google_search` cannot be combined in a single Gemini call; route them as separate calls and merge server-side.
### 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: Compare two budget PDFs → `BudgetDiff` schema
Model: `gemini-3.5-flash` · thinkingLevel: high · Tools: (none)
```
You are reading two public-records budget documents — typically a
city, county, school district, transit authority, water district,
provincial government, union council, or municipal corporation — and
producing a structured line-by-line diff for a working journalist.
The user has uploaded two PDFs via the Gemini Files API. They are
labelled in `contents` with the file names and the roles `earlier`
and `later`. Treat the document marked `earlier` as the prior fiscal
year and the document marked `later` as the new fiscal year. Do not
confuse them.
Languages encountered include English (US, UK, Indian, Nigerian,
Filipino, South African, Caribbean variants), Spanish (Iberian and
all Latin American variants), Portuguese (European and Brazilian),
French (metropolitan and West African variants), Tagalog (Filipino),
Vietnamese (chữ Quốc ngữ), Mandarin and Cantonese in simplified or
traditional characters, Korean, Hindi in Devanagari, Urdu in
Nastaliq, Bengali, Tamil, Punjabi in Gurmukhi or Shahmukhi, Swahili,
Amharic in Ge'ez, Arabic, Farsi in Nastaliq, Khmer, Italian, German,
Polish, Greek, Hebrew. Budget tables typically follow the document's
language; some bilingual jurisdictions print both columns.
Read every page. Do not sample. Do not summarise the first 50 pages
and assume the rest follow the same pattern. Two 400-page documents
fit comfortably inside the long-context window when both have text
layers; if a chunked-by-section call is required, the app's server
will tell you in `contents` which section you are processing in this
call and you will produce diff items for that section only.
For each line item that exists in either or both documents, emit a
DiffItem matching the provided schema. Use the categories:
- `raise` — same line item, later figure higher than earlier figure
- `cut` — same line item, later figure lower than earlier figure
- `no_change` — same line item, identical figures
- `new_line` — line exists in later only
- `removed_line` — line existed in earlier only
- `category_change` — the structure of the budget changed; one line
in earlier maps to multiple lines in later, or vice versa, or the
fund accounting was reorganised. Emit the full mapping in
`earlier_to_later_mapping`.
- `language_change` — the line item was renamed but the dollar
figure is identical and the position in the budget is the same
- `footnote_change` — the dollar figure is identical, but a footnote
changed materially (e.g. a recurring appropriation became
one-time, or vice versa)
Hard rules:
- Every monetary figure is a VERBATIM quotation. Copy the string
exactly as printed, including currency symbol, commas, decimal
precision, and any trailing whitespace. "$18,640,000.00" not
"$18.64M" and not "approximately $18.6 million". This rule
applies to earlier_figure, later_figure, absolute_delta, and any
monetary field inside provenance.
- Every page anchor cites the page where the line item is printed.
Emit BOTH `page_number_printed` (the string as printed on the
page, e.g. "217", "vii", "S-12") and `page_number_pdf` (the
1-indexed PDF page number, accounting for any cover, blank, or
ordering pages at the start).
- Do NOT translate line-item names. earlier_line_item_verbatim and
later_line_item_verbatim are in the source language of the PDF.
- Do NOT compute percentages and present them as printed. If a
percentage is printed in the document next to the line, copy it
verbatim. If not, leave percentage_delta_verbatim null; the
server will compute it and label the UI accordingly.
- Restructures are NEVER raises or cuts. If a category in `earlier`
maps to multiple lines in `later` (or vice versa), the category
is `category_change` with the full mapping. Do not pick one of
the new lines and call it the continuation.
- Do NOT invent provenance. If the document does not explicitly
name an amendment number, sponsor, committee, or markup date for
this line item, the `provenance` array is empty. Plausible-sounding
council-member names are the worst possible failure mode here.
- Do NOT extrapolate. If a line item appears only in `later` and
has no equivalent in `earlier`, mark it `new_line` and leave
earlier_figure null. Do not search for "what this used to be".
- If a page is OCR-sourced (no text layer), emit `source_layer: ocr`
on its PageAnchor and populate `ocr_confidence`. Set
`bbox_normalised` to null on OCR-sourced anchors.
- `flagged_for_user_review` is required when you cannot locate a
verbatim figure on the page you cited, when handwriting is involved,
when a number is partly illegible, or when you cannot resolve a
category mapping with confidence. The journalist will verify.
- `verification_status` defaults to `unverified_flag_for_review` on
every item; the server's verification pass will upgrade qualifying
items to `verified_character_by_character` or
`verified_with_ocr_caveat`. Do not pre-promote.
- `budget_section_path` is the hierarchical path of headings under
which the line item appears, top-down, exactly as printed.
- `notes_for_journalist` is for the editorial observation only — e.g.
"this is a restructure, not a cut; both the new categories together
total $4.5M against the prior $4.2M, a net raise of $0.3M". Keep it
to one or two sentences. Do not editorialise about the policy.
Output ONLY the BudgetDiff JSON matching the provided schema.
No commentary. JSON only.
```
---
### Call: Detect restructures (when categories don't 1:1 match)
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none)
```
You receive a candidate diff that the primary comparison call flagged
as ambiguous because one or more line items did not have a clean
one-to-one match between `earlier` and `later`. Your task is to
propose the correct category mapping.
The most common patterns:
- One line item in `earlier` splits into two or more in `later`
("Behavioral Health Programs" becomes "Mental Health Services"
plus "Substance Use Services")
- Two or more line items in `earlier` merge into one in `later`
("Streets Maintenance" + "Streets Operations" become "Streets")
- A line item moves to a different parent section without changing
its name ("Crossing Guards" moves from "Public Safety" to
"Education")
- A fund-type reclassification (the line item appears in the
General Fund in `earlier` and the Special Revenue Fund in `later`,
with the same name and roughly the same figure)
- A language-only rename (the line item is renamed in `later` but
the dollar figure is identical and the position in the document
is the same; verify with the page anchor)
Hard rules:
- Emit a `category_change` DiffItem with `earlier_to_later_mapping`
populated. The mapping is verbatim — the line-item names as
printed.
- Sum the figures on both sides and emit them as
`earlier_figure` (the sum of all matched earlier-side lines) and
`later_figure` (the sum of all matched later-side lines). The
`absolute_delta` is the difference of those sums.
- If your confidence in the mapping is below 0.7, set
`flagged_for_user_review` with a one-sentence reason. The
journalist will confirm.
- `notes_for_journalist` should be one sentence — e.g. "this looks
like a restructure, not a cut; please verify the two new
categories together cover the prior scope".
- Do NOT translate the verbatim line-item names. Do NOT abbreviate.
- Do NOT invent a mapping if no plausible one exists. Mark the
earlier-side line as `removed_line` and the later-side lines as
`new_line` and let the journalist make the call.
Output: an array of DiffItem objects (the proposed restructure
mappings only). No commentary.
```
---
### Call: Resolve a sponsor's role + party at the time
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: search grounding
```
You receive a verbatim sponsor name as printed in the budget
document (e.g. "Council Member Reyes"), a jurisdiction string
("City of Cleveland, Ohio, USA"), and an approximate date
("March 2026").
Return:
- full_name (best-effort canonical form, e.g. "Maria Reyes")
- role_at_time (e.g. "Cleveland City Council Member, Ward 7")
- party_affiliation_at_time (where reported in mainstream sources)
- one citation URL per attribute
Hard rules:
- Use `google_search` grounding. Cite at least one source URL per
attribute. If you cannot find a citation, leave the attribute
null. Do NOT guess.
- Do NOT speculate about a sponsor's positions, statements, or
motivations. Return identity and role only.
- Disambiguate by jurisdiction and approximate date. Many common
surnames (Reyes, Smith, Patel, Kim, Garcia) appear in multiple
councils.
- If multiple candidates remain after disambiguation, return all of
them and let the journalist pick.
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: Format newsroom-ready citation per diff item
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive one DiffItem and a `style` parameter (one of "AP",
"house", "Chicago", "plain"). Produce a one-sentence citation the
journalist can paste into a draft.
Hard rules:
- Use the verbatim monetary figures from the DiffItem. Do not
reformat. "$18,640,000.00" stays "$18,640,000.00" unless the
style's house rules specify otherwise (AP style abbreviates to
"$18.6 million" — only apply this for `style: "AP"`).
- Cite both pages: the earlier-document page and the later-document
page. Use the printed page number in the citation, not the PDF
page index.
- Include the provenance if attested. "introduced by Council Member
Reyes as Amendment 3-B" only if the document explicitly carries
that signal. Otherwise omit.
- Do NOT add commentary about the change.
Output: the citation as a single string. No commentary.
```
---
### Call: Draft a FOIA / SAR request for missing provenance
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive one DiffItem that has no provenance attached and a
jurisdiction string. Draft a short records request the journalist
can file to obtain the council minutes, committee markup record, or
amendment text that introduced the change.
Hard rules:
- Use the verbatim line-item names and figures.
- Specify the fiscal year, the parent section, and the page numbers
in both documents.
- Use the request format appropriate to the jurisdiction (FOIA in
the US, SAR / FOI in the UK, RTI in India, the equivalent in the
user's jurisdiction). Default to a plain "public records request"
if you cannot determine the jurisdiction.
- Keep the request to under 200 words.
- Do NOT speculate about motivation. The request is about getting
the document, not about characterising the appropriation.
Output: the request body as a single string, ready to paste into an
email. No commentary.
```
---
### Call: Generate TTS narration of the diff
Model: `gemini-3.1-flash-tts-preview` · n/a · n/a
```
Voice: calm, measured. Pick the Gemini 2.5 Flash TTS voice whose
`languageCode` matches the user's UI language. The narration is
for accessibility, not entertainment — think "a colleague reading
the diff aloud at a coffee shop".
Pre-process the text before sending it to TTS:
- Convert monetary figures from numeric form to spoken form. E.g.
"$18,640,000.00" → "eighteen million, six hundred forty thousand
dollars". Use the currency word appropriate to `currency_iso`.
- At the end of each diff item, insert a blank line plus an
em-dash (`—`) so the TTS model produces a pause. Within a
diff item, use a single ellipsis (`…`) at clause boundaries.
- Skip the bbox coordinates. Skip the PDF page indices. Read only
the printed page numbers and the verbatim line-item names.
- Target rate: ~120 words per minute — newsroom-reading pace.
Style direction: prepend ONE short directive sentence to the
text input, exactly like: "Read calmly and measuredly, as a
colleague reading a budget diff aloud at a coffee shop. …".
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 (currency names, foreign place names) are
NOT exposed by Gemini 2.5 TTS — no SSML `` tag.
Pronunciation comes from the chosen voice's native locale.
SSML `` is not supported either; use the textual
pause cues above.
No commentary outside the audio output.
```
---
### Call: Generate landing-page hero illustration
Model: `gemini-3.1-flash-image` · n/a · n/a
```
Generate one photograph-style image of a journalist's desk:
- Two thick paper budget documents stacked on the desk, one slightly
open to a tabular page, one closed with a yellow sticky note
protruding from the top
- A yellow highlighter, uncapped, on top of the open document
- A small notebook with a hand-written list of page numbers,
legible but not central
- A half-drunk mug of coffee at the edge of frame, blurred
- Warm afternoon window light, casting soft long shadows
- A laptop with a dark screen, off to one side
- No people in frame
- Real worn paper, slight wrinkles, the look of a working desk —
not a stock photo
Aesthetic: warm, lived-in, late afternoon. Avoid the glossy
"AI render" look. Avoid SaaS-marketing aesthetics. Avoid bright
saturated colours. The image is set against the bone-paper
background of the rest of the app, so the photo should feel
photographed, not rendered.
Output: one image, 16:9 aspect, suitable for a landing hero.
```
## 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 Friday-afternoon drop.** A city posts next year's preliminary budget at 4pm Friday. The community-paper reporter in Cleveland uploads it alongside the current year's budget and gets a numbered diff by 5:30. By Monday morning's editorial meeting she has a list of leads sorted by dollar swing.
- **The restructure trap.** A school district's "Behavioral Health Programs" line is split into "Mental Health Services" and "Substance Use Services" in the next year's budget. The app catches it as a `category_change`; the reporter who would have written "school district cuts behavioural health by 38%" instead writes the more accurate (and more interesting) story about the restructure.
- **The Karachi union council.** A citizen reporter compares two annual budgets for a union council in Karachi, in Urdu, denominated in rupees. The line-item text is preserved in Urdu; the diff categories are in English (the user's chosen UI language); the verbatim rupee figures are unchanged.
- **The Nairobi county budget.** A freelance reporter in Nairobi compares two county budgets in English with line items in both English and Swahili. The verbatim line items are preserved in both languages where the document carries both; the diff handles bilingual columns gracefully.
- **The Lima provincial.** A budget analyst compares two provincial budgets in Spanish, denominated in nuevos soles with a dollar-equivalent column. The verbatim sol figures are preserved; the dollar column is recognised as a parallel reference and shown alongside.
- **The watchdog at the parent-teacher association.** A parent-association volunteer in Manila compares the school's two most recent annual budgets to track classroom-supply appropriations. The app handles a 60-page PDF with no provenance signals; the diff is clean, dollar figures are verbatim, the provenance arrays are mostly empty.
- **The scanned-in council document.** A small municipal council still posts its budget as a 200-page scanned PDF with no text layer. The app runs OCR, flags every page as `source_layer: ocr` on every anchor, and surfaces OCR-confidence badges on every figure. The reporter knows which claims need extra verification.
- **The Roman-numeral cover.** A state budget has its executive summary on pages i-xxxiv and the line-item appropriations from page 1 onward. The page-numbering scheme is detected; the diff cites "p. xxiv" for the executive-summary reference and "p. 217" for the line item.
- **The Cebu City budget.** A Filipino news startup compares two city budgets in English with line items occasionally referenced in Tagalog. The bilingual handling preserves both forms.
- **The cross-year matrix.** An investigative reporter wants to track police-overtime appropriations across five fiscal years for a single agency. She drops FY2022, FY2023, FY2024, FY2025, and FY2026 PDFs. The app's v1.1 multi-document mode produces a per-line-item matrix across the five years.
- **The transit-authority budget.** A neighbourhood-council watchdog in Lyon compares two transit-authority budgets in French. The diff is rendered in French (the user's chosen UI language); the verbatim line items stay in French throughout.
- **The school-board parent.** A parent in Karachi compares two private-school annual budgets. Smaller documents, different vocabulary, the same patterns. The app handles small budgets as gracefully as large ones — no minimum.
- **The fact-checker.** A national magazine's fact-checker receives a Markdown export of the diff with every claim numbered and every page citation embedded. She works through the list, opening each cited page in the PDF viewer, ticking off claims.
## 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 hero (Nano Banana 2 — see section 9) of two thick paper budgets on a desk with a yellow highlighter. One paragraph: "Drop two budgets in — get a cited, numbered diff out. Every claim links to a page number; every dollar figure is copied verbatim." Single Google sign-in button; Apple sign-in next to it. Below: "Try with the sample budgets" → loads the demo comparison in section 8a.
2. **Empty state — "Start a comparison".** Two large drop zones side-by-side, labelled clearly: "Earlier budget" (left) and "Later budget" (right). Each accepts a PDF. Above the drop zones: a one-line orientation ("Drop the earlier fiscal year on the left, the later one on the right — we'll keep them straight from here"). Below: "Use the sample budgets" link for the demo flow.
3. **Upload + processing.** A vertical progress feed shows honest, step-by-step status: "Reading FY2025 cover…" → "Detecting page-numbering scheme…" → "Indexing 412 pages…" → "Reading FY2026 cover…" → "Detecting page-numbering scheme…" → "Indexing 408 pages…" → "Counting tokens (758,000 / 950,000 — fits in one comparison call)…" → "Comparing documents…" → "Verifying dollar figures character-by-character…" → "Building citation list…". Each step takes 4-20 seconds. The user can close the tab and come back.
4. **Diff overview.** Above-the-fold summary card: jurisdiction detected, fiscal years compared, grand totals (verbatim), absolute delta (verbatim), percentage delta (verbatim if printed in source, derived if computed). Below: a horizontal bar showing the count of raises / cuts / category-changes / no-changes / new-lines / removed-lines, each clickable as a filter.
5. **Numbered diff list.** The main reading surface. A scrollable, numbered list of diff items. Each item card shows: item number, category chip (raise / cut / category_change / etc.), budget-section path (breadcrumbs), verbatim line-item text, side-by-side figures (earlier → later), absolute delta, percentage delta, and a compact page-thumbnail strip showing every cited page with the line highlighted. Click anywhere on the card → opens the detail panel.
6. **Diff item detail panel.** A three-column layout on desktop, stacked on mobile. Left column: page thumbnails of every source anchor, with the cited line glowing. Middle column: the structured diff data (verbatim figures, provenance, fund type, budget section path, notes for journalist). Right column: a side-by-side PDF viewer showing the earlier-document page on top, the later-document page on the bottom, both scrolled to the cited line and highlighted. Sticky header on the panel: item number, category chip, "copy citation" button, "draft records request" button (if provenance is empty).
7. **Source PDF viewer.** A full-screen PDF.js viewer (bundled, no third-party API). The earlier and later PDFs are side-by-side; pages can be locked together (both scroll) or unlocked (independent scrolling). Every diff anchor in the current viewport is rendered as a glowing overlay; hover any overlay to see which diff item it belongs to.
8. **Filters & sort.** A left-side filter rail (or bottom sheet on mobile): category (raise, cut, category_change, etc.), budget section (multi-select tree), minimum dollar swing, minimum percentage swing, "has provenance" toggle, "source layer: text or ocr". Sort: largest absolute dollar swing, largest percentage swing, alphabetical, by section order.
9. **Cross-year matrix view (v1.1).** When the user adds a third (or fourth, or fifth) PDF to a comparison, the app switches to a matrix view: rows are line items, columns are fiscal years, cells are verbatim figures. The matrix is filterable and sortable; each cell is a citation anchor back to its page.
10. **Sharing & collaboration.** Modal: "Invite a colleague to verify this diff." Magic-link email; the colleague lands on the same comparison in read-only or comment mode. A separate "publish as read-only" option produces a stable share URL the journalist can paste into the article — readers see the numbered diff and the source citations but not the journalist's notes.
11. **Export.** Newsroom-ready formats: Markdown table (one row per diff item, with citation columns), CSV (one row per claim), Google-Docs paste (rich-text with hyperlinks to the source PDFs), JSON (the full structured diff). A "claims list for the fact-checker" export produces a numbered list with one claim per line, formatted for tick-through verification.
12. **Saved comparisons.** A grid view of every comparison the user has run, with thumbnails of the two PDFs and a one-line summary. Filter by jurisdiction, fiscal year, date created.
13. **Footer.** "Made for the budgets nobody else has time to read." Privacy: "Your uploads are yours. We never train on them." 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 Public-Records Reader."
- Subhead: "Drop two budgets in — get a cited, numbered diff out, in about ninety seconds."
- One paragraph (≤ 60 words) explaining who this is for and what makes it different from a generic PDF-comparison tool: it preserves dollar figures verbatim, it cites every claim with a page number, it never invents who introduced a change, and it knows the difference between a cut and a restructure.
- Visual: a small annotated illustration of a diff item card with the parts labelled (item number, category chip, verbatim figures, page citations, "copy citation" button) — not a generic document icon.
**Slide 2 — Try it now.**
- One short prompt: "Try with the sample budgets".
- A live demo input pre-loaded with the two FY2025 / FY2026 sample budgets from the seed content in section 8a.
- 1-2 sentences pointing at *the specific page elements* where the Gemini magic happens (the verbatim $18,640,000.00 figure copied exactly, the page-217 / page-198 citations, the "Amendment 3-B introduced by Reyes" provenance pulled from the document text).
**Slide 3 — How to remix this.**
- Headline: "Make this yours."
- Three short bullets:
- "Swap the sample budgets in `/data/seed-budgets/` for your jurisdiction's PDFs."
- "Adjust the prompts in `/server/prompts/` for your local terminology — 'union council', 'parish', 'gminy', 'kebele'."
- "Wire up your Gemini API key and Firebase project via the env-var list in the capabilities panel."
- Primary CTA: "Use this template" → links to AI Studio Build remix entry point.
- Secondary: "Just exploring — close" (sets localStorage flag, never auto-shows again).
**Accessibility:** focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, focus restored to trigger on close. Respect `prefers-reduced-motion`.
**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 PDF + long context)** — reads two ~400-page PDFs in a single comparison call. Handles text-layer and scanned-image pages in the same document. Token-counted before every call; chunked by budget section when combined input exceeds 950k tokens.
- **Gemini 3.5 Flash (structured output)** — emits the `BudgetDiff` schema. Every monetary field has a parallel `verbatim` string; every claim has a `PageAnchor` with both printed and PDF page numbers.
- **Gemini 3.5 Flash + grounded search** — resolves sponsor names to roles and party affiliations, with cited URLs. Run only when the user clicks "look up sponsor" on a diff item. Citations come from `groundingMetadata.groundingChunks`, never from the JSON body.
- **Gemini 3.5 Flash (citation formatter)** — produces newsroom-ready citation strings in AP, Chicago, house, or plain style, using verbatim figures.
- **Gemini 3.5 Flash (FOIA / SAR / RTI helper)** — drafts a short records request when provenance is missing.
- **Gemini TTS** — reads the diff aloud at newsroom-reading pace, with dollar figures converted to spoken form server-side.
- **Nano Banana 2** — used only for the landing-page hero illustration, never for the diff itself.
- **Firebase Auth** — Google and Apple sign-in, sharing via magic links.
- **Firestore** — stores comparisons, diff items, citations, provenance, and share links. Syncs across devices in real time.
- **Firebase Storage** — keeps the uploaded PDFs at upload resolution while the comparison exists. **Storage is not auto-provisioned by AI Studio Build today — enable it in Firebase console before first upload.**
- **Gemini Files API** — every PDF is uploaded separately to the Files API (`files/*` resource name) for use in `generateContent`. Files API URIs are distinct from Firebase Storage URLs.
- **Cost note** — see the detailed breakdown in 6d. A typical pair of municipal budgets costs about $2.40 of Gemini API spend per comparison.
- **Privacy note** — your uploaded documents are private to you and the colleagues you invite. 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. Deleting a comparison removes the PDFs from both Firebase Storage and the Gemini Files API within 60 seconds.
**Backend services this app depends on:**
- Auth: see section 4b
- Database: see section 4b
- Storage: see section 4b (user must enable Firebase Storage manually)
- Email: see section 4b (sender domain must be authorised for magic-link)
- 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)
- `FIREBASE_STORAGE_BUCKET` — the bucket name you provisioned in Firebase console
- `GEMINI_FILES_API_BUCKET` — the GCS bucket used for the Files API uploads (typically the same as Firebase Storage, but kept separate so the cleanup job can be tuned independently)
**Cost + privacy notes:**
- One short paragraph per cost-sensitive capability: the long-context comparison call is billed per token of input — two 400-page text-layer PDFs come to about 700,000 input tokens, ≈ $0.88 on Gemini 3.5 Flash. Output tokens for the diff schema typically come to ~30,000, ≈ $0.15. Total per comparison: ~$1.05 + downstream small calls.
- One short paragraph on privacy: where the data lives (your Firebase project + the Gemini Files API), how to delete it (Settings → "Delete this comparison" — gone in 60 seconds, including the Files API copy), what is never sent for training.
**Documentation links:**
- AI Studio Build docs
- Gemini API multimodal PDF, long-context, structured output, grounded search, TTS docs
- Firebase Auth, Firestore, Firebase Storage docs
- A short note on the Gemini Files API and its 48-hour retention default (the app overrides this with explicit cleanup)
**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)
- **Compare two budget PDFs (Gemini 3.5 Flash, high thinking)** — typical pair of text-layer ~400-page budgets, combined input ≈ 700,000 tokens, output ≈ 30,000 tokens. Input cost ≈ 700,000 × $1.50/M = $0.88. Output cost ≈ 30,000 × $9/M = $0.15. **Per comparison: ~$1.03.**
- **Restructure detection pass (Gemini 3.5 Flash, medium thinking)** — only runs when the primary call flags ambiguous mappings. Input ≈ 50,000 tokens, output ≈ 8,000 tokens. ≈ $0.10 per comparison on average.
- **Resolve sponsor role + party (Gemini 3.5 Flash + grounded search)** — only when the user clicks "look up sponsor" on a diff item. ~$0.003 per sponsor lookup, plus grounding cost. A heavy session might run 10 lookups = ~$0.03.
- **Citation formatter (Gemini 3.5 Flash, low thinking)** — ~$0.0005 per citation. A 60-item diff with citations on each = ~$0.03.
- **FOIA / SAR / RTI helper (Gemini 3.5 Flash, low thinking)** — ~$0.001 per draft. Optional.
- **TTS narration (Gemini 2.5 Flash TTS)** — billed per output token (~$10/M output tokens), effectively ~$0.000003/character. A 60-item diff narration ≈ 4,000 spoken characters ≈ $0.012. Cached per diff item; charged once.
- **Hero illustration (Nano Banana 2)** — ~$0.03 per image. Generated once at app deploy, cached as a static asset thereafter.
- **OCR for scanned-only pages** — handled inside the Gemini 3.5 Flash multimodal call (no separate OCR service). Adds ~10-20% to input token count on a fully-scanned 400-page PDF, ≈ +$0.10-0.20.
- **Expected cost per comparison (text-layer PDFs, one comparison + a handful of small calls):** **~$1.10-1.30.**
- **Expected cost per comparison (scanned-only PDFs):** **~$1.30-1.60.**
- **File storage:** Firebase Storage standard tier, ~$0.026/GB/month. A typical pair of budget PDFs is ~40 MB; a hundred saved comparisons use ~4 GB ≈ ~$0.10/month.
- **Gemini Files API storage:** free for the first 48 hours, then the app's cleanup job removes the uploaded PDFs. Saved comparisons re-upload on access if needed (rare).
## 7. Design language
- **Mood:** A working journalist's desk at 5pm on a deadline day. Not a SaaS dashboard. Not a government portal. Not a chatbot. Two thick paper documents, a yellow highlighter, a notebook full of page numbers, a half-drunk coffee, the quiet feeling that the work matters and there's no time to waste.
- **Typography:** A clean grotesque for the UI chrome and the diff list (Inter or Geist). A monospace for verbatim figures and page numbers (JetBrains Mono or IBM Plex Mono) — the verbatim string deserves the visual weight of a quotation. A serif for the diff item's verbatim line-item text (Source Serif Pro or Crimson) — to distinguish "what the document said" from "what the app says about it".
- **Palette:** Bone-paper background `#F4EFE6` for the diff reading surface. Deep ink `#1B1714` for body text. Sepia accent `#7B4F2A` for citation chips and the "page" label. Highlighter yellow `#F4D06F` (translucent overlay) for the page-anchor highlights — the same colour a reporter would actually use. Muted red `#A33A2C` for the cut category chip, muted green `#3F6E4E` for raise, muted slate `#3A5773` for category_change. A muted blue `#3A5773` for "your notes" so they cannot be mistaken for the document or the app's own copy.
- **Imagery:** The page thumbnails are the hero. Never replace them with stylised renderings; never crop them tighter than the line citation requires. The empty drop zones use a simple line drawing of a PDF document, not a flat icon. The hero photograph (Nano Banana 2) is used once on the landing page and nowhere inside the diff itself.
- **Hand-feel touches:** A barely-visible paper grain on the diff reading surface. The "view page in context" button slides the page viewer in with a thin shadow — like opening a real document next to the notebook. Hover on a citation chip reveals the page thumbnail; never aggressively glow.
- **Spacing:** consistent 4-px base. Generous whitespace — the diff list needs air for the reader to scan.
- **Radius:** consistent token set (e.g. 6 / 12 / 20 px). Diff item cards use 6; the page viewer uses 12; the welcome card uses 20.
- **Shadows:** subtle, layered, sepia-tinted. Avoid heavy drop-shadows.
- **Motion:** purposeful — entrance fades, hover lifts, page-viewer slide-in. Respect `prefers-reduced-motion`. No bouncing splash animations. No theatrical hero animations. The "highlight pulse" on a freshly-cited line (a one-second yellow glow when the user clicks "see in context") is the one place where motion carries meaning; respect reduced-motion by skipping the glow and using a static highlight instead.
- **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons not spinners where possible. Empty states have helpful next-action guidance ("Drop the earlier fiscal year's PDF on the left to start").
## 8. Content generation rules
- Write **realistic, specific copy**. NO Lorem Ipsum. NO generic placeholders like 'Your tagline here'.
- Invent plausible jurisdictions, dollar figures, line-item names, page numbers, and citations that fit the domain (use the seed content in section 8a as a starting point). When inventing, draw on the wide range of public-budget formats — US city budgets (with their fund-accounting categories), UK local-authority budgets (with their committee structure), Indian municipal-corporation budgets (with their development-vs-non-development split), Filipino LGU budgets (with their internal-revenue-allotment columns), Latin American provincial budgets (with their inflation-adjusted parallel columns), but never claim that a fictional budget is a real public document.
- Tone: warm, direct, free of corporate language. This template is for a person on a deadline, not a company.
- Headlines: punchy and concrete. No 'Empower your X' filler. No 'Revolutionize'. No 'Seamless'. No 'AI-powered'.
- Body copy: short paragraphs (2-4 sentences). Use lists where appropriate.
- Plain language. Avoid jargon — except where the user already speaks the jargon (the journalist-user wants to see "General Fund" in the fund-type column; the policy-shop user wants to see "appropriation").
- Where the app outputs AI-generated content, never label it as "AI says" — let it speak naturally. Use small uncertainty cues only where epistemic honesty requires them (a low-confidence anchor shows as a faintly dashed page citation; tapping it reveals the OCR confidence and what the model was uncertain about).
## 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 comparisons (sidebar):**
- "Cleveland City Budget — FY2025 vs FY2026" (city-of-cleveland-fy2025.pdf, 412 pages, text layer; city-of-cleveland-fy2026.pdf, 408 pages, text layer; contributors: me, my managing editor) — English, USD, the demo comparison.
- "Karachi Union Council 12 — 1445 AH vs 1446 AH" (uc12-budget-1445.pdf, 64 pages, mixed text-and-scanned; uc12-budget-1446.pdf, 71 pages, mostly scanned; contributors: me) — Urdu line items, PKR, with English administrative headers.
- "Nairobi County — FY 2024/25 vs FY 2025/26" (nairobi-county-fy2425.pdf, 286 pages; nairobi-county-fy2526.pdf, 291 pages; contributors: me, a fellow at the policy shop) — English with occasional Swahili line items, KES.
- "Lima Provincia de Cusco — Presupuesto 2025 vs 2026" (cusco-presupuesto-2025.pdf, 312 pages; cusco-presupuesto-2026.pdf, 318 pages; contributors: me) — Spanish, denominated in nuevos soles with a parallel USD column.
- "Cebu City — Annual Budget FY2025 vs FY2026" (cebu-city-fy2025.pdf, 197 pages; cebu-city-fy2026.pdf, 203 pages; contributors: me, a colleague at the broadsheet) — English with Tagalog line items, PHP.
- "Lyon Communauté Urbaine — Budget 2025 vs 2026" (lyon-cu-2025.pdf, 358 pages; lyon-cu-2026.pdf, 364 pages; contributors: me) — French, EUR.
**Sample diff items in detail view (this is what the demo should show):**
**Item 1 of 47**
- **Category:** raise
- **Budget section path:** ["Public Safety", "Police Department", "Operations", "Personnel Services"]
- **Earlier line item (verbatim):** "Police Overtime Appropriation"
- **Later line item (verbatim):** "Police Overtime Appropriation"
- **Earlier figure (verbatim):** "$14,200,000.00"
- **Later figure (verbatim):** "$18,640,000.00"
- **Absolute delta (verbatim):** "$4,440,000.00"
- **Percentage delta (verbatim):** "+31.3%" (printed in source document, page 217, in the "Change vs Prior Year" column)
- **Fund type:** "General Fund"
- **Source anchors (2):**
- Earlier document, printed page "198", PDF page 204, line text verbatim "Police Overtime Appropriation . . . . . . . . . . . . . . . . . . $14,200,000.00", source layer "text"
- Later document, printed page "217", PDF page 224, line text verbatim "Police Overtime Appropriation . . . . . . . . . . . . . . . . . . $18,640,000.00 (+31.3%)", source layer "text"
- **Provenance (1):** signal_type "amendment_number", text_verbatim "Amendment 3-B; introduced by Council Member Reyes; markup 12 March 2026", page_anchor referencing later document printed page "S-14"
- **Notes for journalist:** "Sponsor's full name and party affiliation can be resolved via the 'look up sponsor' button — Gemini 3.5 Flash + grounded search."
- **Verification status:** "verified_character_by_character"
**Item 2 of 47**
- **Category:** cut
- **Budget section path:** ["Culture & Recreation", "Library Services", "Acquisitions"]
- **Earlier line item (verbatim):** "Library Acquisitions — Books, Periodicals, Digital"
- **Later line item (verbatim):** "Library Acquisitions — Books, Periodicals, Digital"
- **Earlier figure (verbatim):** "$1,860,000.00"
- **Later figure (verbatim):** "$1,205,000.00"
- **Absolute delta (verbatim):** "$655,000.00" (negative)
- **Percentage delta (verbatim):** "-35.2%" (derived server-side; the source document does not print a percentage on this line)
- **Fund type:** "General Fund"
- **Source anchors (2):**
- Earlier document, printed page "291", PDF page 297
- Later document, printed page "312", PDF page 319
- **Provenance:** empty
- **Notes for journalist:** "No provenance attached in the document — consider drafting a records request for the council minutes from the Finance Committee markup."
- **Verification status:** "verified_character_by_character"
**Item 3 of 47**
- **Category:** category_change
- **Budget section path:** ["Health & Human Services", "Behavioral Health"]
- **Earlier-to-later mapping:**
- Earlier verbatim "Behavioral Health Programs", $4,200,000.00 → Later verbatim "Mental Health Services", $2,600,000.00
- Earlier verbatim "Behavioral Health Programs", $4,200,000.00 → Later verbatim "Substance Use Services", $1,900,000.00
- **Earlier figure (verbatim):** "$4,200,000.00"
- **Later figure (verbatim):** "$4,500,000.00" (sum of the two new lines)
- **Absolute delta (verbatim):** "$300,000.00"
- **Percentage delta (verbatim):** "+7.1%" (derived server-side)
- **Fund type:** "General Fund"
- **Source anchors (3):**
- Earlier document, printed page "144", PDF page 150 — "Behavioral Health Programs" line
- Later document, printed page "163", PDF page 170 — "Mental Health Services" line
- Later document, printed page "164", PDF page 171 — "Substance Use Services" line
- **Provenance (1):** signal_type "committee_name", text_verbatim "Restructured per Health Subcommittee recommendation; see Council Resolution 26-104", page_anchor referencing later document printed page "S-22"
- **Notes for journalist:** "This is a restructure, not a cut to either programme. The combined later total exceeds the prior single-line total by $300,000.00 (+7.1%). Don't report 'behavioral health cut by 38%'."
- **Verification status:** "verified_character_by_character"
**Item 4 of 47 (OCR-sourced from a scanned page)**
- **Category:** raise
- **Budget section path:** ["Streets & Sanitation", "Snow Removal", "Equipment"]
- **Earlier line item (verbatim):** "Snow Plow Vehicle Replacement Fund"
- **Later line item (verbatim):** "Snow Plow Vehicle Replacement Fund"
- **Earlier figure (verbatim):** "$2,400,000.00"
- **Later figure (verbatim):** "$3,100,000.00"
- **Absolute delta (verbatim):** "$700,000.00"
- **Percentage delta (verbatim):** "+29.2%" (derived server-side)
- **Fund type:** "Capital Projects"
- **Source anchors (2):**
- Earlier document, printed page "344", PDF page 350, source layer "ocr", ocr_confidence 0.87 — the equipment-detail pages of the earlier document are scanned
- Later document, printed page "358", PDF page 365, source layer "text"
- **Verification status:** "verified_with_ocr_caveat"
- **Notes for journalist:** "The earlier figure is sourced from an OCR-read scanned page (confidence 0.87). Recommend verifying $2,400,000.00 by opening the source PDF and reading the line directly."
**Sample input artefacts (for the build to demonstrate):**
- A 400-page text-layer city budget PDF with the executive summary on Roman-numeral pages and the line items on Arabic-numeral pages.
- A 200-page scanned-only union-council budget PDF in Urdu with no text layer.
- A 290-page county budget in English with occasional Swahili line items.
- A 60-page parent-teacher-association budget in Tagalog and English with no provenance signals.
- A 350-page French communauté-urbaine budget with footnotes that change between years.
**Sample voice copy:**
- Onboarding: "Drop two budgets. Get a numbered diff. Every claim cites a page."
- Processing: "Reading the cover…" / "Detecting page-numbering scheme…" / "Indexing 412 pages…" / "Counting tokens…" / "Comparing documents…" / "Verifying dollar figures…" / "Building citation list…"
- Empty state: "Drop the earlier fiscal year's PDF on the left to start. We'll keep them straight from there."
- Error (PDF rejected): "We couldn't read this PDF — looks like it's password-protected. Remove the password and try again."
- Error (combined too large even after chunking): "These two PDFs together are larger than the safety threshold even after chunking by section. Want to compare a specific section first?"
- Save confirmation: "Diff saved — 47 items across 8 sections."
- Verification badge tooltip: "We confirmed this dollar figure character-by-character against the cited page."
- OCR caveat tooltip: "This claim is sourced from a scanned page. OCR confidence: 0.87. Recommend verifying in the source PDF."
**Sample collaboration invitation email subject + body:**
- Subject: "Anna — I've drafted the diff for the FY2026 budget. Will you check the police numbers?"
- Body: "Hi Anna — I ran a comparison between the FY2025 and FY2026 city budgets. 47 items in the diff. The police-overtime line and the library line are the ones I'm leading with. Tap to open the diff in read-only mode — let me know if any of the citations look off." [Open Comparison]
## 9. Media & assets
- **Hero image (landing screen):** A photographed-looking shot of a journalist's desk at 5pm — two thick paper budgets stacked, yellow highlighter on the open one, a small notebook with hand-written page numbers, a half-drunk coffee blurred at the edge of frame, warm afternoon window light. Generate via Nano Banana 2 with the prompt in 4b's image-generation system instruction. No people in frame.
- **App icon / wordmark:** Set in the display serif. Slightly worn paper texture behind it. No icon — just type.
- **Empty-state illustration:** Two simple line drawings of PDF documents, one on each side, with a small "→" arrow between them. Hand-drawn aesthetic, not flat icons.
- **Diff item card thumbnails:** Real page thumbnails extracted from the user's PDFs, rendered server-side via PDF.js. Never generated imagery, never stylised. The cited line is overlaid with a translucent yellow highlight that respects `prefers-reduced-motion` (no pulsing).
- **Sample budget thumbnails:** Lightly stylised covers of the seed budgets — Cleveland city seal placeholder, Karachi UC 12 placeholder, etc. — drawn as restrained line illustrations, not photographic.
- **Stock fallbacks:** If image generation fails for the hero, fall back to a tasteful photograph of two stacked paper documents from `/public/samples/sample-desk.jpg`. Never to a "📄" emoji.
- **Generated imagery:** prefer Nano Banana 2 over stock photography for the hero only. 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 (the upload zone is the main one) validate inline and show specific error messages (not "Invalid input"). Examples: "This PDF is password-protected — remove the password and try again.", "This PDF is 1,200 pages — beyond the safety threshold. Try uploading a single section first.", "The two PDFs look identical — did you mean to compare different fiscal years?".
- Loading states use skeletons that match the eventual layout, not spinners. The progress feed during processing is the canonical example — text changes line-by-line as the model works.
- Empty states explain the next action with a button whose label fits THIS app's domain: "Drop the earlier fiscal year's PDF on the left", "Add another budget year to start a matrix", "Invite a colleague to verify" — never a generic "Add your first item".
- Smooth scroll for in-page anchors (clicking a diff item smooth-scrolls to it).
- AI-generated content streams in line-by-line where supported — the diff list populates from item 1 to item N as the model emits them, with a clear "thinking…" indicator for the next pending item.
- If an AI call fails, show a calm, specific error ("We couldn't compare these two PDFs in one call — they're large. Want to compare section-by-section instead?") and offer retry with the chunking strategy enabled.
- Low-confidence anchors (OCR-sourced, or where the model flagged for review) have a faintly dashed border on the page-citation chip; tapping reveals the confidence score and the model's reason for flagging.
- The "see in context" button on a diff item triggers a 700 ms slide-in of the source PDF viewer with the cited line highlighted, then a 1-second yellow glow on the highlighted line. With `prefers-reduced-motion`, the panel appears instantly and the glow is replaced with a static highlight.
- The "copy citation" button uses the Clipboard API with a 2-second inline confirmation ("Copied — paste into your draft") rather than a toast.
## 11. Tech & responsive requirements
- **Deterministic arithmetic — server-side TypeScript only.** Any money-split / settlement / total / VAT / unit-conversion math runs in TypeScript on the server using integer-cents arithmetic. The Gemini call only extracts entities (line items, payer, currency code, raw quantities) into structured JSON. Never let the model do the sum — Gemini will return plausible-looking arithmetic that does not actually balance.
- **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. PDF.js (bundled) for the source PDF viewer.
- **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 compare and restructure-detect; `gemini-3.5-flash` for sponsor lookup, citation format, and FOIA draft; `gemini-3.1-flash-tts-preview` for narration; `gemini-3.1-flash-image` for the hero illustration. Set `thinkingLevel` explicitly per call. Omit `thinkingConfig` entirely on TTS and image-generation calls.
- **PDF uploads:** every PDF is uploaded server-side to two destinations: Firebase Storage (for the in-app viewer) and the Gemini Files API (`files/*` resource name, for `generateContent`). Do NOT pass Firebase Storage public URLs to `generateContent` — the Gemini API does not fetch them. The Files API has a 48-hour default retention; the app's cleanup job runs hourly and removes uploads from saved comparisons (re-uploading on next access if needed).
- **Token counting:** before every comparison call, run the SDK's token-counting helper on the combined input. If combined > 950,000 tokens, switch to chunked-by-section mode and tell the user via the progress feed.
- **Verification pass:** after the comparison call returns, the server runs a character-by-character string match between each verbatim monetary figure and the line of text extracted from the cited page. Matches → `verification_status: verified_character_by_character`. OCR-sourced anchors → `verified_with_ocr_caveat`. Mismatches → `unverified_flag_for_review`, and the item shows a visible badge in the UI.
- **Database:** Firestore (auto-provisioned by AI Studio Build). Show the seed comparison on first launch.
- **Auth:** Firebase Auth — Google sign-in by default; Apple sign-in next to it; magic-link email for collaboration. Apple sign-in and magic-link email require user-configured setup (Apple Developer account + service-id config for Apple; sender domain authorisation for magic-link).
- **Storage:** Firebase Storage for the uploaded PDFs and page-thumbnail cache. Pre-signed URLs only. **Storage is not auto-provisioned by AI Studio Build today — enable it in Firebase console and wire the bucket name into the AIS Build project before first upload.**
- **Mobile-first.** Verify layouts at 375 px (iPhone SE), 768 px (iPad), 1024 px, 1440 px+. On mobile, the diff item detail panel becomes a full-screen sheet; the side-by-side PDF viewer becomes top-and-bottom stacked.
- 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 diff overview and the saved-comparisons grid.
- Optimistic UI on writes (saving notes, copying citations); reconcile on response.
## 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.
- All images have meaningful `alt` text. Page thumbnails have `alt` describing the artefact ("page 217 of FY2026 budget, showing the Police Department appropriations table, with the Overtime Appropriation line highlighted").
- Form fields have associated `