# roamgap — full documentation snapshot

> Generated at 2026-07-19T19:52:37.155Z. Source: https://roamgap.ike.work/llms-full.txt

This file concatenates every durable documentation page in source order. 
It exists so AI agents can stuff the entire project context into a single 
request without making N round trips. For navigation, see `/llms.txt`.

---

# Project specification

_Source: `specs/PROJECT.md`_

# roamgap — Project Specification

> **Status:** v1.0 specification, May 2026. Source of truth for product behaviour. If code disagrees with this document, the code is wrong unless this document is updated first (see [SPEC_FIRST.md](./SPEC_FIRST.md)).

## 1. One-line description

roamgap is a **smart holiday optimizer**. Given a country and a PTO budget, it returns the calendar trips that maximise continuous time off per PTO day spent.

## 2. Core insight

A Tuesday public holiday combined with the surrounding weekends can give a 5-day break for just 2 days of PTO. The optimizer surfaces these opportunities automatically, ranked by efficiency.

## 3. Primary metric — Efficiency Score

```
Efficiency Score = Total days in trip ÷ PTO days required
```

A 9-day trip (two weekends + a mid-week public holiday) needing 4 PTO days scores `9 ÷ 4 = 2.25`. Higher is better. See [ALGORITHM.md](./ALGORITHM.md) for the exact derivation, edge cases, and worked examples.

## 4. User flow

The planner is **one free-tier page, `/year`** — **whole-year** planning ("plan my whole year"). A single-break finder once shipped as a standalone `/trip` page (Phase 1), was briefly folded into `/year` as a `view=trip` mode ([ADR-007](../docs/DECISIONS.md)), and was then **removed from the UI** ([ADR-008](../docs/DECISIONS.md)) — `/year` is whole-year only; the `POST /api/trip` backend endpoint remains. See §4.2. The other primary tool, the read-only **calendar** (§4.4), stays its own path.

### 4.1 Whole-year planner (primary, default mode)

| Step | Action |
| ---- | ------ |
| 1 | Pick country. Locale-detected default. |
| 2 | Enter **annual PTO budget** for the year (e.g. 30 days). |
| 3 | Enter **max trip length** — the longest single break the user can take (e.g. 14 days). |
| 4 | The plan shape (length, budget mode, trip style) lives in **Advanced settings**, collapsed by default, alongside travel window, year, region, excluded weekdays, blocked dates, calendars ([COMPONENTS.md § FilterPanel](./COMPONENTS.md)). The smart defaults stand in for most visitors (the one-tap Quick-start presets were removed in the §4.1.3 declutter). |
| 5 | See the **best plan immediately** (the *Longest breaks* default, or the saved preference). The five alternative year-plan strategies are a **collapsed "Compare strategies"** disclosure (§4.1.3). Each plan is a set of non-overlapping trips that fit the budget. |
| 6 | Each trip is a calendar strip; share a URL with the plan ID + parameters encoded. |

This is the headline UX. It matches how users actually think about PTO: "I have N days for the year, plan it for me." For a signed-in user the hero inputs (country, leave, max trip length) **autoload** from the saved availability profile (§4.1.2, [AVAILABILITY_PROFILE.md §5.2](./AVAILABILITY_PROFILE.md)). The form's structure is governed by §4.1.2.

### 4.1.1 First-run landing (onboarding)

The home page (`/year` with no input) is the conversion surface — for most visitors it is the *whole* product they ever see. The first cut presented a thin eyebrow + the bare headline "Plan your year off." above a pre-filled form. First external testers bounced off it: **"too complicated"**, low conversion (tester-feedback wave, TF-4). A visitor who has never heard the core insight (§2) cannot tell, from a cold form, what submitting will get them. The landing now **shows the value before asking for any input**, in three parts, top to bottom:

1. **Value-proposition hero.** The headline leads with the *benefit*, not the mechanism — the core insight (§2) in one line ("Turn public holidays into more time off."), with a one-sentence lead that names what a result is (Trips, §3-canonical), that the results are ranked, and that it is free with no sign-up. It replaces the mechanism-first "Plan your year off." Tone: VOICE.md §2 "Marketing — landing" (confident, no superlatives).

2. **Inline "how it works" — three steps.** A compact three-step strip on the page itself, so the visitor understands the flow without navigating away to [`/about/how-it-works`](#43-how-it-works-concept-explainer) (which stays the full explainer, linked from the strip): (1) tell us your country and leave; (2) we line up public holidays and weekends; (3) get five ready-made plans. Each step is a short title + one line. This is a *paraphrase* of §4.3 / [docs/GLOSSARY.md](../docs/GLOSSARY.md) for the first-run context; those remain the source of truth.

3. **Live demo results — a worked example, no cold form.** Beneath the form, the first-run page renders an **actual example plan** through the real result components (the same `TripCard` + `CalendarStrip` the optimizer outputs), so a visitor sees the payoff — *"8 days of leave → 18 days off"* — before filling anything in. The form is therefore no longer a cold form sitting above empty space; it is bracketed by *why* (hero), *how* (steps), and *what you get* (the example). The example is **explicitly labelled an example** (eyebrow "Example · {country} {year}", a one-line "a sample plan — enter your country and leave days above to build your own") — never passed off as the visitor's own data (the §12 honesty rule, VOICE.md §1).

**The demo is a curated static example, not a live backend call — deliberately.** The home route is dynamic (it reads URL parameters for the real flow), so it cannot be statically cached the way the per-country landing pages are ([SEO.md](./SEO.md)); fetching a live plan on every cold landing would couple the single most-visited page to backend latency and uptime, and make the onboarding non-deterministic to test. Instead the example is a **recorded optimizer output** — real holidays on their real weekdays for one country/year — held as a fixture and rendered read-only. This keeps the canonical home fast and resilient, keeps the algorithm backend-only (the fixture is *output*, never a client recomputation — [root CLAUDE.md §3](../../../CLAUDE.md)), and lets the onboarding be unit-tested against exact content. The fixture carries a "refresh annually" note so its year does not silently age. The demo renders **only** in the no-input state; once the visitor submits (the page has input), the real `ResultsClient` plans replace it.

The leave term in all onboarding copy is the per-market catalog term (VOICE.md §3.2, [I18N.md §14](./I18N.md)) — "leave" at en-KE, "PTO" at en-US — never hardcoded. Components: [COMPONENTS.md § PlannerHero / HowItWorksSteps / PlannerDemo](./COMPONENTS.md). Copy contract: [VOICE.md §4.19](./VOICE.md).

**Shipped (2026-06-21, TF-4).** `PlannerHero` + `HowItWorksSteps` + `PlannerDemo` compose the `/year` no-input branch ([src/app/year/page.tsx](../src/app/year/page.tsx)); the demo fixture is [src/lib/planner/demo.ts](../src/lib/planner/demo.ts) (Kenya 2026 — two bridge trips, 18 days off / 8 leave). Onboarding copy is the `planner.onboarding.*` catalog namespace. Drawn `Pattern · Home — first run (TF-4)` Light `691:1306` / Dark `695:1423` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). **Recorded reuse:** the Figma demo reuses the already-drawn German `TripCard` instances (so its labels read Germany / 11 days / 3 leave) rather than re-drawing two 9-day strips — the live fixture is the Kenya example above; the visual contract (Example-labelled read-only cards) is identical. **Tracked follow-up:** syncing the legacy Home frames' (`8:183` / `108:179`) hero text to the new copy (an absolute-layout reflow).

### 4.1.2 Planner form & advanced settings (UX-11 revamp)

External testers found the planner form **option-heavy** — the hero's three inputs sat above a Budget-mode toggle and an **Advanced settings** drawer that stacked Year, Region, a "Long trips" stepper *plus a two-line paragraph*, a Filters card (12 month-chips + 7 weekday-chips + blocked dates), and a Calendars card. Too much to read on the highest-traffic conversion surface (ROADMAP.md UX-11). The revamp's principle is **presets + progressive disclosure + smart defaults**: lead with one-tap good configs, show a live preview, and demote the raw knobs into a tidy drawer most visitors never open. No option is *removed* — each stays reachable — but few are *visible by default*.

**The new top-of-form (what a visitor sees first):**

1. **Hero inputs** — Country, annual leave, max trip length (unchanged geometry; **max trip length defaults to one week** — the 1w/2w/3w preset chips were removed, [ADR-008](../docs/DECISIONS.md)). These **autoload** from the saved availability profile when signed in ([AVAILABILITY_PROFILE.md §5.2](./AVAILABILITY_PROFILE.md)) — a saved `maxTripLength` overrides the one-week default.
2. **Plan preview (`PlanSummary`)** — a one-line, live, plain-language summary of what the current inputs will produce, just above **Find trips**, so the defaults feel trustworthy and Advanced feels optional. (The **Quick start preset row was removed** in the §4.1.3 declutter — the plan shape lives in Advanced settings and the smart defaults carry most visitors.)

**Advanced settings (collapsed by default), now light.** With plan shape handled by presets and the travel window pulled out as its own progressive control, the drawer holds only the genuinely-occasional knobs, grouped:

- **Trip style (`TripStyleSlider`)** — replaces the "Long trips" stepper *and its paragraph* with one slider, "Fewer, longer trips ↔ More, shorter trips" (maps to `longTripCount`).
- **Travel window (`TravelWindowPicker`)** — replaces the 12 always-on month-chips with "Any time" by default, expanding to quarter quick-picks + the month grid on demand. Lives in the (now lighter) `FilterPanel`.
- **Year**, **Region** (when the country has subdivisions), **Exclude weekdays**, **Blocked dates**, and **Calendars** (`CalendarOverlayPicker`) — unchanged in behaviour, regrouped for scannability.

**Scope note (all editors).** The profile-button removal + autoload reach every editor (`/year`, `/overlap`, groups). The *advanced-settings* redesign (presets, trip-style, travel window, summary) applies to the `/year` planner form — that is where the option overload lives; the `/overlap` and group editors have no "Advanced settings" drawer (their controls are per-person availability rows) and are touched only to drop the old save/load cluster. Extracting the shared workweek + also-observes sub-controls from those editors into reusable pickers (so the settings `AvailabilityProfileEditor` need not re-implement them) is a tracked follow-up — see ROADMAP.md UX-11.

Copy: [VOICE.md §4.20](./VOICE.md). Components: [COMPONENTS.md](./COMPONENTS.md) (`PlannerPresets`, `TripStyleSlider`, `TravelWindowPicker`, `PlanSummary`, `AvailabilityProfileEditor`).

**Shipped (2026-06-21, UX-11).** `/year` leads with the autoloaded hero inputs → `PlannerPresets` → `PlanSummary` + Find trips, with the lighter Advanced drawer (`TripStyleSlider`, `BudgetModeToggle` demoted here, `TravelWindowPicker` inside `FilterPanel`, Year/Region/weekdays/blocked/calendars). `/trip` gets the `PlanSummary` + travel window at its scale. The profile autoloads via `usePlannerDefaults`; it is edited only in `/account/settings` (`AvailabilityProfileEditor`). Drawn `Pattern · Planner revamp (UX-11)` Light `698:1541` / Dark `705:1540` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). Deviations recorded in [ROADMAP.md → UX-11](../../../ROADMAP.md). **The `PlannerPresets` Quick-start row was later removed — see §4.1.3.**

### 4.1.3 Results declutter (post-UX-11 · tester feedback)

UX-11 cut the *form's* option overload; testers then flagged the **results surface**
as still too busy. This pass removes the remaining clutter on `/year`, keeping every
capability reachable:

1. **Quick start removed.** The one-tap `PlannerPresets` row (Long weekends / A
   balanced year / One big trip) is gone. It set only the *plan shape*
   (`maxTripLength`, `budgetMode`, `longTripCount`), all of which stay editable in
   the hero (`MaxTripLengthInput`) and **Advanced settings** (`TripStyleSlider`,
   `BudgetModeToggle`); the smart defaults carry the median visitor. The component +
   its test are deleted ([COMPONENTS.md § PlannerPresets](./COMPONENTS.md)).
2. **Strategy comparison collapses by default.** The five-strategy `YearPlanComparison`
   no longer opens as a big `surface/sunken` "Pick a strategy." block above the result.
   The **best plan is shown immediately** (the *Longest breaks* default, or the saved
   `DefaultStrategyPreference`); the comparison lives in a **collapsed "Compare
   strategies" `<details>`** — the same disclosure idiom as Advanced settings — between
   the selected-plan header and its trips ([COMPONENTS.md § YearPlanComparison](./COMPONENTS.md)).
3. **"Lost to weekend" card removed.** The informational callout (*"N
   holidays fell on a weekend…"*) is dropped from the results — it is
   commentary, not an action, and added a heavy card before the plan. (The
   `optimizer.lost.*` copy stays in the catalog, now unused by the UI after the
   single-break finder's removal, [ADR-008](../docs/DECISIONS.md).)
4. **Result-card header decluttered.** `TripCard` drops the prominent coloured
   efficiency `ScoreBadge` from the header — the header is now **date + actions** — and
   surfaces efficiency as a plain footer stat (Total · Leave · **Efficiency**)
   ([COMPONENTS.md § TripCard](./COMPONENTS.md)). On the whole-year results surface (`ResultsClient`).

Copy: [VOICE.md §4.20](./VOICE.md). The open **`/trip`-vs-`/year` consolidation**
question (could `/year` + sorting subsume the single-trip finder?) was first answered by
folding `/trip` into `/year` ([ADR-007](../docs/DECISIONS.md)), then **resolved outright** by
removing the single-break UI and keeping only the whole-year planner ([ADR-008](../docs/DECISIONS.md)) — see §4.2.

> **SHIPPED 2026-06-22.** `PlannerPresets.tsx` + its test deleted; `AnnualPlannerForm`
> drops the row. `ResultsClient` shows the selected plan immediately with the
> collapsed `Compare strategies` `<details>` and no lost-to-weekend card (the
> `lostCount` prop is gone from `ResultsClient` + `year/page.tsx`). `TripCard`'s header
> is date + actions; efficiency is a footer stat `5.00×`. Drawn `Results declutter
> (§4.1.3)` Light `715:1542`/`716:1540`, Dark `717:1540`/`717:1579`
> ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). **Deviation (recorded):** `ScoreBadge`
> is **not** deleted — it is still used by `ItineraryTripCard` (the saved-itinerary
> view, out of this scope); only `TripCard` stopped rendering it.

### 4.2 Whole-year results — sort, layout & selection

`/year` has **one mode — whole-year**. (A single-break `view=trip` mode briefly existed — the folded-in former `/trip`, [ADR-007](../docs/DECISIONS.md) — and was removed in [ADR-008](../docs/DECISIONS.md): one planner is enough. The `POST /api/trip` backend endpoint and the `SortKey` schema are kept; only the UI is gone. `?view=trip` is now an ignored unknown param, [URL_FORMAT.md §6](./URL_FORMAT.md).)

The results surface (`ResultsClient`, [COMPONENTS.md](./COMPONENTS.md)) shows the selected plan's trips with two affordances carried over from the retired single-break view:

- **Sort (`SortToggle`).** A pill group — **efficiency** (most time off per leave day), **longest first** (length), **by date** (chronological) — re-orders the selected plan's trips. The default is **by date**: a year plan is lived in calendar order and `YearPlan.trips` is already start-ascending, so nothing reorders until the visitor asks. This is a **client-side reorder** (the plan's trips are already computed), not a re-query — unlike single-break, where `sort` re-ran the backend ([ALGORITHM.md §4](./ALGORITHM.md) still governs that backend sort for `/api/trip`). Sort is UI state, not a URL param.
- **Two-up grid.** Each `TripCard` is a calendar strip + a stat row; rendered full-width it stranded a wide empty gutter beside the fixed-width calendar. The cards now flow **two per row** from `sm:` up. An **odd final card spans the full width** and switches to a side-by-side (`wide`) layout — calendar left, stats right — so it fills the row rather than re-opening the gutter.

Result cards are **selectable** for itinerary + calendar export — see §4.2.1.

**Session persistence (free tier):** in-browser only. Communicate this explicitly in the UI — never hide it.

### 4.2.1 Result cards — selection, itinerary & calendar export

The whole-year result cards (`TripCard`) are **selectable by clicking the card itself** (item 8). A whole-card click toggles selection, and a selected card — **its whole surface and contents** — goes **transparent** so the chosen set reads at a glance. Clicking a card did nothing before, so the gesture is free; it **replaces the per-card `Select` checkbox** (the checkbox read as primitive). The chosen actions live in **one action bar** (`TripSelectionBar`, [COMPONENTS.md](./COMPONENTS.md)) that appears once at least one card is selected. From the bar the visitor applies one action to the whole selection:

- **Add to itinerary** — pins the selected breaks into the browser-local itinerary draft (the existing free-tier draft, §5; [ITINERARIES.md](../../backend/specs/ITINERARIES.md)).
- **Download `.ics`** — one calendar file (RFC 5545) with a VEVENT per selected break, importable into any calendar app ([CALENDAR_EXPORT.md](./CALENDAR_EXPORT.md)).
- **Add to Google Calendar** — Google's prefilled-event composer. Google's link carries one event, so this action is offered only when **exactly one** break is selected; for several, the `.ics` download is the multi-break path. The bar says so.

This keeps the per-card surface to *date + the calendar strip + the footer stats* (no checkbox — the card **is** the control), and makes "export" a deliberate, selection-scoped choice rather than ambient noise on every row. Selection is **transient UI state** (not persisted) — it drives the action bar only; "Add to itinerary" is what commits to the draft. The same whole-card-click-to-transparent affordance is reused on the group shared-break cards ([GROUPS.md §4.2](./GROUPS.md)), where the action bar instead dispatches **Add to trip** / **Add to poll**.

### 4.2.2 Sticky planner input — returning searchers skip the demo (item 9)

A visitor who has searched before should **not** be shown the first-run example again. The planner persists the **last submitted search** — country / subdivision / year / PTO / max length / filters — in `localStorage` on submit. On a cold visit to `/year` **with no URL params**, a small client shim reads that store and, if a prior search exists, **redirects to the encoded search** (`/year?country=…&pto=…&…`) so the server renders **that visitor's last results**, not the `PlannerDemo` example. A **genuinely first-time** visitor (no stored search) still gets the full TF-4 first-run (`PlannerHero` + `HowItWorksSteps` + `PlannerDemo`, §0). The URL stays the source of truth whenever params are present; the sticky restore only fills the empty-params cold load. Persistence is in-browser only (free-tier session persistence, §4.2) — communicated, never hidden.

> **SHIPPED 2026-06-22.** `TripCard` is now a **whole-card click-to-select** toggle (`role="button"`, `aria-pressed`, `opacity-50` + `ring-accent` when selected) — the `Select` checkbox is gone (§4.2.1, item 8); `ResultsClient` already owned the transient selection + `TripSelectionBar`. Sticky input (§4.2.2, item 9): [`src/lib/planner/sticky.ts`](../src/lib/planner/sticky.ts) (`rememberPlannerSearch`/`readPlannerSearch`), `AnnualPlannerForm` remembers on submit, and [`StickyPlannerRedirect`](../src/components/planner/StickyPlannerRedirect.tsx) redirects the returning searcher from the no-params cold load. **Deviation:** there is **no explicit "reset to demo" affordance** — the form is pre-filled with the last search (editable), and clearing the stored search is via the browser; adding a reset button was out of scope.

> **SHIPPED 2026-06-22 ([ADR-008](../docs/DECISIONS.md)).** `/year` is whole-year only — the single-break `view=trip` UI, `ModeToggle`, the `rg_view` cookie, and `view-mode.ts` are deleted, along with the orphaned single-break components (`OptimizerForm`(+`WithPrefs`), `OptimizeResultsHeader`, `ResultsHeader`, `SelectableTripList`, `TripList`, `MaxTripPresets`). `?view=trip` is an ignored unknown param; `POST /api/trip` + the `SortKey` schema are unchanged. The whole-year results (`ResultsClient`) gain a `SortToggle` (efficiency/length/date, **By date** default) that reorders the selected plan's trips **client-side**, and render `TripCard`s in a **two-up grid** with the odd final card full-width (`TripCard` `wide`). `AnnualPlannerForm` defaults max trip length to **one week (7)**; the 1w/2w/3w presets are gone. `SavedSearchesList` opens old `optimize` searches in the whole-year planner. References swept (json-ld `SearchAction`, the 404, the `year.md` twin, nav, sitemap, llms.txt, and the about/url-format/how-it-works/overlap/calendar surfaces). Drawn `Pattern · /year results — sort + 2-up grid (ADR-008)` Light `728:1540` / Dark `732:1540` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). **907 web Vitest + `next build` green.**
>
> **Deviations (recorded):**
> 1. **Old saved single-break searches migrate to the whole-year planner.** An `optimize`-mode `SavedSearch` now links to `/year?country=…&pto=…&max=7` (the annual budget clamped to ≤60), since there is no single-break view to open; the "Trip" badge is kept as the honest record of what was saved. A single-break query can't be reproduced exactly — the UI is gone.
> 2. **The Figma pattern is schematic.** Card eyebrows + stat labels are drawn in Inter (the code uses `font-mono`), and the calendar is one colour-coded week row per narrow card (two on the wide card) rather than the full week-stacked `CalendarStrip`. The live components are unchanged.
> 3. **`POST /api/trip` kept dormant.** The backend single-break endpoint and its `OptimizeInput`/`SortKey`/URL codec (`src/lib/url/trip.ts`, still tested) remain — a deliberate, cheap option to revive single-break later; removing them would be an out-of-scope backend change.

### 4.3 How it works (concept explainer)

A free, public explainer page at `/about/how-it-works` (Markdown twin `/about/how-it-works.md`) that defines the vocabulary the rest of the UI assumes, so a first-time user understands what every control and label means. It covers, in plain language with examples:

- **Efficiency** — the score (§3): total days off ÷ PTO days spent.
- **Trips** and **bridge days** — what a trip is and the high-leverage single-PTO-day play (§9).
- **Plan strategies** — the five year-plan rules and what each optimises for: `packed-efficiency` (best leverage), `packed-length` (longest breaks), `spread-quarterly`, `spread-even`, and `single-long` (one big trip). See [docs/GLOSSARY.md](../docs/GLOSSARY.md).
- **Budget mode** — `exhaust` (spend the whole budget) vs `selective` (stop when the next trip's efficiency drops below the floor).
- **Regions** — national vs ISO 3166-2 subdivision, and why regional accuracy is free (§5).
- **Holidays** — weekend-lost holidays and substitute holidays (§8).

It is a human-friendly *rendering* of [docs/GLOSSARY.md](../docs/GLOSSARY.md), §3, §5, §8, and §9 — those remain the source of truth; the page paraphrases, it does not redefine a term. Linked from the global `Footer`. Reuses the existing doc-page article pattern (no new component). Per [§12](#12-non-negotiables) it ships with a Markdown twin, a sitemap entry, an `llms.txt` link, and an `Article` JSON-LD block.

### 4.4 Calendar (reference view)

A free, public **calendar** at `/calendar` (Markdown twin `/calendar.md`) — the third primary tool alongside the planner and the optimizer, surfaced next to them in the top nav. It answers a question the other two modes don't: *"What does this country's year actually look like?"* No PTO budget, no optimization — just the year, laid out so a visitor can see where the weekends and public holidays fall before they plan around them.

| Step | Action |
| ---- | ------ |
| 1 | Pick country (and optional region). For a **signed-in** user the country defaults to the one saved in their account settings (`GET /me`, [USERS.md §3.6](../docs/AI_AGENTS.md)) — visiting `/calendar` with no `country` lands them directly on that country's year. Signed-out, or with no saved country, falls back to the picker (locale-detected default), same `CountryPicker` as the other modes. |
| 2 | Pick a year. Prev/next-year controls; defaults to the current year. |
| 3 | See the **whole year** — twelve months tiled — with **weekends** and **public holidays** rendered in the same day theme the optimizer uses (`day-weekend` "W", `day-holiday` "H"). Regular workdays are plain. Holidays carry their name on hover / in the accessible label. |

**Settings adaptation.** A signed-in user's saved `country` (and `subdivision`) is the default for the bare `/calendar` URL: the page resolves the profile client-side (the user Bearer is in-memory only — [AUTH](../docs/AI_AGENTS.md)) and canonicalises to `/calendar?country=…`. An explicit `country` in the URL always wins, so the user can still view any country; only the parameter-less entry adapts. Anonymous users and signed-in users with no saved country see the picker.

**Responsive contract.** On a wide screen the entire year renders at once (months tiled in a grid). On a narrow screen, where twelve months can't fit legibly, the view collapses to **one month at a time** with month and year navigation — the visitor slices in and out of months and years rather than scrolling a wall of grids. The full twelve-month markup is always present server-side (no-JS and the Markdown twin both show the complete year); the single-month paging is a client-side enhancement layered on small viewports.

**Estimated holidays.** A forward-looking holiday whose date is not yet authoritative — a lunar/observed date in a future year, or (behind `RG_HOLIDAY_PROJECTION_ENABLED`) one carried forward from last year ([roamgap-backend HOLIDAY_PROJECTION.md](../../backend/specs/HOLIDAY_PROJECTION.md)) — is shown in the same **estimated** theme the optimizer's `CalendarStrip` uses: the holiday colour as a **dashed outline** instead of a solid fill, with "Estimated" in the legend and "(estimated)" appended to the day's accessible name ([COMPONENTS.md § MonthGrid](./COMPONENTS.md), [VOICE.md §3](./VOICE.md)). The honesty rule holds — we mark it estimated, never predicted.

**Scope.** This is a *reference* surface, not an optimizer surface. It honours only the identity parameters (`country`, `sub`, `year` — [URL_FORMAT.md §3.1](./URL_FORMAT.md)); it ignores `pto`, filters, and the rest. Holiday inclusion follows the same national-vs-regional rule as the optimizer ([domain.ts](./domain.ts) `OptimizeInput.subdivision`, [ALGORITHM.md §5.8](./ALGORITHM.md)): a holiday shows if it is national (no subdivisions) **or** applies to the selected region. The weekend pattern defaults to Mon–Fri working days (Sat/Sun weekend); a future change can wire the shared `wd` workweek param through here. Per [§12](#12-non-negotiables) the page ships with a Markdown twin, a sitemap entry, an `llms.txt` link, and JSON-LD.

### 4.5 About / documentation surface

The `/about/*` namespace holds the static documentation pages — the public, durable explanation of the product, its contract, and its design. These are the SEO/AI-discoverability surface the growth strategy depends on ([§11](#11-technical-stack)), so **every URL advertised in [`sitemap.ts`](../src/app/sitemap.ts), [`public/llms.txt`](../public/llms.txt), or the JSON-LD in [`src/lib/seo/json-ld.ts`](../src/lib/seo/json-ld.ts) must resolve 200** — a dangling entry advertises a 404 to crawlers and is a bug. URL grammar for this namespace is out of scope for [URL_FORMAT.md §2](./URL_FORMAT.md) (which governs the optimizer/planner/calendar query grammar only); this section is its home.

Two shapes, both honouring the [§12](#12-non-negotiables) "every URL has a Markdown twin" rule:

1. **Explainer pages** — a human HTML article (the existing doc-page article pattern, no new component) **plus** a `.md` twin, listed in both `sitemap.ts` (with its twin) and `llms.txt`:

   | Page | Markdown twin | Renders | Source of truth |
   | ---- | ------------- | ------- | --------------- |
   | `/about` | `/about.md` | One-page product overview + "where to go next" hub | this document |
   | `/about/how-it-works` | `/about/how-it-works.md` | Concept explainer (§4.3) | [docs/GLOSSARY.md](../docs/GLOSSARY.md), §3/§5/§8/§9 |
   | `/about/url-format` | `/about/url-format.md` | How to construct optimizer URLs by hand | [URL_FORMAT.md](./URL_FORMAT.md) |
   | `/about/algorithm` | `/about/algorithm.md` | The efficiency formula + worked examples | [ALGORITHM.md](./ALGORITHM.md) |
   | `/about/spec` | `/about/spec.md` | The product brief in plain Markdown | this document (PROJECT.md) |
   | `/about/license` | `/about/license.md` | Licence under which the optimizer output + holiday data are offered | [§5](#5-tier-matrix) (free, open) |
   | `/about/stats` | `/about/stats.md` | Public transparency aggregates ([TELEMETRY.md §10](./TELEMETRY.md)) | live PostHog |

2. **Design-contract Markdown twins** — the design system is specified in `specs/*.md`; those specs are surfaced to AI consumers as **Markdown-only** twins (no separate HTML article — the human-facing form is the Figma library, [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). Listed in `llms.txt` under "Design contract" as `.md` URLs; **not** in `sitemap.ts` (they are AI-discoverability first-tier, not indexable marketing pages). Each `.md` route serves the underlying spec file verbatim (read at request time, the [`/llms-full.txt`](../src/app/llms-full.txt/route.ts) idiom) so the twin can never drift from the spec:

   | URL | Serves |
   | --- | ------ |
   | `/about/design-system.md` | [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) |
   | `/about/components.md` | [COMPONENTS.md](./COMPONENTS.md) |
   | `/about/figma-reference.md` | [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md) |
   | `/about/accessibility.md` | [ACCESSIBILITY.md](./ACCESSIBILITY.md) |
   | `/about/voice.md` | [VOICE.md](./VOICE.md) |
   | `/about/i18n.md` | [I18N.md](./I18N.md) |

   **No Figma change — non-visual.** A Markdown twin that streams an existing spec adds no visual surface; the design-contract docs' visual realisation already lives in the Figma library. Adding or removing a row in either table is a `spec:` change here first, then `sitemap.ts` / `llms.txt` are updated to match in the same change (they must never advertise a URL absent from these tables, nor omit one present here that is durable).

## 5. Tier matrix

### Free (no account)

The free tier must feel complete for everyday use. Gating the core optimizer would destroy trust and kill the share loop. **Do not artificially throttle results** (e.g. "1 trip free, 4 behind paywall").

| Capability | Free |
| ---------- | ---- |
| Holiday data | Current year, live from public API |
| **Annual planner** | 5 alternative year plans, both budget modes, full filters |
| **Multi-country overlap** (`/overlap`) | Find shared breaks for 2–6 people (or one dual-calendar person), each with their own country + leave — **free & open, no account** ([MULTI_COUNTRY.md](./MULTI_COUNTRY.md)) |
| Single-break mode | Top 5 breaks ranked by efficiency, length, or date-in-year |
| Filters | Day-of-week, month, blocked ranges, min/max trip length |
| Region | National **and** ISO 3166-2 sub-regional, where the backend has data |
| Sharing | URL with encoded parameters |
| Persistence | Browser session only |
| **Itineraries (draft)** | Combine selected trips from any search, detect overlapping days, join overlapping/adjacent trips — kept in this tab |
| History | Current year only |

> **Why regional is free.** Country alone underspecifies the holiday set — Bavaria's calendar isn't Berlin's, California's isn't Alabama's. Paywalling the difference would ship inaccurate basic data to free users and kill the trust loop the optimizer depends on. Paid differentiation comes from history, AI destinations, and the **Groups** social/organiser layer (below) — not from baseline correctness, and **not** from multi-country overlap, which is **free** (`/overlap` is a free extension of `/year` — [MULTI_COUNTRY.md §3](./MULTI_COUNTRY.md)). The subdivision field is wired through the optimizer input (`subdivision` on [`specs/domain.ts`](./domain.ts) `OptimizeInput` / `AnnualOptimizeInput`).

### Free (account)

Signing in is **free**. Everything roamgap does today is free — an account only adds cross-device persistence and notifications, at no cost. (Sign-in-only: Google auto-provisions, email is sign-in.)

| Capability | Free (account) |
| ---------- | -------------- |
| Saved searches | Cross-device persistence — **autosaved** (every search a signed-in user runs is persisted, deduped; a rolling window of the most recent, no manual "Save" button). See [SAVED_SEARCHES.md](../../backend/specs/SAVED_SEARCHES.md) §4. |
| Saved itineraries (up to 2) | Cross-device persistence of combined itineraries (overlap detection + join); a free account keeps up to **2** named itineraries — roamgap Plus lifts the count cap ([PAYMENTS.md §20](./PAYMENTS.md)). Drafts (above) stay unlimited and browser-local. |
| Calendar export | Google Calendar / .ics |
| Email alerts | New holiday declared in a saved country |

### Paid — roamgap Plus (Phase 3/4, not yet built)

**No current feature is fully paid-gated; the first quota is the saved-itinerary count.** roamgap Plus (single plan, KES 500/mo) will gate the *future* features below as they ship; the payments core (WiraPay) is built and wired but **gates nothing today** — see [PAYMENTS.md](./PAYMENTS.md). The **first** real per-plan quota is the saved-itinerary count (free keeps **2**; Plus is unlimited) — the *feature* stays free for everyone, only the *count* is tiered. It ships dark behind `RG_PAID_TIER_ENFORCED` and activates with the rest of the gate. The natural upgrade triggers beyond it are historical data, AI destinations, and Group trips. (Multi-country overlap is **not** an upgrade trigger — it is free, §5.)

| Capability (coming) | roamgap Plus |
| ------------------- | ------------ |
| Historical data | 3–5 years of public holiday records per country |
| Predictive alerts | "Last year on this date, a holiday was declared" — informational only |
| AI destinations | Claude-generated recommendations scoped to the calculated trip |
| Unlimited saved itineraries | More than 2 named single-user itineraries (free keeps 2) — the count cap is lifted, not the feature; the first quota to go live |
| Trip reminders | Email reminders as a planned trip approaches + when a public holiday falls near it ([EMAIL_ALERTS.md §4.5](../../backend/specs/EMAIL_ALERTS.md)). The new-holiday-in-a-saved-country alert + transactional emails stay free (§5). |
| Shared itineraries | Collaborative itineraries across multiple people (distinct from the free single-user saved itineraries) |

### 5.1 Future filter ideas — tracked in the ROADMAP

The shipped `Filters` are months, excluded weekdays, and blocked dates ([domain.ts](./domain.ts) `Filters`) — that is the current contract this spec owns. **Proposed future filters** (custom workweek, holiday-type inclusion, minimum-efficiency floor, trip-length bounds, must-span-a-weekend, season quick-picks) are *planning*, not contract, so they live in the ROADMAP, not here: see [ROADMAP.md → Standing items → "Filter expansion — proposed ideas"](../../../ROADMAP.md) for each idea's proposed shape, where it plugs into the optimizer, and its sequencing. Any that ships adds its `Filters` field here in lockstep with the backend mirror ([root CLAUDE.md §2](../../../CLAUDE.md)).

## 6. Multi-country overlap

**Free & open** (§5; [MULTI_COUNTRY.md](./MULTI_COUNTRY.md)) — `/overlap` is `/year` for two or more people, available to anyone with no account. Distributed teams and international couples have no good tool for this. Engine adds countries with independent PTO budgets and finds intersecting trips. Score is **combined PTO days burned across all parties** — lower is better.

UI: side-by-side calendar. Green = free for both. Amber = PTO for one. Red = PTO for both.

**B2B hook:** team-mode shareable link → multiple people add their own country and PTO budget → engine finds the optimal team-wide trip. Strong differentiator vs general travel tools.

## 7. AI destination recommendations

Take the calculated trip (e.g. 14–24 March), the user's home country, and optional budget/style preferences. Pass to Claude API. Return:

- Best destination countries for that **specific** trip (weather, seasonality, cultural events)
- 2–3 towns or cities per destination
- Accommodation **style** recommendations (not specific hotels — avoid affiliate dependency)
- Why this trip is particularly good for each destination

**Cache** per `(home_country, start_date, end_date, budget_band)`. AI is the most expensive layer per-request; this is what justifies the paywall.

**Where it surfaces.** The recommendation is **per Trip**, not per itinerary — keyed by
the same cache tuple above, it attaches to a single date range. Its first host surface
is the saved-itinerary detail page, where each Trip card carries its own "Where to go"
slot ([COMPONENTS.md § Itinerary components](./COMPONENTS.md)); a **single-Trip**
itinerary (a planned future trip) is the canonical case — the user fixes *when*, the AI
suggests *where*. Until Phase 5 the slot is a quiet placeholder behind the dark paid
gate ([PAYMENTS.md §20](./PAYMENTS.md)); the prompt + output schema are owned by
[AI_PROMPTS.md](./AI_PROMPTS.md).

Future: reverse search — pick destination first, optimiser finds best trip. v2.

## 8. Historical data — what is and isn't predictable

| Type | Predictability |
| ---- | -------------- |
| Statutory holidays (Christmas, Easter, national days) | Fully predictable |
| Observed substitutes (Dec 26 when Dec 25 = Sunday) | Predictable by rule |
| Long-weekend bridges | Predictable by convention in many countries |
| Ad-hoc declarations (elections, state funerals, COVID closures) | **Not predictable** |

**UI rule:** alerts based on historical data are informational. Phrase as "Last year, a holiday was declared here" — never "A holiday is expected here." A user booking travel on a predicted ad-hoc holiday that never materialises will have a bad time.

The recorded past holidays are surfaced at `/public-holidays/[c]/history` — the user-facing read of the backend snapshot ledger ([SEO.md §8.8](./SEO.md), [HISTORICAL_HOLIDAYS.md](../../backend/specs/HISTORICAL_HOLIDAYS.md)), under the same informational-never-predictive rule. It ships dark until the ledger is enabled.

## 9. Edge cases (non-exhaustive — see ALGORITHM.md)

- Public holidays falling on a weekend: effectively "lost"; note this to the user.
- Bridge days: e.g. Thursday holiday → Friday taken = 4-day weekend. Flag as high-leverage single-PTO-day plays.
- Non-Mon–Fri work weeks: workweek definition must be per-country (e.g. Fri–Sat weekend in parts of the Middle East).
- Regional holidays: free tier supports ISO 3166-2 subdivision selection wherever the backend has data ([`domain.ts`](./domain.ts) `subdivision` field). Absent subdivision = national-only. The legacy "regional is paid" rule was reversed in §5 — country alone is too coarse to ship as a free-tier default.

## 10. Phased delivery

| Phase | Scope | Status |
| ----- | ----- | ------ |
| 1 — MVP | Single-country optimizer, current year, top 5 trips, filters, shareable URL. No account. | ✅ Complete |
| 2 — Auth + Persistence | Firebase Auth, `SignInForm` + `AccountMenu` ([COMPONENTS.md](./COMPONENTS.md)), DSAR ([PRIVACY.md §13](./PRIVACY.md)), Firestore `users/{uid}`, saved searches (`SavedSearchesList` + backend `/searches`, [SAVED_SEARCHES.md](../../roamgap-backend/specs/SAVED_SEARCHES.md)). | **Wired end-to-end** — sign-in (`/sign-in` + `SessionProvider`), `AccountMenu` mounted (`NavAuthSlot`), `SaveSearchButton` on `/year` (both modes), `/account/{saved,settings,data,delete,restore}`, GDPR gate (`/legal/*` + cookie-consent banner). spec → Figma → test → impl, 370 web Vitest green (backend 531 unit + 100 e2e Jest), `next build` green. **i18n foundation shipped** (`next-intl`, `en-KE`/`en-US` live); only native-speaker-reviewed `fr-FR`/`es-ES` catalogs remain — scaffolded, not a launch blocker ([I18N.md](./I18N.md)). Saved-search payment gate and export-bundling deferred to Phase 3 (no Stripe yet; PRIVACY.md §13.1). |
| **Launch — v1 go-live** | Host both apps (Firebase Hosting/App Hosting + Cloud Run, `europe-west1`), `firestore.rules`, secrets in Secret Manager, CI/CD, CSP + `security.txt`, App Check enforced, **and the SEO/discoverability go-live checklist** (Search Console + Bing verification, sitemap submission, prod canonical origin, OG cards). Free + accounts, **pre-payments**. Specified but not yet provisioned. | See [ROADMAP.md](../../../ROADMAP.md) Milestone 1 |
| 3 — Paid | **WiraPay** (card auto-renew + M-Pesa pass), historical data, AI, Group trips. (Multi-country overlap is **free**, §5/§6.) Single fixed KES price, no trial. Calendar export is **free**; the new-holiday-in-country email alert + transactional emails are **free**, but the trip-linked email reminders (vacation-approaching + holiday-near-trip, [EMAIL_ALERTS.md §4.5](../../backend/specs/EMAIL_ALERTS.md)) are roamgap Plus; regional holidays free per §5. Payments core built but gates nothing until a paid feature ships. See [PAYMENTS.md](./PAYMENTS.md). | — |
| 4 — Multi-country | Overlap engine, team-mode links. | — |
| 5 — AI layer | Claude destinations with caching. | — |
| 6 — B2B | Team / HR dashboard, public API, seat-based pricing. | — |

## 11. Technical stack

| Layer | Choice |
| ----- | ------ |
| Frontend | **Next.js 16 (App Router)**, hosted on Firebase Hosting |
| Backend | **NestJS** on Cloud Functions or Cloud Run |
| DB | **Firestore** — users, saved searches, cached holiday data |
| Auth | **Firebase Authentication** — Google OAuth + email/password |
| Holiday data | **[Nager.Date](https://date.nager.at)** (primary, MIT) → **OpenHolidays** (fallback) |
| AI | **Anthropic Claude** (`claude-sonnet-4-6` or newer) |
| Payments | **Stripe** subscriptions |
| Email | **Resend** via Firebase Extensions |
| Region | `europe-west1` (lowest latency for East African + EU users; no Firebase Africa region exists yet) |

App Check on all backend functions to block free-tier abuse.

**Go-live is a distinct step.** Hosting both apps and taking v1 live (free + accounts, **pre-payments**) sits **between Phase 2 and Phase 3** — see [ROADMAP.md](../../../ROADMAP.md) "Milestone 1 — Production launch (v1)". The stack above is specified but not yet provisioned (no `firebase.json`, backend container, CI/CD, or `firestore.rules` in the repo yet). Because there is **almost no marketing budget, organic search + AI search is the growth strategy**: the launch milestone includes a discoverability/SEO checklist that operationalizes the AI-discoverability surface this app already ships (`llms.txt`, Markdown twins, JSON-LD, sitemap; [DECISIONS.md](../docs/DECISIONS.md) ADR-001) — Search Console + Bing verification, sitemap submission, a production canonical origin (`NEXT_PUBLIC_SITE_URL`), OG/Twitter cards ([BRANDING.md](./BRANDING.md)), and the programmatic long-tail of per-country `/calendar`·`/year` URLs ([URL_FORMAT.md](./URL_FORMAT.md)). The **keyword strategy, market targeting, and per-page SEO contract** for all of this is specified in [SEO.md](./SEO.md).

## 12. Non-negotiables

- **AI-discoverable.** Every URL has a Markdown twin (`/foo` and `/foo.md`). `llms.txt` and `llms-full.txt` are kept current. See [docs/AI_AGENTS.md](../docs/AI_AGENTS.md).
- **Spec-first.** No code change without a spec change. See [SPEC_FIRST.md](./SPEC_FIRST.md).
- **Test-first.** New algorithmic behaviour ships with failing tests written first. See [docs/TESTING.md](../docs/TESTING.md).
- **Honest about limits.** Free-tier session-only storage and historical-data uncertainty are surfaced in UI, not hidden.
- **No PCI in our DB.** Stripe handles cards. Never store card details in Firestore.
- **GDPR-ready.** Privacy policy + cookie consent live before any EU traffic.

## 13. Open product questions (track in DECISIONS.md once resolved)

1. Does the algorithm assume bridge-day uptake automatically, or surface bridges as suggestions?
2. ~~Sort default: "best value" (highest ratio) or "longest break" (most total days)? Both available?~~ — **Resolved (§4.2):** efficiency is the default; **all three** of efficiency, length, and date-in-year are available in single-break mode.
3. Team-mode pricing: per-seat or flat tier?
4. ~~Trial length for paid~~ — **Resolved (Phase 3):** no trial; free tier + paywall. Provider **WiraPay** (the ike.work payment service), single fixed KES price. See [PAYMENTS.md §2](./PAYMENTS.md).
5. **Resolved.** URL shape: one planner path `/year`, whole-year only. (A `view=trip` single-break mode was folded in, [ADR-007](../docs/DECISIONS.md), then removed, [ADR-008](../docs/DECISIONS.md).) Full grammar in [URL_FORMAT.md](./URL_FORMAT.md).
6. When the annual planner's 5 plans share a lot of identical trips, do we hide the duplication or show it explicitly? Defer to UI design; flag in [COMPONENTS.md](./COMPONENTS.md).


---

# Algorithm specification

_Source: `specs/ALGORITHM.md`_

# Algorithm Specification — Trip Optimizer

> The trip optimizer is the product's primary differentiator. Every other feature is built on it. **This document is the contract.** The implementation lives in [`roamgap-backend/src/optimizer/trip-optimizer.ts`](../../roamgap-backend/src/optimizer/trip-optimizer.ts) (and the annual planner in `roamgap-backend/src/planner/annual-planner.ts`) — see [docs/DECISIONS.md](../docs/DECISIONS.md) ADR-005. Every example below must be expressed as a test in `roamgap-backend/src/optimizer/trip-optimizer.spec.ts` or `roamgap-backend/src/planner/annual-planner.spec.ts`.

## 1. Inputs

```ts
type OptimizeInput = {
  country: ISO3166Alpha2;          // e.g. "KE", "DE", "US"
  subdivision?: ISO3166Dash2;      // e.g. "DE-BY" — refines the holiday set (§5.8)
  year: number;                    // e.g. 2026
  ptoBudget: number;               // integer >= 0
  workweek?: Workweek;             // default: country's workweek
  filters?: {
    months?: number[];             // 1-12; default = all
    excludeWeekdays?: Weekday[];   // ISO weekday numbers 1-7; default = none
    blockedRanges?: DateRange[];   // user is unavailable here
  };
  sort?: "efficiency" | "length" | "date";  // default = "efficiency"
  topN?: number;                   // default = 5
};

type Workweek = {
  workdays: Weekday[];             // e.g. [1,2,3,4,5] for Mon–Fri
};

type DateRange = { start: ISODate; end: ISODate };
```

`Weekday` follows ISO 8601: 1 = Monday, 7 = Sunday. `ISODate` is `YYYY-MM-DD`.

## 2. Output

```ts
type OptimizeOutput = {
  trips: Trip[];               // length <= topN, sorted per `sort`
  meta: {
    totalCandidates: number;       // how many were considered before pruning
    holidaysLostToWeekend: Holiday[];
    bridgeOpportunities: BridgeDay[];
    overBudget?: boolean;          // §5.9: a returned trip exceeds budget via required days. Absent = within budget
  };
};

type Trip = {
  start: ISODate;
  end: ISODate;
  totalDays: number;
  ptoDays: number;
  efficiency: number;              // totalDays / max(ptoDays, 1)
  composition: DayComposition[];   // day-by-day breakdown
  overBudget?: boolean;            // §5.9: ptoDays exceeds budget because of required days off. Absent = within budget
};

type DayComposition = {
  date: ISODate;
  kind: "weekend" | "holiday" | "pto" | "blocked";
  holidayName?: string;
};
```

## 3. Score formula

```
efficiency = totalDays / max(ptoDays, 1)
```

We clamp the divisor to `1` so a fully-free trip (e.g. holiday-only stretch) doesn't divide by zero. A 3-day weekend with no PTO scores `3 / 1 = 3.0`.

## 4. Trip generation

Brute force is acceptable; a year has at most 365 candidate starts and ~30 reasonable lengths per start, so worst case ~11k candidates.

```
for each start in [Jan 1 .. Dec 31]:
  for each length in [3 .. 30]:
    trip = (start, start + length - 1)
    if trip crosses year boundary: skip
    if trip intersects a blockedRange: skip
    compute composition, ptoDays, efficiency
    if ptoDays > ptoBudget: skip
    if filters.months set and any day in trip outside allowed months: skip
    if filters.excludeWeekdays intersects trip's working days: skip
    record trip
```

After generation, **deduplicate overlapping trips that share a peak**. If two candidates overlap and one dominates the other on both dimensions (length and efficiency), drop the dominated one. Otherwise keep both as alternatives.

Sort the surviving Pareto front by the `sort` parameter, then return the top `topN`. The sort happens **before** the slice, so a different `sort` can surface a different set of trips (not just a reordering of the same set):

| `sort` | Primary key | Answers |
| ------ | ----------- | ------- |
| `"efficiency"` (default) | `efficiency` descending | "Most time off per leave day." |
| `"length"` | `totalDays` descending | "My longest possible breaks." |
| `"date"` | `start` ascending (date-in-year) | "My best breaks, in the order they happen." |

All three break ties with §5.7 (`start` asc, then `end` asc). For `"date"`, `start` is already the primary key, so the front is simply ordered chronologically and the top `topN` are the **earliest** non-dominated breaks of the year — the next good breaks coming up, in order. Because the front is the *good* (non-dominated) trips, the chronological slice is still a slice of quality breaks, not arbitrary early candidates.

## 5. Edge cases — contract

These are normative. Each gets a test in `roamgap-backend/src/optimizer/trip-optimizer.spec.ts` (single-trip cases) or `roamgap-backend/src/planner/annual-planner.spec.ts` (annual-planner cases per §11).

### 5.1 Holiday on a weekend
A public holiday falling on a Saturday or Sunday does **not** reduce PTO required. It is recorded in `meta.holidaysLostToWeekend` so the UI can surface it — **unless a substitute gives the day back**, in which case it is *not* counted as lost. A substitute may be **provider-supplied** (the upstream returns the observed weekday instead of the weekend date) or **synthesized** ([roamgap-backend `HOLIDAY_SUBSTITUTION.md`](../../backend/specs/HOLIDAY_SUBSTITUTION.md): a substitute day carries its source holiday's name and `isSubstitute: true`). So a weekend holiday is "lost" only when **no** substitute for it exists.

### 5.2 Bridge days
A bridge day is a working day **adjacent to** a holiday + weekend block, where consuming it converts a 3-day stretch into a 4-day stretch.

Concretely: if Thursday is a holiday and Friday is a working day, taking Friday costs 1 PTO for a 4-day Thu–Sun. The algorithm:
- Does **not** automatically include bridge days in a trip unless the trip's start/end requires it.
- **Does** surface them in `meta.bridgeOpportunities` so the UI can suggest them.

### 5.3 Non-Mon–Fri workweeks
The `workweek.workdays` array is the source of truth for what counts as a working day. PTO is consumed only for working days within a trip. Test: a country with Sun–Thu workweek (Fri–Sat weekend) computes PTO correctly.

### 5.4 Year boundary
Trips must not cross `Dec 31 → Jan 1`. A trip starting Dec 28 with length 8 is rejected. Cross-year optimisation is a v2 feature; the API may later accept a date range spanning years.

### 5.5 Empty PTO budget
`ptoBudget = 0` is valid. Only fully-free trips (pure weekend + holiday stretches) qualify.

### 5.6 Filter interactions
- `filters.months = [3]` → every day of every candidate trip must fall in March.
- `filters.excludeWeekdays = [5]` (no Fridays) → no candidate may include a Friday that would consume PTO. A Friday holiday is still fine.
- `filters.blockedRanges` → any candidate intersecting a blocked range is rejected outright.

### 5.7 Deterministic ordering
Given identical inputs, output must be byte-identical. When two trips tie on the sort key, break ties by `start` ascending, then by `end` ascending.

### 5.8 Subdivision (regional holiday) filtering

Public-holiday sets vary below the country level — Bavaria observes holidays the rest of Germany does not. The holiday provider returns the **country-wide superset**; each `Holiday` carries `subdivisions: string[]` (empty = national; `["DE-BY"]` = Bavaria-only). See [`roamgap-backend/specs/HOLIDAY_CLIENT.md` §6](../../roamgap-backend/specs/HOLIDAY_CLIENT.md) for why the cache stores the superset and is **not** keyed by subdivision.

Before trip generation, the holiday set is filtered against `subdivision` (the same rule for `OptimizeInput` and `AnnualOptimizeInput`):

```
include a holiday iff  h.subdivisions.length === 0          // national — always included
                   ||  h.subdivisions.includes(subdivision) // regional — only the chosen region
```

- When `subdivision` is **omitted**, only national holidays apply — a regional-only holiday is *not* a day off ([PROJECT.md §9](./PROJECT.md): "Absent subdivision = national-only").
- The filter runs **upstream of everything else**, so `meta.holidaysLostToWeekend`, `meta.bridgeOpportunities`, and every trip's `composition` all reflect the subdivision-scoped set. A filtered-out regional holiday can never be classified as a `holiday` day or reduce `ptoDays`.
- **`subdivision` must share the `country` prefix.** It must start with `country + "-"` — e.g. `country: "DE"` admits `"DE-BY"` but not `"FR-75"`. A cross-country mismatch is **rejected at input validation**: the `OptimizeInput` / `AnnualOptimizeInput` refinement in [`domain.ts`](./domain.ts) raises `invalid_input` (HTTP 400) before the algorithm runs. The base code *format* is owned by `Iso3166Dash2`; the cross-field prefix check is the object-level refinement on the two optimizer inputs. A well-formed, prefix-matching subdivision that no holiday observes is **not** an error — it simply yields the national set. The web URL decoder drops a mismatched `sub` *before* validation ([URL_FORMAT.md §3.1](./URL_FORMAT.md)), so this rejection is reachable only by direct API callers. Enforcement is pinned by the schema unit tests (`roamgap-backend/src/common/schemas/domain.spec.ts`, `roamgap-web/tests/unit/domain-optimize-input.test.ts`) and the controller e2e tests (`roamgap-backend/test/e2e/{optimizer,planner}.e2e-spec.ts`).

#### Worked example — Germany 2026, regional holiday

Suppose Germany's set includes Fronleichnam (Corpus Christi), Thu 2026-06-04, observed in Bavaria — `{ date: "2026-06-04", subdivisions: ["DE-BY"], … }` — alongside the national holidays.

| `subdivision` | Jun 4 (Thu) is | Thu–Sun break (Jun 4–7) |
| ------------- | -------------- | ----------------------- |
| `"DE-BY"` | a holiday | 4 days, **1 PTO** (Fri bridge) → efficiency 4.00 |
| omitted, or `"DE-BE"` (Berlin doesn't observe it) | an ordinary working day | 4 days, **2 PTO** → efficiency 2.00 |

The national trips (e.g. the Easter block in Example A) are identical in both cases. Each row above has a test in `roamgap-backend/src/optimizer/trip-optimizer.spec.ts` (and the planner equivalent in `annual-planner.spec.ts`).

### 5.9 Required days off (personal `pto` overlays)

A user can overlay a personal calendar in `pto` mode (roamgap-backend `CALENDAR_OVERLAYS.md` §3): a festival day they will **take off, spending leave**. These arrive as an explicit `requiredDaysOff: ReadonlySet<ISODate>` input to the algorithm (assembled by the service from the overlay set, deduped against granted holidays). They are distinct from holidays: a holiday is *free*; a required day off *costs one PTO day*.

The rule is **"always take them, flag overflow"** — required days are never dropped and never silently capped:

- A required day is **forced non-working and counted as one PTO day**, even on a weekend. (A required date that coincides with a granted holiday is already free and is deduped out upstream, so it never reaches the algorithm.)
- **Single-trip mode.** A candidate trip is rejected for budget only when its *optional* PTO — the PTO it spends on non-required working days — exceeds `ptoBudget`. A trip whose total PTO exceeds the budget purely because of required days it spans is **kept**, with `Trip.overBudget = true`.
- **Annual mode.** Required days are reserved against the annual budget first: `requiredPto = |requiredDaysOff|`. Each strategy selects optional trips from the remaining budget `annualPtoBudget − requiredPto` (which may be negative). A plan reports `totalPto = requiredPto + optionalPto` and `YearPlan.overBudget = totalPto > annualPtoBudget`. `budgetUtilisation` stays clamped to `[0, 1]`; overflow is carried by `overBudget`.
- A roll-up `overBudget` flag is also surfaced on `OptimizeMeta` / `AnnualOptimizeMeta` (`true` iff any returned trip/year is over budget). The `overBudget` field is optional on the schema — an omitted flag means "within budget" — but the algorithm always emits a concrete boolean on every trip/year/meta it returns.

When `requiredDaysOff` is empty (the default — no `pto` overlays), every clause above is a no-op and the output is byte-identical to before.

#### Worked example — required days off over budget

Country with no relevant granted holidays, workweek Mon–Fri, **2 PTO budget**, and three `pto` overlay dates on working days: `2026-05-27` (Wed), `2026-06-16` (Tue), `2026-08-25` (Tue) → `requiredDaysOff = { 2026-05-27, 2026-06-16, 2026-08-25 }`, so `requiredPto = 3`.

- Each of those three dates is forced to a `pto` cell wherever a trip spans it and counts 1 PTO.
- Annual planner: `remaining = 2 − 3 = −1`, so no *optional* trip fits; every plan still ships with `totalPto = 3`, `overBudget = true`, `budgetUtilisation = min(1, 3/2) = 1`. `meta.overBudget = true`.
- A `free` overlay on the same date would instead be a zero-PTO holiday cell and never set `overBudget`.

Tests live in `roamgap-backend/src/optimizer/trip-optimizer.spec.ts`, `annual-planner.spec.ts`, and the service specs (which assert `pto` is threaded through from `resolveObservedCalendars`).

## 6. Worked examples

### Example A — Germany 2026, 10 PTO

- Apr 3 (Good Friday) is a holiday.
- Apr 6 (Easter Monday) is a holiday.
- Apr 4–5 is a weekend.

Trip `Apr 3–6` is 4 days, 0 PTO → efficiency `4 / 1 = 4.0`.

Extending to `Apr 2 (Thu) – Apr 6 (Mon)` = 5 days, 1 PTO → `5.0`. Higher score, longer break, wins on both metrics.

Extending further to `Apr 1 (Wed) – Apr 6 (Mon)` = 6 days, 2 PTO → `3.0`. Lower efficiency, longer length. Both available depending on `sort`.

### Example B — Kenya 2026, 5 PTO

Madaraka Day falls on Mon Jun 1, 2026.

Raw candidates that pass the budget check:

| Trip | Days | PTO | Efficiency |
| ------ | ---- | --- | ---------- |
| May 30 (Sat) – Jun 1 (Mon) | 3 | 0 | 3.00 |
| May 29 (Fri) – Jun 1 (Mon) | 4 | 1 | 4.00 |
| May 28 (Thu) – Jun 1 (Mon) | 5 | 2 | 2.50 |
| May 30 (Sat) – Jun 7 (Sun) | 9 | 4 | 2.25 |
| May 29 (Fri) – Jun 7 (Sun) | 10 | 5 | 2.00 |

After Pareto dedupe (§4): `May 30 – Jun 1` is **strictly dominated** by `May 29 – Jun 1` (more days AND higher efficiency, same overlap) and is dropped. The other four are pairwise non-dominated and survive.

Top by efficiency: `May 29 – Jun 1` (4.00). Top by length: `May 29 – Jun 7` (10 days).

### Example C — USA 2026 with Saturday holiday

Independence Day Jul 4, 2026 falls on a Saturday. In the USA the observed substitute is Friday Jul 3.

Implementation note: the API returns Jul 3 as the holiday because Nager.Date already applies the substitute rule. Jul 4 is **not** added separately. `meta.holidaysLostToWeekend` is empty for this case because the substitute exists.

The Pareto winner around this block is `Jul 2 (Thu) – Jul 5 (Sun)` at 4 days / 1 PTO / efficiency 4.00, which dominates the bare `Jul 3 – Jul 5` (3 days, 0 PTO, efficiency 3.00). The 3-day variant is dropped from the Pareto front.

If a country has no substitute rule, the original Saturday entry appears in `holidaysLostToWeekend`.

## 7. Multi-country overlap (Phase 4)

> **Realised in Phase 4 — this section is the seed, not the contract.** The full
> engine contract is now [`roamgap-backend/specs/OVERLAP.md`](../../backend/specs/OVERLAP.md)
> (pure `computeOverlaps`, the `POST /overlap` gated endpoint, ranking, Pareto
> dedupe, perf, caching) and the product/UX spec is [`MULTI_COUNTRY.md`](./MULTI_COUNTRY.md);
> the schemas are `OverlapRequest`/`Overlap`/`OverlapOutput` in [`domain.ts`](./domain.ts).
> The realised contract **refines** the sketch below: each party also carries an
> optional `subdivision`; the result keeps a **per-party `composition`** (not just
> `ptoDays`) plus a year-wide three-state **heatmap**; ranking is `combinedPtoDays`
> ascending then `totalDays` descending; and the feature is **gated** (roamgap Plus).
> The §8 performance budget below stands.

The original seed (kept for history; superseded by OVERLAP.md):

```ts
type OverlapInput = {
  parties: Array<{
    country: ISO3166Alpha2;
    ptoBudget: number;
    workweek?: Workweek;
  }>;
  year: number;
  filters?: OptimizeInput["filters"];
};
```

Output extends `Trip` with `byParty: Record<PartyId, { ptoDays: number }>`. Sort by `sum(ptoDays)` ascending.

## 8. Performance budget

- Single-country optimise (single-trip mode): < 50ms p95 on a 2024 mid-tier laptop.
- Annual planner (5 plans for a full year): < 250ms p95.
- Multi-country (4 parties): < 250ms p95.
- All inputs validated by Zod schema (`specs/domain.ts`) before the algorithm runs.

## 9. References

- Nager.Date public-holiday API: https://date.nager.at/swagger/index.html
- ISO 8601 weekday numbering: Mon=1 … Sun=7
- See [PROJECT.md](./PROJECT.md) §9 for the product-level edge-case discussion this document formalises.

---

## 11. Annual planner mode

> The annual planner is the **primary** user-facing mode — see [PROJECT.md §4.1](./PROJECT.md). It answers a different question than the trip finder: not "what's the best single break for this PTO?" but "given my annual PTO budget, plan my year for me."
>
> The single-trip mode (§§1–6) is preserved and remains useful as a focused secondary view.

### 11.1 Inputs

```ts
type AnnualOptimizeInput = {
  country: ISO3166Alpha2;
  subdivision?: ISO3166Dash2;   // refines the holiday set — see §5.8
  year: number;
  annualPtoBudget: number;      // total PTO available for the year (integer 1-60)
  maxTripLength: number;      // hard cap on calendar days per single trip (3-30)
  minTripLength?: number;     // optional — trips shorter than this are excluded (default 3)
  longTripCount?: number;     // §11.10 — target # of long trips (>=7d) for the "Longest breaks"
                              //          plan only; integer 1-8; default = smartLongTripDefault(budget)
  budgetMode?: "exhaust" | "selective";  // default "exhaust"
  workweek?: Workweek;          // default: country's workweek
  filters?: {
    months?: number[];
    excludeWeekdays?: Weekday[];
    blockedRanges?: DateRange[];
  };
  planCount?: number;           // default 5; max 10
};
```

### 11.2 Output

```ts
type AnnualOptimizeOutput = {
  plans: YearPlan[];            // length = planCount
  meta: {
    totalCandidateTrips: number;
    holidaysLostToWeekend: Holiday[];
    bridgeOpportunities: BridgeDay[];
    budgetMode: "exhaust" | "selective";
    overBudget?: boolean;         // §5.9: any plan exceeds budget via required days off. Absent = within budget
  };
};

type YearPlan = {
  id: PlanStrategy;
  label: string;                // human-readable name from VOICE.md
  description: string;          // one-line explanation
  strategy: PlanStrategy;
  trips: Trip[];            // non-overlapping, sorted by start date
  totalPto: number;             // requiredPto + sum(optional PTO of selected trips)
  totalDaysOff: number;         // sum(trips.totalDays)
  averageEfficiency: number;    // sum(totalDays) / max(sum(ptoDays), 1)
  budgetUtilisation: number;    // min(1, totalPto / annualPtoBudget) — clamped; overflow carried by overBudget
  overBudget?: boolean;         // §5.9: totalPto > annualPtoBudget (required days off). Absent = within budget
};

type PlanStrategy =
  | "packed-efficiency"   // greedy by efficiency, packs trips where the calendar is best
  | "packed-length"       // greedy by total length, prefers longer trips
  | "spread-quarterly"    // at most one trip per calendar quarter
  | "spread-even"         // soft constraint for roughly even spacing across the year
  | "single-long";        // concentrate as much budget as possible on one max-length trip
```

### 11.3 The five plan strategies

Every plan uses the **same input** (country, year, annual budget, max/min trip length, filters, budget mode). Each strategy applies a different selection rule over the same candidate pool.

| Strategy | Selection rule | When it wins |
| -------- | -------------- | ------------ |
| `packed-efficiency` | Greedy: pick the highest-efficiency non-overlapping trip that fits the remaining budget; repeat. | "Get me the most leverage out of my PTO." |
| `packed-length` | Greedy by length, but each trip is capped at an even share of the budget so the plan yields ~`longTripCount` comparable **long (≥7-day)** trips, spread across the year, instead of one or two giant low-efficiency ones. See **§11.10**. | "I want a handful of proper long breaks, spread out — not one monster trip." |
| `spread-quarterly` | Partition the year into 4 quarters. Within each quarter, run `packed-efficiency` on a per-quarter sub-budget = `annualPtoBudget / 4` (rounded). | "I want breaks distributed across the year, not all in spring." |
| `spread-even` | Greedy by efficiency, but reject candidates that fall within `year / planCount` days of an already-selected trip's start. Re-relax the gap if no candidate remains. | "Roughly even distribution without forcing one-per-quarter." |
| `single-long` | Choose the single trip with `totalDays = maxTripLength` (or longest available) that fits the budget. May leave most of the budget unused — surface that in the UI. | "I'd rather have one really long trip than several short ones." |

### 11.4 Budget mode

- **`exhaust`** (default) — keep selecting trips until either the budget is fully spent or no candidate fits. A plan's `budgetUtilisation` should be close to 1.0.
- **`selective`** — stop selecting once the next-best candidate's efficiency falls below `2.0`. Plans may report `budgetUtilisation < 1.0`. The UI surfaces remaining unused PTO so the user can either accept it or switch modes.

The threshold (`2.0`) is normative — encode it as a constant `SELECTIVE_EFFICIENCY_FLOOR` and reference it in code, not as a magic number. Changing it is a spec change.

### 11.5 Non-overlap constraint

Trips within a single plan **must not overlap** — even by a single day. The greedy algorithms maintain a set of selected `(start, end)` ranges and reject any candidate that intersects an existing one.

Across plans, overlap is fine and expected. The same trip can appear in multiple plans.

### 11.6 Filtering and budget interaction

- `minTripLength` is applied during candidate generation. A trip shorter than the min is never considered.
- `maxTripLength` is similarly a hard upper bound.
- `filters.blockedRanges` reject candidate trips that intersect any blocked range (same as single-trip mode §5.6).
- `filters.months` restricts candidate trips to those falling entirely within the listed months. Note: this can conflict with `spread-quarterly` — if no months in Q1 are allowed, the Q1 partition simply contributes 0 trips. The plan still ships, with `budgetUtilisation < 1.0`.

### 11.7 Edge cases (contract — each has a test)

- **11.7.1** `annualPtoBudget = 0` — only fully-free trips (weekend + holiday clusters) are eligible. All five plans may collapse to the same set, or to empty. We still return five plan entries; equal plans get distinct labels and a "no PTO required" note.
- **11.7.2** `minTripLength > maxTripLength` — invalid input; reject with Zod refinement.
- **11.7.3** Budget too small for any candidate — all plans return `trips = []`. Meta includes a hint about the smallest possible trip.
- **11.7.4** A plan can't fill `planCount` non-overlapping trips — it returns fewer; `trips.length` may be less than the user might expect, and the UI is responsible for explaining why.
- **11.7.5** Tie-breaking within a strategy — deterministic per §5.7: tie on the strategy's primary metric → break by `start` ascending → break by `end` ascending.

### 11.8 Worked example — Germany 2027, 25 PTO, max 14, exhaust mode

Assume Germany 2027 statutory holidays (Easter Apr 16/19, Labour May 1 Sat, Ascension May 6 Thu, Whit Mon May 17, Unity Day Oct 3 Sun, Christmas Dec 25/26).

| Strategy | Plan |
| -------- | ---- |
| `packed-efficiency` | Easter Thu–Tue (5d, 1 PTO), Ascension Thu–Sun (4d, 1 PTO), Whit Sat–Tue (4d, 1 PTO), Christmas trip Dec 24–Jan 2 (10d, ~4 PTO), summer block (e.g. Jul 17–Aug 1, 16d but capped at 14 → 14d, 10 PTO). Sums to ~17 PTO. Algorithm continues filling until 25 PTO is spent or no candidate fits. |
| `packed-length` | Long summer trip (14d, 10 PTO), long Christmas (14d, 6 PTO), Easter long (10d, 5 PTO). Fewer, longer trips. |
| `spread-quarterly` | One trip per quarter — e.g. Easter (Q2), summer split (Q3), Christmas (Q4), and a short Q1 trip. |
| `spread-even` | Roughly one trip every ~10 weeks. |
| `single-long` | One 14-day summer trip using 10 PTO. Returns `budgetUtilisation = 0.4`; UI shows "15 PTO unused." |

These numbers are illustrative — the real test fixtures (`roamgap-backend/test/fixtures/holidays/de-2026.ts`) drive the assertions.

### 11.9 Why we ship 5 plans, not one

The "right" plan is a value judgement (more leverage vs. more breaks vs. evenness) that depends on the user. We refuse to pick one for them. Returning five labelled alternatives lets the user see the tradeoffs side-by-side and pick. This matches the [VOICE.md](./VOICE.md) commitment to be quietly confident, not bossy.

If usage data later shows one strategy dominates picks 90%+ of the time, we'll reduce the default count and surface alternatives behind a "More plans" affordance. Phase 6+ decision.

### 11.10 Long-trip count — tuning the "Longest breaks" plan

> **Motivation.** `packed-length` greedily maximises length, so with a large `maxTripLength` and a healthy budget it spends the *entire* budget on **one or two enormous, low-efficiency trips**. Kenya 2027, `maxTripLength: 28`, `annualPtoBudget: 30` produces exactly two trips — 28 days / 18 PTO and 19 days / 12 PTO, both at efficiency ≈ 1.5, both crammed into Jan–Mar. That is rarely what a user who reaches for "Longest breaks" actually wants. `longTripCount` lets them ask for a **target number of comparably-sized long trips**, and the budget is spread across them. It tunes **only** this one strategy; the other four are untouched.

#### 11.10.1 Constants (normative)

```
LONG_TRIP_MIN_DAYS        = 7   // a trip is "long" iff totalDays >= 7
TARGET_PTO_PER_LONG_TRIP  = 8   // basis for the smart default
MAX_LONG_TRIP_COUNT       = 8   // input ceiling AND smart-default clamp
```

Each is a spec constant — reference it in code, not as a magic number. Changing any is a spec change.

#### 11.10.2 Input and resolved count

`longTripCount?: number` — integer in `[1, MAX_LONG_TRIP_COUNT]`, optional (§11.1). When omitted, the strategy computes a **smart default** from the budget.

Let `B` be the budget available for *optional* trips — the annual budget minus the required-days-off reservation (§5.9); with no `pto` overlays this is simply `annualPtoBudget`. Then:

```
N = longTripCount ?? smartLongTripDefault(B)

smartLongTripDefault(B) = clamp(round(B / TARGET_PTO_PER_LONG_TRIP), 1, MAX_LONG_TRIP_COUNT)
```

`smartLongTripDefault` is **normative and duplicated on the web client** (it seeds the form control before the request is sent); both copies must agree, exactly like `SELECTIVE_EFFICIENCY_FLOOR`. For `B = 30`, `N = round(3.75) = 4`.

#### 11.10.3 Selection rule (replaces the old `packed-length` greedy)

```
long = candidates where totalDays >= LONG_TRIP_MIN_DAYS
if long is empty:
    # maxTripLength < 7, or nothing survives the filters — preserve prior behaviour
    return greedy-by-length over ALL candidates   (the pre-feature rule, unchanged)

cap = max(1, ceil(B / N))            # per-trip PTO ceiling — this is what spreads the budget
sort `long` by  totalDays desc, then efficiency desc, then tieBreak (§5.7)

selected = []; remaining = B
for w in long:
    if selected.length == N: break
    if budgetMode == "selective" and w.efficiency < SELECTIVE_EFFICIENCY_FLOOR: continue
    if optionalPto(w) > cap: continue
    if optionalPto(w) > remaining: continue
    if w overlaps any trip in selected: continue
    selected.push(w); remaining -= optionalPto(w)
return selected
```

`optionalPto(w)` is the trip's PTO excluding required days off (§5.9) — the same quantity the other greedy strategies charge against the budget.

**Why a per-trip cap.** `cap = ceil(B / N)` is the entire mechanism: it forbids any single trip from eating more than its even share, so the greedy-by-length pass is forced to leave room for the next `N − 1` trips. Lower `N` → higher cap → fewer, longer trips (`N = 1` approaches `single-long`). Higher `N` → lower cap → more, shorter — but still ≥7-day — trips.

#### 11.10.4 Budget exhaustion

Like `single-long` (§11.3), `packed-length` now prioritises producing `N` comparable long trips over spending the last PTO day. When the calendar can't supply `N` non-overlapping ≥7-day trips within the cap, it returns **fewer** and leaves budget unspent — `budgetUtilisation < 1`, surfaced in the UI. This is ordinary under-spend, **not** `overBudget` (§5.9).

#### 11.10.5 Edge cases (contract — each has a test in `annual-planner.spec.ts`)

- **11.10.5.1** `longTripCount` omitted → `N = smartLongTripDefault(B)`; the plan returns several ≥7-day trips, none of them a budget-hogging monster.
- **11.10.5.2** `longTripCount = 1` → at most one long trip (the longest that fits `B`); a ≥7-day-restricted `single-long`.
- **11.10.5.3** Every returned `packed-length` trip has `totalDays >= LONG_TRIP_MIN_DAYS` — *unless* the fallback fired (no long candidate exists at all).
- **11.10.5.4** `maxTripLength < LONG_TRIP_MIN_DAYS` → no long candidate → fallback → output byte-identical to the pre-feature `packed-length` for that input.
- **11.10.5.5** `longTripCount` larger than the calendar can support → returns fewer than `N` trips (cf. §11.7.4); the UI explains the shortfall.
- **11.10.5.6** Determinism per §5.7 holds (stable metric sort + tie-break).
- **11.10.5.7** `selective` budget mode still applies its `2.0` floor; because long trips are inherently low-efficiency, `selective` + a high `longTripCount` may return very few trips. Documented, not a bug.

Only the `packed-length` plan changes. `packed-efficiency`, `spread-quarterly`, `spread-even`, and `single-long` ignore `longTripCount` entirely, so their output is byte-identical to before for every input.

#### 11.10.6 Worked example — Kenya 2027, `maxTripLength: 28`, `annualPtoBudget: 30`, exhaust

`longTripCount` omitted → `N = smartLongTripDefault(30) = 4`, `cap = ceil(30 / 4) = 8` PTO per trip.

**Before** (old greedy length) — two crammed, low-efficiency trips, both in Jan–Mar:

| Trip | Days | PTO | Eff |
| --- | --- | --- | --- |
| Jan 1 – Jan 19 | 19 | 12 | 1.58 |
| Mar 2 – Mar 29 | 28 | 18 | 1.56 |
| **Total** | **47** | **30** | **1.57** |

**After** (`N = 4`, cap 8) — four ≥7-day trips, each anchored on a holiday, spread across the year, **more** total days off for the same 30 PTO:

| Trip | Anchor | Days | PTO | Eff |
| --- | --- | --- | --- | --- |
| Jan 1 – Jan 13 | New Year | 13 | 8 | 1.63 |
| Mar 20 – Apr 4 | Easter (Good Fri + Easter Mon) | 16 | 8 | 2.00 |
| May 20 – Jun 1 | Madaraka Day | 13 | 8 | 1.63 |
| Oct 14 – Oct 24 | Mashujaa Day | 11 | 6 | 1.83 |
| **Total** | | **53** | **30** | **1.77** |

The knob slides the whole spectrum (Kenya 2027, same budget): `1` → one 28 d / 18 PTO trip (the old monster, `budgetUtilisation 0.60`); `2` → two ~24 d trips; `3` → three ~17 d trips; `6` → six ~9–11 d trips (avg eff 1.97). Every value keeps trips ≥ 7 days and spends ≤ `ceil(30/N)` PTO each.

These numbers are pinned by the `annual-planner.spec.ts` Kenya-2027 fixture tests (§11.8 convention — fixtures drive assertions).


---

# Spec-first workflow

_Source: `specs/SPEC_FIRST.md`_

# Spec-First Development

> roamgap is **spec-driven**, **Figma-realised**, and **test-driven**, in that order. The directory `specs/` is the source of truth for behaviour; the Figma library is the source of truth for the visual realisation of that behaviour; the code under `src/` is the executable form that satisfies both. The order is normative — see [`docs/DECISIONS.md` ADR-006](../docs/DECISIONS.md) for the rationale.

## The three-phase order

```
            ┌─────────────┐      ┌─────────────┐      ┌────────────────────────┐
            │             │      │             │      │   Development          │
   change → │    Spec     │  →   │    Figma    │  →   │   ─────────────────    │  →  ship green
   request  │             │      │             │      │   failing test → impl  │
            └─────────────┘      └─────────────┘      └────────────────────────┘
              text contract        visual contract       executable contract
              specs/*.md           specs/figma/*.png      src/, tests/
              specs/domain.ts      design library file    npm run check
```

Three stages, **never reordered**, never silently skipped. Each stage's output is the input to the next.

### Stage 1 — Spec

Write or update the text contract before anything else. The spec PR is the design review at the *contract* layer — reviewers focus on intent (does this match what we want users / API consumers to experience?), not syntax. Title prefix: `spec:`.

Outputs: edits in `specs/*.md`, `specs/domain.ts`, `specs/openapi.yaml`, and regenerated `specs/schemas/*.json` (`npm run spec:emit`).

### Stage 2 — Figma

Realise the spec's visual contract in the design library, then export screenshots into `specs/figma/` and update [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md). Drawing the component is the design review at the *visual* layer — it surfaces ambiguity the text alone hides (layout, alignment, focus order, empty-state copy). Anything that comes up here loops back into Stage 1 as a small spec edit before code starts.

Title prefix: `design(figma):`. Outputs: refreshed `specs/figma/*.png`, node IDs and section commentary in `FIGMA_REFERENCE.md`.

**No-op exception.** When a spec change has no visual surface — URL grammar, algorithm contract, schema fields, telemetry property allowlist — Stage 2 is a recorded no-op. The spec PR notes "no Figma change — non-visual" in its description, and the development PR follows directly. *Do not* silently skip Stage 2 for a change that has visual surface even if the surface is small; that's how visual drift starts.

### Stage 3 — Development

Test-first inside the implementation PR. Write a failing test that encodes the spec section being enforced; the test title cross-references the section (e.g. `it("does not deduct PTO for a holiday on a weekend (§5.1)", …)`). Then implement the smallest change that makes the test pass. Then `npm run check` green.

Title prefix: `impl(spec:#NNN):`. Outputs: `src/`, `tests/`, green CI.

See [`docs/TESTING.md`](../docs/TESTING.md) for the TDD loop inside Stage 3.

### Keeping the schema snapshot fresh

`schemas/*.json` is a committed snapshot regenerated by `npm run spec:emit` from the Zod `SCHEMA_REGISTRY` in [`domain.ts`](./domain.ts). Nothing forces you to re-run emit after editing `domain.ts`, so the snapshot used to drift silently (on 2026-06-22 five registry types had gone unemitted and ~9 files were missing fields). A guard now prevents that: [`tests/unit/schemas-registry.test.ts`](../tests/unit/schemas-registry.test.ts) rebuilds every schema in-memory — through the same [`scripts/build-schemas.ts`](./scripts/build-schemas.ts) helpers [`emit-schemas.ts`](./scripts/emit-schemas.ts) uses, so the check and the emit can't disagree — and fails `npm run check` (and CI) if any committed file is stale, missing, or orphaned. `index.json`'s `generatedAt` timestamp is the only thing it ignores. **After any `domain.ts` change, run `npm run spec:emit` and commit the regenerated `schemas/`.**

## The rule

If you are about to change behaviour visible to a user or to an API consumer, **change the spec first** (Stage 1). If the change has a visual surface, **draw the Figma next** (Stage 2). Only then write the failing test and the implementation (Stage 3).

If you find yourself wanting to skip Stage 1, the change is probably bigger than you think. If you find yourself wanting to skip Stage 2 for a visual change, you're about to ship drift between code and design — don't.

## What counts as a spec

| File | Scope |
| ---- | ----- |
| [`PROJECT.md`](./PROJECT.md) | Product behaviour — what users can do, what tiers exist, what the UI promises. |
| [`ALGORITHM.md`](./ALGORITHM.md) | The trip-optimizer contract. Inputs, outputs, edge cases, worked examples. |
| [`DESIGN_SYSTEM.md`](./DESIGN_SYSTEM.md) | Visual tokens — colour, type, spacing, radius, elevation, motion. The Figma library mirrors this. |
| [`COMPONENTS.md`](./COMPONENTS.md) | Component inventory — states, props, dependencies. Figma realises one-to-one. |
| [`VOICE.md`](./VOICE.md) | Every user-facing string. Copy in Figma without a VOICE.md change is reverted. |
| [`BRANDING.md`](./BRANDING.md) | Brand assets — favicon, app icons, social-share images, PWA manifest, and their `<head>` wiring. |
| [`ACCESSIBILITY.md`](./ACCESSIBILITY.md) | WCAG 2.2 AA contract — contrast, keyboard, screen-reader patterns. |
| [`I18N.md`](./I18N.md) | Locale support and long-text constraints. |
| [`URL_FORMAT.md`](./URL_FORMAT.md) | Shareable URL grammar — the `/year` parameter contract. |
| [`ERROR_STATES.md`](./ERROR_STATES.md) | Exhaustive empty + error taxonomy with copy and surface (Toast / inline / full-page). |
| [`domain.ts`](./domain.ts) | Zod schemas for every entity that crosses a boundary (HTTP, DB, AI prompt). Runtime-validated. |
| [`openapi.yaml`](./openapi.yaml) | REST API contract. Generated clients and docs consume this. |
| `schemas/*.json` | JSON Schema exports of the Zod schemas. Published for AI agents and external consumers. Regenerated by `npm run spec:emit`. |

## What counts as Figma

The design library at file key `gzgZd6XTnFVwHHbPxKuac2` (Portfolio team) is the single source of truth for visual realisation. Its current state is mirrored screenshot-by-screenshot in [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md) and committed to `specs/figma/`. When Figma and a screenshot disagree, refresh the screenshot — the live file wins.

When Figma and a spec disagree, **the spec wins** (subject to the "demonstrably wrong" exception below). Update Figma to match the spec, then refresh the screenshot.

## Workflow

1. **Stage 1 — Propose spec.** Open a PR that changes only the spec. Title prefix: `spec:`. Reviewers focus on intent, not syntax.
2. **Stage 2 — Realise in Figma.** Once the spec PR merges (or in the same PR, see "collapse" below), update the Figma library and refresh the relevant screenshot under `specs/figma/`. Title prefix: `design(figma):`. The PR description names the spec PR it realises.
3. **Stage 3 — Build.** Open the implementation PR. Title prefix: `impl(spec:#NNN):`. The PR description references the spec PR and the Figma screenshot. Write a failing test first, then implement.

For pre-product-launch development the three stages can collapse into a single PR, but the commits must still be ordered `spec: → design(figma): → test: → impl(spec:#NNN):` (with the `design(figma):` commit omitted if Stage 2 is a recorded no-op). Anyone (human or AI) reading the history must be able to see the order from the log alone.

## What is NOT a spec change

- Refactors that preserve behaviour.
- Performance work that doesn't change outputs.
- Internal type renames that don't cross a boundary.
- Test additions for already-specified behaviour (these are *closing gaps*, not specifying).

These are normal code PRs and they skip Stages 1 and 2. They still need green tests.

## When the spec, Figma, and code disagree

The order of precedence is:

1. **Spec wins**, unless the spec is demonstrably wrong (impossible, contradictory, or contradicts a worked example).
2. **Figma wins over code** when both already exist and the spec is silent — but the silence is itself a bug; open a spec PR to make the contract explicit.
3. **Code never wins.** A code-only change to behaviour visible at a boundary is a contract violation regardless of how clean the diff looks.

If a spec section is demonstrably wrong:

1. Stop coding.
2. Open a spec PR with the correction.
3. If the correction has visual surface, run Stage 2.
4. Resume.

Never silently "fix" the code to a different behaviour than the spec describes.

## AI agents

The same rule applies to AI agents editing the codebase. See [`docs/AI_AGENTS.md`](../docs/AI_AGENTS.md) — the rule appears there too, and the agent rulebook in [`AGENTS.md`](../AGENTS.md) at the repo root opens with it.


---

# Design system

_Source: `specs/DESIGN_SYSTEM.md`_

# Design System Specification

> Visual contract for roamgap. The Figma library and the Tailwind theme are both downstream of this document — when this file changes, both should be re-derived. See [SPEC_FIRST.md](./SPEC_FIRST.md).
>
> **Figma file:** [roamgap — design library](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2). For a screenshot-rich snapshot of the current realized state, see [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md).

## Realized — current state

The colour system is fully realized in Figma as a single `colour` collection with `Light` and `Dark` modes. Below: all 22 semantic tokens with both values side-by-side. The remaining systems (typography, spacing, radius, elevation) live on the same [Tokens page](./figma/tokens-page.png).

![Colour tokens — 7 categories, 22 semantic roles, Light + Dark](./figma/tokens-colour.png)

Every component fill, stroke, and text colour binds to one of these variables. Switching the colour collection mode (Tokens page → right panel → mode dropdown) cascades every component instance to Light or Dark. See [FIGMA_REFERENCE.md § Tokens page](./FIGMA_REFERENCE.md#tokens-page--visual-realization) for the per-category breakdown.

## 1. Principles

1. **Calm over busy.** Calendar planning is thoughtful work. Surfaces are quiet by default; emphasis is earned by the data itself (high efficiency scores), not by background colour.
2. **Time is the canvas.** Days are the primary visual unit. Everything that supports the calendar strip should defer to it.
3. **Trust through legibility.** We show dates, scores, and tradeoffs. If a user can't read a number cleanly, they don't trust it. Type contrast and rhythm matter more than illustration.
4. **One accent.** A single warm accent carries brand recognition. We don't compete with the data colours that classify days.
5. **Dark mode is first-class, not a tint.** Surfaces, semantic roles, and contrast budgets are defined for both modes from the start.

## 2. Colour

### 2.1 Token philosophy

We use **semantic tokens**, not raw palette names. Components reference `surface`, `accent`, `score-high` — never `orange-500` or `zinc-100`. The raw palette can be retuned without touching component code.

Each semantic token has a value in light mode and a value in dark mode. Both must pass the contrast budget in [ACCESSIBILITY.md](./ACCESSIBILITY.md).

### 2.2 Semantic roles

| Token | Role | Example use |
| ----- | ---- | ----------- |
| `surface` | Default background | Page body |
| `surface-elevated` | Card / panel background | TripCard |
| `surface-sunken` | Inputs, code blocks | Form fields |
| `border` | Default 1px line | Card edges |
| `border-strong` | Emphasized line | Section dividers |
| `text` | Default body text | Paragraphs |
| `text-muted` | Secondary copy | Hints, captions |
| `text-faint` | De-emphasised | Disabled labels |
| `accent` | Brand accent | Primary CTA, links on hover |
| `accent-contrast` | Text on `accent` | CTA labels |
| `focus-ring` | Keyboard focus | Outline around focused elements |
| `score-high` | Efficiency ≥ 3.0 | Score badge, calendar highlight |
| `score-mid` | Efficiency 2.0–2.99 | Score badge |
| `score-low` | Efficiency < 2.0 | Score badge |
| `day-weekend` | Weekend cell | CalendarStrip, MonthGrid |
| `day-holiday` | Public holiday cell | CalendarStrip, MonthGrid |
| `day-pto` | PTO-consuming cell | CalendarStrip |
| `day-blocked` | Blocked range cell | CalendarStrip |
| `day-bridge` | Bridge-day hint | CalendarStrip annotation |
| `status-success` | Saved, copied, etc. | Toasts |
| `status-warn` | Historical-data uncertainty | Callouts |
| `status-danger` | Errors, rate-limit | Toasts, form errors |

### 2.3 Raw palette (recommended values)

These are starting recommendations — re-tune in Figma if needed, then update this table. **Treat the table as canonical**: the Tailwind theme is generated from it.

#### Light mode

| Token | Hex | Note |
| ----- | --- | ---- |
| `surface` | `#FAFAF7` | Warm off-white, not stark white |
| `surface-elevated` | `#FFFFFF` | Card lift |
| `surface-sunken` | `#F2F1EC` | Inputs |
| `border` | `#E5E3DC` | 1px hairline |
| `border-strong` | `#C8C5BB` | Section dividers |
| `text` | `#0E0F0B` | Near-black, slight warmth |
| `text-muted` | `#5A5B53` | Captions |
| `text-faint` | `#8C8C82` | Disabled |
| `accent` | `#E26A2C` | Warm coral — "sunset" |
| `accent-contrast` | `#FFFFFF` | On accent |
| `focus-ring` | `#0E0F0B` | High contrast on light surfaces |
| `score-high` | `#1E7F3E` | Green |
| `score-mid` | `#B58300` | Amber |
| `score-low` | `#9B2C2C` | Brick red |
| `day-weekend` | `#EEEDE6` | Quiet neutral |
| `day-holiday` | `#F7C16D` | Warm yellow |
| `day-pto` | `#E26A2C` | Brand accent |
| `day-blocked` | `#C8C5BB` | Strikethrough overlay applied |
| `day-bridge` | `#F7C16D` | Same as holiday, dashed border |
| `status-success` | `#1E7F3E` | |
| `status-warn` | `#B58300` | |
| `status-danger` | `#9B2C2C` | |

#### Dark mode

| Token | Hex | Note |
| ----- | --- | ---- |
| `surface` | `#0E100D` | Warm near-black |
| `surface-elevated` | `#1A1D18` | Lift |
| `surface-sunken` | `#0A0B09` | Inputs |
| `border` | `#2A2D26` | |
| `border-strong` | `#3F4338` | |
| `text` | `#F2F1EC` | Off-white |
| `text-muted` | `#A8A89C` | |
| `text-faint` | `#6B6D63` | |
| `accent` | `#F08652` | Lighter coral for dark surfaces |
| `accent-contrast` | `#1A1D18` | |
| `focus-ring` | `#F2F1EC` | |
| `score-high` | `#5FBF7F` | |
| `score-mid` | `#DCB04A` | |
| `score-low` | `#E07474` | |
| `day-weekend` | `#1A1D18` | |
| `day-holiday` | `#DCB04A` | |
| `day-pto` | `#F08652` | |
| `day-blocked` | `#3F4338` | Strikethrough overlay applied |
| `day-bridge` | `#DCB04A` | Dashed border |
| `status-success` | `#5FBF7F` | |
| `status-warn` | `#DCB04A` | |
| `status-danger` | `#E07474` | |

### 2.4 Colour-not-only signal

Colour is never the only signal. See [ACCESSIBILITY.md](./ACCESSIBILITY.md) §3 — every day classification also has an icon, text label, or pattern.

### 2.5 Theme override (System / Light / Dark)

Colour mode resolves in two layers:

1. **System default.** With no explicit choice, the active mode follows the OS via `@media (prefers-color-scheme: dark)`. This keeps dark mode first-class (§1) for visitors who never touch the control.
2. **Explicit override.** The [`ThemeSwitch`](./COMPONENTS.md#themeswitch--phase-1) in the `Footer` lets a visitor pin `light` or `dark`, or return to `system`. The choice persists in `localStorage` under `roamgap-theme` (`"system" | "light" | "dark"`).

The override is expressed as a `data-theme` attribute on `<html>`:

| `roamgap-theme` | `<html data-theme>` | Resolved mode |
| --------------- | ------------------- | ------------- |
| absent / `system` | (no attribute) | OS preference via media query |
| `light` | `light` | Light, regardless of OS |
| `dark` | `dark` | Dark, regardless of OS |

Implementation contract (extends §10.1):

```css
:root { /* light tokens */ }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { /* dark tokens */ }
}
:root[data-theme="dark"] { /* dark tokens */ }
```

`:not([data-theme="light"])` lets an explicit Light choice win over an OS dark preference; the standalone `[data-theme="dark"]` selector lets Dark win over an OS light preference. The dark token block is duplicated across the two selectors and the copies **must stay in sync** — a single retune touches both. To avoid a flash of the wrong theme, a tiny synchronous script in `<head>` reads `roamgap-theme` and sets `data-theme` before first paint.

### 2.6 Filter chips (toggle)

The `FilterPanel` month/weekday selectors are **toggle chips** on the small-pill scale ([FIGMA_REFERENCE.md § Decisions §4](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build)) — not raw checkboxes. The two states bind to existing tokens; no new colour token is introduced:

| State | Fill | Text | Border | Extra |
| ----- | ---- | ---- | ------ | ----- |
| Unselected | `surface-sunken` | `text-muted` | `border` 1px | — |
| Selected | `accent` | `accent-contrast` | none | leading check glyph (the non-colour signal, [ACCESSIBILITY.md §3](./ACCESSIBILITY.md)) |

Scale: `radius-full`, `text-xs`, ~28–32 px tall, 8–12 px horizontal padding — the same small-pill scale as the nav and preset chips. Selected chips carry the single brand `accent` (§1.4 "one accent"); they are the only place the accent *fills* a chip, which is deliberate — the chosen constraints are what the user is reasoning about. Unselected chips stay quiet so a 12-chip month row doesn't read as busy (§1 "calm over busy"). The same scale and token pair govern the blocked-date **removable chips** (with a trailing `✕` button), the `CalendarOverlayPicker`'s per-row **Free / PTO toggle** (the small-pill segmented idiom), and any future `FilterPanel` group ([PROJECT.md §5.1](./PROJECT.md)).

## 3. Typography

### 3.1 Families

| Role | Family | Stack |
| ---- | ------ | ----- |
| Sans (UI + body) | **Geist** | Already wired via `next/font/google` |
| Mono (data, code, URLs) | **Geist Mono** | Already wired |
| Display (optional, sparing) | Geist | Same family at large sizes |

We do not ship a serif. If branding pressure later calls for a display face, add it as a separate token (`font-display`) rather than retiring Geist.

### 3.2 Scale

Modular scale, 1.200 ratio (minor third), base 16 px. Line height tightens as size grows.

| Token | Size | Line height | Weight | Use |
| ----- | ---- | ----------- | ------ | --- |
| `text-3xs` | 11 px / 0.6875 rem | 14 px | 500 | Micro labels, badges |
| `text-2xs` | 12 px / 0.75 rem | 16 px | 500 | Captions, table headers |
| `text-xs` | 13 px / 0.8125 rem | 18 px | 400 | Secondary copy |
| `text-sm` | 14 px / 0.875 rem | 20 px | 400 | Default UI text |
| `text-base` | 16 px / 1 rem | 24 px | 400 | Body |
| `text-lg` | 19 px / 1.1875 rem | 28 px | 400 | Section lead |
| `text-xl` | 23 px / 1.4375 rem | 30 px | 600 | H3 |
| `text-2xl` | 28 px / 1.75 rem | 34 px | 600 | H2 |
| `text-3xl` | 33 px / 2.0625 rem | 38 px | 600 | H1 (page) |
| `text-display` | 48 px / 3 rem | 52 px | 700 | Landing hero only |

Letter-spacing: -0.01em on sizes ≥ `text-xl`. No tracking changes below that.

### 3.3 Numbers

Tabular numerals (`font-feature-settings: "tnum"`) on every place a number is compared visually: score badges, calendar headers, ranking lists. Without this, the 3 in 3.00 doesn't line up under the 4 in 4.00 and the ranking looks sloppy.

## 4. Spacing

4 px base, geometric progression where useful.

| Token | Value | Use |
| ----- | ----- | --- |
| `space-0` | 0 | |
| `space-px` | 1 px | Hairline |
| `space-0.5` | 2 px | Icon nudge |
| `space-1` | 4 px | |
| `space-2` | 8 px | Tight stack |
| `space-3` | 12 px | Form gap |
| `space-4` | 16 px | Default gap |
| `space-6` | 24 px | Section gap |
| `space-8` | 32 px | Region gap |
| `space-12` | 48 px | Major divide |
| `space-16` | 64 px | Page section |
| `space-24` | 96 px | Hero padding |

## 5. Radius

| Token | Value | Use |
| ----- | ----- | --- |
| `radius-none` | 0 | Calendar cells (sharp) |
| `radius-sm` | 4 px | Buttons, inputs |
| `radius-md` | 8 px | Cards |
| `radius-lg` | 12 px | Modals, large panels |
| `radius-full` | 9999 px | Pills, avatars |

Calendar cells are **sharp**, not rounded. The grid is the point.

## 6. Elevation (shadow)

We use 3 elevations only. Each combines a near layer (sharper) with a far layer (softer).

| Token | Spec |
| ----- | ---- |
| `elevation-0` | none |
| `elevation-1` | `0 1px 2px rgb(0 0 0 / 0.04), 0 1px 3px rgb(0 0 0 / 0.06)` — resting card |
| `elevation-2` | `0 2px 4px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.08)` — hover, dropdown |
| `elevation-3` | `0 4px 8px rgb(0 0 0 / 0.06), 0 12px 32px rgb(0 0 0 / 0.12)` — modal, toast |

In dark mode the alpha values double; the underlying colour stays black.

## 7. Motion

### 7.1 Durations

| Token | ms | Use |
| ----- | -- | --- |
| `dur-fast` | 100 | Property nudges (hover state) |
| `dur-base` | 200 | Default UI transitions |
| `dur-slow` | 400 | Layout shifts, reveal |
| `dur-deliberate` | 700 | Hero reveal, brand moments |

### 7.2 Easings

| Token | Curve | Use |
| ----- | ----- | --- |
| `ease-standard` | `cubic-bezier(0.2, 0.0, 0.0, 1.0)` | Default; enter |
| `ease-out` | `cubic-bezier(0.0, 0.0, 0.2, 1.0)` | Exit |
| `ease-emphasis` | `cubic-bezier(0.3, 0.0, 0.0, 1.0)` | Brand moments |

### 7.3 Reduced motion

When `prefers-reduced-motion: reduce`, every animation collapses to a 0 ms cross-fade. Layout shifts still happen but instantly. No exceptions.

### 7.4 Named keyframes

Project keyframes live in `globals.css` and are token-bound (they animate `var(--c-*)`, so they theme correctly). The §7.3 reduced-motion rule neutralises them globally — no per-keyframe guard is needed.

| Keyframe | Use |
| -------- | --- |
| `rg-cal-sweep` | The `CalendarLoader` month cells — each cell sweeps neutral → `accent` → neutral; a staggered per-cell delay makes the highlight travel Jan→Dec ("the calendar progressing forward"). See [COMPONENTS.md § CalendarLoader](./COMPONENTS.md). |

## 8. Breakpoints

| Token | min width | Notes |
| ----- | --------- | ----- |
| `sm` | 640 px | Tablet portrait |
| `md` | 768 px | Tablet landscape |
| `lg` | 1024 px | Desktop |
| `xl` | 1280 px | Wide desktop |
| `2xl` | 1536 px | Display |

The optimizer UI is **mobile-first**. The calendar strip horizontally scrolls on `< md`; switches to side-by-side multi-month at `md` and above.

## 9. Density

Single density (`comfortable`) in Phase 1. A `compact` density may be added in Phase 6 for the B2B team dashboard. Don't pre-bake compact-density components now — that's [scope creep](../docs/AI_AGENTS.md#6-dont-add-features-that-arent-in-the-spec).

## 10. Implementation contract

### 10.1 Tailwind v4

We're on Tailwind v4 — theme tokens live in CSS via `@theme inline`. Every token above maps to a CSS variable in `src/app/globals.css`. The mapping is mechanical:

```css
@theme inline {
  --color-surface: var(--c-surface);
  --color-accent: var(--c-accent);
  /* … */
}
:root {
  --c-surface: #FAFAF7;
  --c-accent: #E26A2C;
  /* … */
}
@media (prefers-color-scheme: dark) {
  :root {
    --c-surface: #0E100D;
    --c-accent: #F08652;
    /* … */
  }
}
```

Components reference `bg-surface`, `text-accent`, `border-border`. **Never** `bg-orange-500`. If you find yourself reaching for a raw palette name, add a semantic token instead.

### 10.2 Figma

The Figma library mirrors this document. Current state lives in [`gzgZd6XTnFVwHHbPxKuac2`](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2) (Portfolio team, Pro tier). Structure realized:

- **Cover** — presentation cover: `accent` bar, brand wordmark, headline, governance lead, metadata, and a numbered "What's inside" page index
- **Tokens** — colour swatches (Light + Dark), type specimens, spacing/radius/elevation showcases
- **Primitives** — atomic interaction states for Button and Input (default/hover/pressed/focus/disabled · idle/focused/error/disabled), token-bound (drawn 2026-06-15)
- **Components** — 26 components in 9 grouped sections (see [FIGMA_REFERENCE.md § Components](./FIGMA_REFERENCE.md#components-page))
- **Patterns** — assembled route screens, filed into eight labelled Light-left / Dark-right Sections

Two notes that emerged during the build:

1. **Two pill sizes coexist on purpose.** Large pills (44 tall, `text/sm`, 12px vertical padding) are reserved for primary controls like `BudgetModeToggle` and `Find trips`. Small pills (~32 tall, `text/xs`, tighter padding, fully rounded) are used for nav and filter chips. See [FIGMA_REFERENCE.md § Decisions](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build).
2. **Pills are only applied to items with action state.** Active nav items get pills; muted nav items stay plain text. CTAs always pill. This keeps the nav visually quiet by default.

To regenerate from scratch, run the `figma-generate-library` skill against this document.

### 10.3 Source of truth

If Figma and this document disagree, **this document wins**. Update the Figma library to match. The reverse — code chasing Figma drift — is how design systems die.

## 11. Controls — buttons

One button scale, applied consistently. The most common source of visual drift in
this app is a primary action that **omits an explicit size** and so inherits
`text-base` (16 px) — heading-adjacent and visibly heavier than its peers. That is a
bug, not a variant.

### 11.1 The primary action button (normative)

A filled call-to-action (`bg-accent` / `text-accent-contrast`) is always **`text-sm`,
`font-medium`** — the §3.2 "Default UI text" size, and the `text/sm` the Figma
"primary controls" decision (§10.2 note 1) already reserves for `Find trips` and the
budget toggle. The canonical recipe is:

```
rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-contrast
```

- **Never** ship a `bg-accent` action with no `text-*` class — that silently inherits
  `text-base` and breaks the rhythm. Every primary CTA in the app — *Find trips*,
  *Find shared breaks*, *Create group*, *Sign in*, *Join this group* — is the same
  `text-sm` size. The font size does not change with the surface.
- Horizontal padding may flex by one step (`px-3.5`–`px-5`) where the surrounding
  density warrants (e.g. the compact `Create group` trigger, item 14), but the **size
  and weight do not** — those are fixed so buttons read as one family.

### 11.2 Smaller actions

Genuinely secondary / inline actions (a row-level *Join*, a *Try again*, a poll vote)
may step down to `text-xs` (Secondary copy, §3.2). This is the only sanctioned
deviation, and it is a deliberate step **down** for a lower-weight action — not the
accidental step **up** to `text-base` that §11.1 forbids.

### 11.3 Secondary / ghost buttons share the primary's geometry

A ghost / outline action (Forget profile, Cancel, Update card) is the **same shape
and size** as the primary — only the fill differs. The canonical recipe is:

```
rounded-md border border-border px-4 py-2 text-sm font-medium text-text-muted hover:text-text
```

A primary and a ghost sitting in the same action row must therefore have **identical
height, radius, and padding** — they read as one family with two emphases, never as
two differently-sized buttons. Mixing `px-4 py-2` and `px-5 py-2.5` in one form is the
"different sized buttons" drift this section exists to prevent.

### 11.4 `rounded-full` is for pills, not standalone buttons

`radius-full` (§5) belongs to **chips, removable tokens, segmented toggles, and
avatars** — controls whose pill shape carries meaning (a tag you can dismiss, a
toggle whose thumb slides). A standalone action button (Save, Add, Create) is
**`rounded-md`**, never `rounded-full`. A pill-shaped Save button next to a
`rounded-md` Save button is the most common settings-page drift; do not ship it.

## 12. Controls — inputs, selects, field labels

The companion to §11: one geometry for the things you type or pick into, so a form
column reads as a single rhythm rather than a stack of differently-rounded boxes.

### 12.1 Text inputs and selects (normative)

One recipe, one radius (`radius-md`), one height (`py-2`, the §9 comfortable density):

```
text input   rounded-md border border-border bg-surface-sunken px-3 py-2 text-sm text-text
select       rounded-md border border-border bg-surface-sunken py-2 pl-3 pr-12 text-sm text-text appearance-none   (+ ChevronGutter)
```

- **One radius.** Inputs and selects are `rounded-md`. `rounded-lg` on a form field is
  drift — it makes a single column read as two families.
- **Selects own a chevron gutter.** An `appearance-none` select reserves a 48 px
  right gutter (`pr-12`) for the centred `ChevronGutter` chevron, so the native arrow
  is replaced by one that matches the `+` stepper geometry in an adjacent row
  ([COMPONENTS.md § CountryPicker](./COMPONENTS.md)).
- The tall numeric **stepper** (`AnnualPtoInput` / `MaxTripLengthInput`, fixed
  `h-[70px]`) is a deliberate exception — a distinct control class that pairs with the
  `size="hero"` `CountryPicker`, not a plain field.

### 12.2 Field labels (normative)

Every field micro-label uses **one** treatment — the eyebrow the shared pickers
already render:

```
text-xs font-medium uppercase tracking-wide text-text-faint
```

A form that mixes sentence-case `text-text-muted` labels (on its local fields) with
uppercase `text-text-faint` labels (from a shared `CountryPicker` / stepper) in the
same card is the label half of the "cluttered" drift. Pick the eyebrow and apply it to
**all** labels on the surface.


---

# Components inventory

_Source: `specs/COMPONENTS.md`_

# Component Inventory

> Every component the UI needs through Phase 3. Each entry names the component, the states it can be in, its required props (the contract), and which spec it depends on. The Figma library mirrors this list 1:1.
>
> **Realized in Figma:** [roamgap — design library](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2). For the visual snapshot of every built component, see [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md). Each subsection below that's already built links to the relevant screenshot.

## Figma library overview

26 components currently live in the file, grouped into 9 sections on the Components page:

![Components page — 26 components, 9 sections](./figma/components-page.png)

Three families that aren't yet drawn but are in this spec: `YearOverview` (the plan-output 12-month calendar), `Toast`, `EmptyState`/`ErrorState`/`DocPage`. Plus `YearPlanCard / Spread Even` and `YearPlanCard / Single Long` — the two missing strategy variants. The standalone **Calendar** family (`YearCalendar` / `MonthGrid` / `CalendarForm` — distinct from the plan-bound `YearOverview`) is realised in the library under a **Calendar** section (node `291:493`); see [§ Calendar — standalone reference view](#calendar--standalone-reference-view) and [`figma/year-calendar.svg`](./figma/year-calendar.svg).

## Conventions

- **States** is exhaustive. If a state isn't listed, the component doesn't render it. Adding a state requires a spec PR.
- **Props (contract)** lists only the public shape — internal implementation can have more. When in doubt, derive types from [`specs/domain.ts`](./domain.ts), never re-invent.
- **Phase** marks when the component first ships. Phase 1 = MVP.
- **Depends on** points at other spec files (design tokens, voice, etc.).

---

## Layout

### `AppShell` — Phase 1 · *Slice 1 (code)*
Root layout with top nav and content slot.

- **States:** default.
- **Props:** `children: ReactNode`.
- **Page padding contract (normative).** `AppShell`'s `<main>` is the **single owner** of page-level padding and width: `mx-auto w-full max-w-6xl px-6 py-10`. The horizontal **gutter is `px-6`** on every route — that is the page padding. **Pages must not add their own page-level horizontal padding** (no `px-*`/`py-*` on a top-level content wrapper inside `AppShell`); doing so stacks on top of `px-6` and the page reads as over-inset (the `/overlap` · `/solo` · `/group` regression, fixed — they each wrapped content in an extra `… px-4 py-8`, so mobile side padding was `24 + 16 = 40 px` vs the correct `24 px` on `/year` · `/calendar`). A page that wants a **narrower content column** (e.g. an explainer) sets only `mx-auto max-w-*` on its wrapper — **width, never padding**; the gutter still comes from `AppShell`. Internal spacing between sections uses `gap-*` / `mt-*`, not wrapper padding.
- **Depends on:** [DESIGN_SYSTEM §8](./DESIGN_SYSTEM.md) (breakpoints).

> **SHIPPED 2026-06-23.** Removed the redundant `px-4 py-8` wrapper on `/overlap`, `/solo`, `/groups` (each kept its `mx-auto max-w-*` column — width only), so all routes share `AppShell`'s `px-6` gutter (was `px-6 + px-4 = 40 px` on mobile vs the correct `24 px` on `/year` · `/calendar`). Pinned by [`tests/unit/page-padding.test.tsx`](../tests/unit/page-padding.test.tsx). Drawn `Pattern · Page gutter` Light `809:1580` / Dark `809:1702` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). `npm run check` green (1105 web tests). Swept all `app/**/page.tsx`: no other page double-pads (`/about/stats` already uses its own `px-6` main; `/` redirects to `/year`).

### `TopNav` — Phase 1 · *built (Figma) · Slice 1 (code)* (on Patterns page)
Top-of-page navigation. Logo left, links right. Sticky. **Visual decision:** only items with action state (active nav, CTA) render as pills; inactive items are plain muted text. See [FIGMA_REFERENCE.md § Decisions §5](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build).

- **States:** default, scrolled (subtle bg + border).
- **Responsive layout:** at `sm`+ ([DESIGN_SYSTEM.md §8](./DESIGN_SYSTEM.md), 640 px) the five primary links render inline with their full labels, **in this order**: **Plan year** (→ `/year`), **Plan a year for 2** (→ `/overlap`, [MULTI_COUNTRY.md](./MULTI_COUNTRY.md)), **Solo trips** (→ `/solo`), **Group trips** (→ `/groups`, [GROUPS.md](./GROUPS.md)), and **Calendar** (→ `/calendar`, [PROJECT.md §4.4](./PROJECT.md)) — then the `NavAuthSlot` identity slot. The order pairs the *plan-/discover-dates* tools (Plan year · Plan a year for 2) with the *plan-trips* tools (Solo trips · Group trips, the Phase-5 AI focus), Calendar last ([VOICE.md §4.11](./VOICE.md)). **Gating is unchanged** by the reorder: `/overlap` + `/groups` are **paid-dark** but sit in the nav for everyone (anonymous included) for discovery and the GD-3 invite loop — a click routes a signed-out visitor to sign-in and an unsubscribed one to the paywall; **Solo trips** (`/solo`) is **free but account-scoped** (its localStorage draft composer works signed-out, § Itinerary components); Plan year + Calendar are free. Below `sm` they collapse behind a single overflow control (`NavMenu`, below): the bar keeps only the wordmark, the ☰ "Menu" button, and the `NavAuthSlot` identity slot, so the row no longer crowds or wraps on a phone. Full labels are used at **both** sizes — the menu has room, so nothing abbreviates (this supersedes the earlier label-shortening rule; [VOICE.md §4.11](./VOICE.md)). Inline links never wrap (`whitespace-nowrap`); the identity slot (`NavAuthSlot`) stays visible at every size. About is the documentation surface ([PROJECT.md §4.5](./PROJECT.md)), not a primary tool — it lives in the Footer nav (§ Footer), not the top bar.
- **Logo:** the left slot links to `/` and renders the `BrandMark` logomark (below) at **every** size; the `roamgap` wordmark (mono text, theme-adaptive via `text-text`) joins it from `sm`+ as a mark + wordmark lockup ([FIGMA_REFERENCE.md §1](./FIGMA_REFERENCE.md)). Below `sm` only the mark shows, to reclaim width. The link's accessible name is the site name at both sizes ([BRANDING.md §10](./BRANDING.md)).
- **Props:** `currentPath: string` (active-link styling; passed through to `NavMenu`).
- **Depends on:** DESIGN_SYSTEM, VOICE (link copy), ACCESSIBILITY (§4.7), `NavMenu`, `NavAuthSlot`, `BrandMark`.

### `NavMenu` — Phase 1 · *Phase 2: auth-aware mobile menu*
The mobile overflow control for `TopNav`. Below `sm` it stands in for the inline primary links **and** the identity CTA: a single ☰ disclosure button that reveals the five destinations in a popover list, with the **guest pitch** (the `NavGuestMenu` block — an eyebrow + what an account adds + a **Sign in** action) appended at the foot when signed-out. Renders nothing at `sm`+ (`sm:hidden`) — the inline links + the standalone `NavAuthSlot` (which carries `NavGuestMenu` at `sm`+) own that range.

- **States:** closed (☰ trigger only), open (popover list of the five primary destinations; a separator + the **guest pitch** — eyebrow "Browsing as a guest" + two value lines + a **Sign in** link ([VOICE.md §4.24](./VOICE.md)) — follows when signed-out, omitted when signed-in since the avatar `AccountMenu` owns identity).
- **Props:** `currentPath: string` — marks the active destination (`aria-current="page"`) inside the list using the same rule as `TopNav` (home `/` counts as the `/year` tab).
- **Behaviour:** click / `Enter` / `Space` toggles the disclosure. Choosing a link, pressing `Esc` (returns focus to the trigger), or a pointer-down outside the control all close it. The list shows the **full** labels, sentence case ([VOICE.md §4.11](./VOICE.md)).
- **Layout — edge-aligned panel (the mobile Sign-in overshadow fix).** The popover is anchored to the **nav container's** right edge, not the trigger's, so a control sitting to the trigger's right (the standalone "Sign in" button, or the signed-in avatar) can no longer push the panel left and detach it from the screen edge. Below `sm` the standalone "Sign in" CTA is **hidden** (it moves into this menu, see `NavAuthSlot`), so on a phone the hamburger is the sole right-aligned trigger and its panel sits flush to the content edge.
- **Auth:** reads `useSession()` — the **guest pitch** (eyebrow + value lines + the **Sign in** link) shows when `status !== "authed"` (anon or still-loading), mirroring `NavAuthSlot`/`NavGuestMenu`.
- **Accessibility:** [disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) per [ACCESSIBILITY.md §4.7](./ACCESSIBILITY.md) — a `<button>` with `aria-expanded` + `aria-controls` revealing a region of ordinary links, **not** a `role="menu"` (site navigation reads better as plain links than as an application menu). The trigger's accessible name is "Menu"; the ☰ glyph is `aria-hidden`.
- **Depends on:** DESIGN_SYSTEM (breakpoints §8, elevation), VOICE (§4.11), ACCESSIBILITY (§4.7), `useSession`.
- **SHIPPED (2026-06-23).** Verified at 390 px: the open panel's right edge aligns to the content edge (it was displaced ~68 px left by the corner "Sign in" button); the standalone Sign-in is `display:none` on mobile and the in-menu entry is the visible one. Drawn `Pattern · TopNav mobile — auth in menu (fix)` Light `788:1580` / Dark `792:1580` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)).

### `BrandMark` — Phase 1
The `roamgap` logomark — a flip calendar (binder band in the single brand `accent`) with a paper plane. Rendered in the `TopNav` left slot at every size (the wordmark joins it from `sm`+) and beside the `Footer` attribution.

- **Theme-safe by construction:** ink strokes are `currentColor` (they inherit the surrounding `text` colour, so the mark adapts to light/dark exactly like the wordmark it replaces); the binder band is the `accent` token ([DESIGN_SYSTEM.md §1](./DESIGN_SYSTEM.md), one accent). No hardcoded colours — nothing to regress on the dark header surface.
- **Authored as vector.** The brand artwork otherwise ships only as raster PNGs (`public/logo.png` / `logo-transparent.png`, [BRANDING.md §10](./BRANDING.md)); this is a simplified, on-brand inline SVG sized for the header, **not** a trace of the full illustration, and the favicon / OG raster is unchanged.
- **Props:** `className?` — sizing + responsive visibility (the consumer passes `h-6 w-6 sm:hidden`).
- **Accessibility:** decorative — `aria-hidden`, `focusable="false"`; the consuming link carries the accessible name (the site name), so the mark adds no duplicate label.
- **Depends on:** DESIGN_SYSTEM (`accent`, `text` tokens).

### `Footer` — Phase 1 · *Slice 1 (code)* · *legal nav added Phase 3*
Page footer with attribution, a docs link nav (`llms.txt`, About, URL format, OpenAPI), and a **legal link nav** (Privacy · Cookies · Sub-processors · Terms · Refunds).

- **States:** default.
- **Props:** none.
- **Contains:** the `BrandMark` logomark beside the `roamgap` attribution; the docs link nav; and the **legal nav** — links to `/legal/privacy`, `/legal/cookies`, `/legal/sub-processors`, `/legal/terms`, `/legal/refunds`. The legal nav was added for the Phase-3 paid-tier go-to-market: a paid product must keep **Terms / Privacy / Refunds reachable from every page** (consumer-protection norms + Google/Meta ad-platform review), and the footer is the site-wide home for it. The `ThemeSwitch` previously in the right-hand cluster has moved to `/account/settings` (see § ThemeSwitch) — a single home for the control, not duplicated chrome.

### `ThemeSwitch` — Phase 1 · *Slice 1 (code)*
Tri-state colour-mode control on the `/account/settings` Theme card — its own page-level tile in `SettingsTiles` (no longer nested in `AccountSettingsPanel`, and not the `Footer`). Lets the visitor override the OS `prefers-color-scheme` default with an explicit Light or Dark choice, or return to System. The choice persists across sessions and is applied before first paint so there is no flash of the wrong theme. See [DESIGN_SYSTEM.md §2.5](./DESIGN_SYSTEM.md#25-theme-override-system--light--dark) for the resolution contract.

- **States:** system-active, light-active, dark-active. Exactly one is active at a time.
- **Props:**
  - `value: ThemeChoice` — `"system" | "light" | "dark"`
  - `onChange: (next: ThemeChoice) => void`
- **Behaviour:**
  - Setting `value` writes the choice to `localStorage` under `roamgap-theme` and reflects the resolved mode onto `<html data-theme>`: `light` / `dark` for explicit choices, the attribute **removed** for `system` (the `prefers-color-scheme` media query then governs).
  - `system` is the default for a visitor who has never chosen — no attribute is written, so the OS preference wins and dark mode stays first-class per [DESIGN_SYSTEM.md §1](./DESIGN_SYSTEM.md).
  - Rendered as a **compact** segmented control (small-pill scale — ~32px, `text-xs`, fully rounded — per [FIGMA_REFERENCE.md § Decisions §4](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build)) so it sits quietly in the settings Theme card rather than competing with the large-pill primary controls.
- **Type note:** `ThemeChoice` is a **UI-local** type defined alongside the component. It never crosses an HTTP / DB / AI boundary, so per [AGENTS.md §6](../AGENTS.md) it is *not* added to [`domain.ts`](./domain.ts).
- **Depends on:** DESIGN_SYSTEM (§2.5 theme override), ACCESSIBILITY (§4.5 radiogroup pattern), VOICE (§4.5 labels).

---

## Annual planner — primary flow

> The annual planner is the home-page experience. Reads inputs once, returns five year plans. See [PROJECT.md §4.1](./PROJECT.md), [ALGORITHM.md §11](./ALGORITHM.md).

### `ModeToggle` — *REMOVED ([ADR-008](../docs/DECISIONS.md), 2026-06-22)*
The `/year` mode switch ("Plan my year" / "Find one trip") and the `rg_view` sticky-mode cookie are **deleted** — `/year` is whole-year only ([PROJECT.md §4.2](./PROJECT.md)). The single-break finder it toggled to was removed; the component, `src/lib/planner/view-mode.ts`, and their tests are gone. The `POST /api/trip` backend endpoint is untouched.

### Form layout — vertical rhythm — *new · tester feedback (spacing)*
The hero form reads as **one tight input cluster followed by evenly-separated sections**, not a flat stack with uniform gaps (the uniform gap flattened the hierarchy so every block — caption, action, Advanced — floated the same distance apart).

- **Input cluster (tight).** The three hero inputs (Country · leave · Max-trip-length) belong together and sit close (a small intra-cluster gap). **The autoload caption was removed** (post-fold declutter, [PROJECT.md §4.2.1](./PROJECT.md)): the "✓ Country, leave & trip length filled in from your saved profile — edit in settings" line was ambient clutter — a signed-in visitor quickly notices their inputs are pre-filled and edits them in account settings without being told. Inputs still autoload from the saved profile silently ([AVAILABILITY_PROFILE.md §5.2](./AVAILABILITY_PROFILE.md)); only the caption is gone.
- **Sections (even).** Below the cluster, two peers carry an equal, larger section gap: the **action row** (`PlanSummary` + Find trips) and **Advanced settings**. (The **Quick start** preset row was removed in the §4.1.3 declutter — [PROJECT.md §4.1.3](./PROJECT.md) — so the plan shape now lives only in Advanced settings.)
- **Trip-length presets removed.** The 1w/2w/3w `MaxTripPresets` chip row was **deleted** ([ADR-008](../docs/DECISIONS.md)); max trip length defaults to one week and is set via the `MaxTripLengthInput` stepper. The hero is the three-input grid with no preset row beneath it.
- **Action row.** Find trips stays right-aligned to the form's right edge (aligned with the inputs/chips above); the `PlanSummary` line is width-capped so the two read as a grouped action, not splayed to opposite edges.

### Finding state (submit → result) — *new · tester feedback (loading + mobile visibility)*
On **Find trips** the submit starts a React transition (the navigation that fetches the five plans). While it is pending: the submit button takes a busy treatment (`aria-busy`, disabled — a double-submit can't fire), and the **`CalendarLoader`** (§ State surfaces) stands in the results slot beneath the form with the "Calculating trips…" caption ([VOICE.md §4.2](./VOICE.md)). The form also **scrolls the results region into view** on submit: on a phone the planner fills the viewport, so without this a user can't tell anything happened — the page looked unchanged up to the button. The smooth scroll honours `prefers-reduced-motion` (instant) per [DESIGN_SYSTEM.md §7.3](./DESIGN_SYSTEM.md). When the plans arrive the loader is replaced by `YearPlanComparison` in the same slot.

### `PlannerHero` — *new · tester feedback (TF-4 onboarding)*
The reframed home hero ([PROJECT.md §4.1.1](./PROJECT.md), [VOICE.md §4.19](./VOICE.md)) — eyebrow + benefit-led H1 + one-line lead. Replaces the inline "Plan your year off." headline that read as mechanism, not value. Server component (no client state); copy comes from the i18n catalog (`planner.onboarding.*`) so the §3.2 leave term is per-market.

- **Props:** `locale?: string` (default the static-launch default, `en-KE`).
- **Renders:** `<section aria-labelledby="hero">` with the eyebrow (`text-faint`, mono uppercase), the H1 at `text/display`, and the lead at `text/lg` muted. No inputs — the `PlannerForm` sits below it.
- **Depends on:** VOICE §4.19, I18N (catalog term).

### `HowItWorksSteps` — *new · tester feedback (TF-4 onboarding)*
The inline three-step "how it works" strip on the first-run home ([PROJECT.md §4.1.1](./PROJECT.md), [VOICE.md §4.19](./VOICE.md)) — a compact paraphrase of [`/about/how-it-works`](./PROJECT.md) (§4.3) so a first-timer grasps the flow without leaving the page. Server component; copy from the catalog.

- **Renders:** a labelled region (`aria-label` "How it works") with an ordered list of three steps, each a numbered marker + title + one-line body, laid out as three columns from `sm` up and stacked below ([DESIGN_SYSTEM.md §8](./DESIGN_SYSTEM.md)). A trailing "See how it works" link to `/about/how-it-works`.
- **Props:** `locale?: string`.
- **Depends on:** VOICE §4.19, ACCESSIBILITY (ordered-list semantics, the number is not the only signal — each step has its text title).

### `PlannerDemo` — *new · tester feedback (TF-4 onboarding)*
The labelled live-demo results block beneath the form on the first-run home ([PROJECT.md §4.1.1](./PROJECT.md), [VOICE.md §4.19](./VOICE.md)) — a **worked example** rendered through the real result components so a visitor sees the payoff before any input. Server component; renders a static fixture (`src/lib/planner/demo.ts`, a recorded `YearPlan` — real holidays on their real weekdays), never a live backend call (PROJECT.md §4.1.1 rationale).

- **Renders:** an eyebrow "Example · {Country} {year}" + the fact headline + the muted "build your own" lead, then the fixture's Trips as read-only **`TripCard`** instances (`selectable` off, no `exportCountry`). Read-only by contract: it must not expose the itinerary-add or export affordances, so it can't be mistaken for a live result.
- **Props:** `locale?: string`.
- **Shown:** only in the no-input first-run state; the live `ResultsClient` replaces it once the page has input.
- **Depends on:** `TripCard`, `CalendarStrip`, `ScoreBadge`; VOICE §4.19; the demo fixture.

### `AnnualPtoInput` — Phase 1 · *built*
Number input + stepper for the user's annual PTO budget. Suffix "days / year".

- **States:** idle, focused, disabled, error.
- **Props:**
  - `value: number`
  - `onChange: (next: number) => void`
  - `min?: number` (default 0) · `max?: number` (default 60)
- **Depends on:** ACCESSIBILITY, VOICE (suffix copy per locale → I18N).

### `MaxTripLengthInput` — Phase 1 · *built · presets removed (ADR-008)*
Length cap for any single break. Renders as a stepper. **Defaults to one week (7)** ([ADR-008](../docs/DECISIONS.md)); a signed-in user's saved `maxTripLength` overrides that. The 1w/2w/3w `MaxTripPresets` sibling chip row (formerly beneath the hero) was **deleted** — the stepper is the only trip-length control.

- **States:** idle, focused, disabled, error.
- **Props:**
  - `value: number`
  - `onChange: (next: number) => void`
- **Depends on:** ACCESSIBILITY, VOICE.

### `MinTripLengthInput` — Phase 1
Optional floor — "don't suggest breaks shorter than X". Tucked under an "Advanced" disclosure by default.

- **States:** idle, focused, disabled.
- **Props:**
  - `value: number | null`
  - `onChange: (next: number | null) => void`

### `LongTripCountInput` — Phase 1 · *built · superseded by `TripStyleSlider` on `/year` (UX-11)*
The numeric model that tunes the "Longest breaks" plan: "how many long trips (7+ days) do you want?" Maps to `AnnualOptimizeInput.longTripCount` ([ALGORITHM.md §11.10](./ALGORITHM.md)). Seeded with `smartLongTripDefault(pto)` and tracks the PTO budget until the user edits it (then the choice sticks and the `lt` URL param is emitted — [URL_FORMAT.md §3.3](./URL_FORMAT.md)). **UX-11:** on `/year` the stepper + its explanatory paragraph are replaced by the `TripStyleSlider` (same `longTripCount` value, captioned ends instead of a paragraph). The original stepper geometry (label "Long trips", subtitle "7+ days each") is retained here as the documented fallback / numeric contract.

- **States:** idle, focused, disabled.
- **Props:**
  - `value: number`
  - `onChange: (next: number) => void`
  - `min?: number` (default 1) · `max?: number` (default 8)
- **Depends on:** ACCESSIBILITY (spinbutton pattern), VOICE (label copy), domain.ts (`longTripCount`).

### `BudgetModeToggle` — Phase 1 · *built*
Segmented control: "Spend it all" / "Only the good stuff" — mapped to `BudgetMode = "exhaust" | "selective"`. Height (44px) defines the "large pill" scale for the rest of the system — see [FIGMA_REFERENCE.md § Decisions §4](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build).

- **States:** default, active-exhaust, active-selective, disabled.
- **Props:**
  - `value: BudgetMode`
  - `onChange: (next: BudgetMode) => void`
- **Responsive:** the control spans the **full width** on mobile with its two segments splitting evenly (`w-full` + each segment `flex-1`); from `sm:` up it reverts to a content-width pill **left-aligned** in its column (`sm:inline-flex sm:w-auto sm:self-start`, segments `sm:flex-none`). The `sm:self-start` also fixes a desktop bug: nested in the Advanced-settings `flex-col`, the bare `inline-flex` was stretched edge-to-edge and read as a misaligned full-width bar.
- **Depends on:** domain.ts (`BudgetMode`), VOICE (label copy).

### `YearOverview` — Phase 1
The hero output: a compact full-year calendar (12 months tiled) with the selected plan's trips highlighted. Tapping a trip scrolls to that `TripCard` below.

- **States:** default, loading (skeleton), empty (no plan selected), error.
- **Props:**
  - `year: number`
  - `trips: Trip[]`
  - `holidays: Holiday[]`
  - `weekStartsOn?: Weekday` (default 1)
  - `onTripSelect: (trip: Trip) => void`
- **Depends on:** DESIGN_SYSTEM (day-* tokens), ACCESSIBILITY (grid pattern at month level), I18N (month names).

### `YearPlanComparison` — Phase 1 · *collapsed by default (§4.1.3)*
Five `YearPlanCard`s shown together — desktop: a **centered wrapping row** of fixed-width cards (5 cards wrap 3 + 2, the partial row centered so it reads as balanced, not orphaned hard-left); mobile: **full-width stacked**. The selected card is bound to the `YearOverview` above.

**Collapsed by default (§4.1.3 declutter).** On `/year` results the comparison no longer opens as a big `surface/sunken` "Pick a strategy." block above the result. The **selected plan is the result, shown immediately**; the comparison lives inside a **collapsed `<details>` disclosure** — *"Compare strategies"* — styled like the form's **Advanced settings** summary (`cursor-pointer text-xs font-medium uppercase tracking-wide text-text-faint`), sitting between the selected-plan header and its trips. Expanding it reveals the five cards; picking one re-selects and updates the result below. The selected-plan header already names the active strategy, so a collapsed switcher loses nothing ([PROJECT.md §4.1.3](./PROJECT.md)).

**Default selection:** the comparison opens with the **Longest breaks** (`packed-length`) plan selected — testers found it the most useful starting point — falling back to the first returned plan when that strategy is absent. The pick is computed by `pickDefaultStrategy` ([src/lib/planner/default-strategy.ts](../src/lib/planner/default-strategy.ts)); a user's saved **default-strategy preference** (§ `DefaultStrategyPreference`, account settings) overrides it.

- **States:** default, loading (skeleton row), empty, error, none-selected.
- **Props:**
  - `plans: YearPlan[]`
  - `selectedPlanId: PlanStrategy | null`
  - `onSelect: (id: PlanStrategy) => void`
- **Responsive:** the cards are **full-width** on mobile (`YearPlanCard` `w-full`), reverting to fixed-width pills from `sm:` up (`sm:w-[280px]`); the row is `flex-col` on mobile and a **centered** wrapping row from `sm:` up (`sm:flex-row sm:flex-wrap sm:justify-center`), so a partial final row (5 → 3 + 2) sits balanced rather than left-orphaned. Mirrors the full-width-on-mobile idiom of `BudgetModeToggle` (§ `BudgetModeToggle` Responsive).
- **Depends on:** domain.ts (`YearPlan`, `PlanStrategy`), YearPlanCard.

### `YearPlanCard` — Phase 1 · *built* (3 of 5 variants)

![YearPlanCard — Selected (Packed · Best leverage), Default (Packed · Longest breaks), Spread (Spread · Quarterly)](./figma/year-plan-card.png)

A single plan summary. Shows `PlanLabelBadge`, total PTO used, total days off, average efficiency, sparkline of trips across the year. The Selected variant uses accent border + `elevation/2` instead of the default `elevation/1`. The action footer reads "Selected" without a checkmark — see [FIGMA_REFERENCE.md § Decisions §2](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build).

**Missing variants:** `spread-even` and `single-long`. The Patterns home pattern currently shows 3 of 5 and labels itself "THREE OF FIVE PLANS".

- **Over budget** (`YearPlan.overBudget === true`) — `totalPto` exceeds `annualPtoBudget` because of `pto`-mode observed-calendar required days reserved across the year ([CALENDAR_OVERLAYS.md §5.1](../../backend/specs/CALENDAR_OVERLAYS.md)). Surfaces the same honest **"Over budget"** `status-warn` marker as `TripCard`, near the strategy label. `budgetUtilisation` stays clamped to `[0,1]`; the overflow is carried by this flag, not by pushing the bar past full.
- **States:** default, hover, focused, selected, dimmed (when another card is selected), **over-budget**.
- **Props:**
  - `plan: YearPlan`
  - `selected: boolean`
  - `onSelect: () => void`
- **Depends on:** PlanLabelBadge, ScoreBadge, DESIGN_SYSTEM (`status-warn`), domain.ts (`YearPlan.overBudget`), [VOICE.md §4.8](./VOICE.md).

### `PlanLabelBadge` — Phase 1
Short pill describing the strategy: "Packed · Best leverage", "Spread · Quarterly", "One big trip", etc.

- **States:** default.
- **Props:**
  - `strategy: PlanStrategy`
- **Depends on:** VOICE (canonical label per strategy).

### `BudgetUtilisationBar` — Phase 1
A thin progress bar showing `totalPto / annualPtoBudget`. Surfaces "unused PTO" warnings in `selective` mode.

- **States:** under-used (warn), near-full (default), fully-used (success).
- **Props:**
  - `used: number` · `budget: number`
- **Depends on:** DESIGN_SYSTEM (`status-*`), VOICE.

---

## Trip finder — focused secondary flow

> The classic single-trip mode — see [PROJECT.md §4.2](./PROJECT.md). Reachable from any annual plan card ("Tweak this trip…") or via a deep-linked URL.

### Trip finder hero (empty state) — *REMOVED ([ADR-008](../docs/DECISIONS.md), 2026-06-22)*

The single-break empty-state hero ("Single break" eyebrow / "Find your best trip." H1 / ranking lead) is **gone** with the single-break UI ([PROJECT.md §4.2](./PROJECT.md)). `/year`'s only empty state is the whole-year onboarding (`PlannerHero` + `HowItWorksSteps` + `PlannerDemo`).

### `OptimizerForm` — *REMOVED ([ADR-008](../docs/DECISIONS.md), 2026-06-22)*
The single-break planner form is **deleted** ([PROJECT.md §4.2](./PROJECT.md)), along with its `OptimizerFormWithPrefs` wrapper and tests. `/year` has one form — `AnnualPlannerForm` (whole-year). The `POST /api/trip` endpoint that consumed its URL is untouched.

### `AdvancedCalendarFilters` — Phase 3 · *new (extracted shared)*
The Filters + Calendars two-card grid inside the `/year` planner's Advanced settings —
extracted during the §4.2 fold when two forms shared it; now `AnnualPlannerForm` only
([ADR-008](../docs/DECISIONS.md)). Renders the `grid gap-4
lg:grid-cols-[3fr_2fr] lg:items-stretch` of a **Filters** card (`FilterPanel
variant="inline"`) + a **Calendars** card (`CalendarOverlayPicker`, shown when a country
is chosen).

- **Props:** `country: Iso3166Alpha2 | null`, `filters: Filters`, `onChange(f: Filters)`,
  `surface?: "surface" | "surface-elevated"` — the nested card background, chosen by the
  host's nesting context (`AnnualPlannerForm` is bare → inner cards `bg-surface-elevated`;
  the `surface` prop stays for any host that nests it inside an elevated card). Sizing/headings/`items-
  stretch` are identical regardless of `surface`, so heights always match.
- **Depends on:** `FilterPanel`, `CalendarOverlayPicker`, `@/lib/domain` (`Filters`).
- **Tests:** equal-height grid (`lg:items-stretch`, never `lg:items-start`); renders the
  Calendars card only when a country is set.

### `FormActionBar` — Phase 3 · *new (extracted shared)*
A presentational wrapper for a form's primary action row — the `surface-sunken` bar that
holds a live summary (`PlanSummary`) beside the submit button, used by both planners so
the bar styling stays in lockstep. Children-only; no logic.

- **Props:** `children: ReactNode` (the summary + the button).
- **Renders:** `flex flex-col items-stretch gap-3 rounded-lg bg-surface-sunken px-4 py-3
  sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-5 sm:py-4`.

> **SHIPPED 2026-06-21; rescoped 2026-06-22 (ADR-008).** `AdvancedCalendarFilters` +
> `FormActionBar` live in [`src/components/optimizer/`](../src/components/optimizer/).
> Since the single-break `OptimizerForm` was removed, `AdvancedCalendarFilters` is used by
> `AnnualPlannerForm` (`/year`, default `surface-elevated`) only; `FormActionBar` is used by
> `AnnualPlannerForm` and `CreateGroupForm`.

### Input components row — Phase 1 · *built*

![Input components — CountryPicker, AnnualPtoInput, MaxTripLengthInput, BudgetModeToggle](./figma/inputs-section.png)

The four primary annual-planner inputs. Detailed entries below.

### `CountryPicker` — Phase 1 · *built (Figma) · Slice 1 (code)*
Searchable country dropdown. Pre-selects by `Accept-Language`. Lists countries by display name in the user's locale.

Phase 1 extends this component with an **inline subdivision sub-listbox** for countries where the backend has ISO 3166-2 data (`DE`, `CH`, `US`, `GB`, `FR`, `CA`, `AU`, `ES`, `BE`, `IT`, `IN` as of Phase 1 — driven by `supportedSubdivisions`). The sub-listbox renders directly below the country combobox; for countries with no entries it does not render at all. This is the realisation of the "country alone isn't enough" decision recorded in [PROJECT.md §5](./PROJECT.md) and the `subdivision` field on [`domain.ts`](./domain.ts) `OptimizeInput` / `AnnualOptimizeInput`.

**Subdivision placement by host (Phase 3 relocation, [CALENDAR_OVERLAYS.md §6](../../backend/specs/CALENDAR_OVERLAYS.md)).** The inline sub-listbox is rendered only by hosts that own the subdivision inline — the **standalone `CalendarForm`** (where Country + Region sit together) and the `AccountSettingsForm`. In the **planner/optimizer hero** the `CountryPicker` renders **Country only** (the country drives the seeded calendar defaults); the subdivision moves into **Advanced settings** as the compact **Region control** (see [§ FilterPanel](#filterpanel--phase-1--slice-1-code--revamped-phase-2)). A host suppresses the inline sub-listbox by not passing `supportedSubdivisions` (or passing an empty map); the relocated Region control reads/writes the same `subdivision` field. The keyboard, display-name, and "country change clears subdivision" rules below are unchanged wherever the sub-listbox renders.

- **States:** idle, focused, open, searching, selected, disabled, error.
- **Props:**
  - `value: Iso3166Alpha2 | null`
  - `onChange: (next: Iso3166Alpha2) => void`
  - `supportedCountries: Iso3166Alpha2[]`
  - `locale: string` (for display names)
  - `subdivision: Iso3166Dash2 | null`
  - `onSubdivisionChange: (next: Iso3166Dash2 | null) => void`
  - `supportedSubdivisions: Partial<Record<Iso3166Alpha2, Iso3166Dash2[]>>` — countries absent from this map render no sub-listbox.
  - `size?: "default" | "hero"` (default `"default"`) — `"hero"` gives the country `<select>` the planner steppers' fixed control height; see "Hero row — one control height" below. The optimizer, `CalendarForm`, and `AccountSettingsForm` use the compact default.
  - `label?: string` (default `"Country"`) — the field label, rendered as **both** the visible `<span>` and the `<select>`'s `aria-label` (they never diverge). Self-context hosts pass **"Your country"** ([VOICE.md §4.23](./VOICE.md)); the neutral default keeps the shared primitive correct for another party's scope (`/overlap` parties 2–6). **SHIPPED 2026-06-25.**
- **Behaviour:**
  - Changing `value` (the country) **must** call `onSubdivisionChange(null)` in the same React commit. A subdivision can never persist across a country change — that's the "DE-BY then switch to US" footgun and the optimizer's input refinement rejects cross-country subdivisions anyway (see [`domain.ts`](./domain.ts) `OptimizeInput.subdivision` comment).
  - Keyboard: `Tab` exits the country combobox into the sub-listbox when it renders; otherwise `Tab` skips to the next form field. The sub-listbox uses the same combobox pattern (single listbox, type-to-filter).
  - Subdivision display name resolution: `new Intl.DisplayNames([locale], { type: "region" }).of("DE-BY")` returns "Bavaria" / "Bayern" depending on locale. Per [I18N.md §10](./I18N.md).
- **Right-edge gutter — aligns with adjacent steppers:** the chevron sits centred in a 48px right-edge area (matching the geometry of the `+` button in [`PtoInput`](#ptoinput--phase-1--slice-1-code), [`AnnualPtoInput`](#annualptoinput--phase-1--built), and [`MaxTripLengthInput`](#maxtriplengthinput--phase-1--built) when these components share a row). Native `<select>` chevrons are removed (`appearance: none`) and replaced with a `▾` glyph in `text-muted`, positioned so its visual centre lines up vertically with the adjacent `+` glyphs. The select's text padding (`pr-12`) reserves that 48px area so option labels never collide with the chevron. This is what makes a Country / PTO / Max-trip row read as a single horizontal rhythm rather than three inputs with different right margins.
- **Hero row — one control height (`size="hero"`):** when the picker shares the planner hero row with the two steppers, the host passes `size="hero"` so the country `<select>` takes the **same fixed control height as the steppers** rather than the compact default — realising [FIGMA_REFERENCE.md Decisions §1](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build) "hero inputs align at one height" (the country select is drawn at the stepper height, [§2](./FIGMA_REFERENCE.md#2-hero--plan-my-year) "PTO + max-trip stay top-aligned with the CountryPicker"). The default size stays compact (`py-2`) everywhere the picker does **not** share a row with the tall steppers — the trip finder (where it pairs with the compact [`PtoInput`](#ptoinput--phase-1--slice-1-code)), `CalendarForm`, and `AccountSettingsForm`.
- **Depends on:** I18N (display-name resolution), ACCESSIBILITY (combobox pattern), domain.ts (`Iso3166Alpha2`, `Iso3166Dash2`).

### `PtoInput` — Phase 1 · *Slice 1 (code)*
Number input with stepper buttons for PTO budget. Min 0, max 60.

- **States:** idle, focused, disabled, error.
- **Props:**
  - `value: number`
  - `onChange: (next: number) => void`
  - `min?: number` (default 0)
  - `max?: number` (default 60)
- **Depends on:** ACCESSIBILITY (spinbutton pattern).

### `FilterPanel` — Phase 1 · *revamped Phase 2 · UX-11*
The filter surface for both flows — **the travel window** (the `TravelWindowPicker`, UX-11, replacing the old always-on 12 month-chips), weekday exclusions, and a blocked-date editor. Originally a stack of raw checkboxes; revamped to the design-system **filter-chip** scale ([DESIGN_SYSTEM.md §2.6](./DESIGN_SYSTEM.md#26-filter-chips-toggle), [FIGMA_REFERENCE.md § Decisions §4](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build)) so it reads as designed, not unstyled. Two presentations from one component:

- **`panel`** (default) — a self-contained disclosure card. Used anywhere the panel owns its own collapse.
- **`inline`** — the fieldsets only, no disclosure chrome. Used inside the annual planner's **Advanced settings** disclosure (`AnnualPlannerForm`), which provides the single collapse so the controls don't double-nest. This is what wires the planner to the "full filters" promised in [PROJECT.md §5](./PROJECT.md) — previously the planner sent empty filters. In the planner the inline fieldsets are wrapped in a titled **"Filters"** card (a static header + `border` divider, mirroring the panel's expanded look minus the collapse) sitting **adjacent to the `CalendarOverlayPicker` card** — the **Filters | Calendars** card pair (see [CalendarOverlayPicker § Layout](#calendaroverlaypicker--phase-3)).

**Region lives here, not in the hero** ([CALENDAR_OVERLAYS.md §6](../../backend/specs/CALENDAR_OVERLAYS.md)). The hero shows **Country only** — the country drives the seeded calendar defaults — and the **Region** control moves into **Advanced settings**, rendered as a **compact Region control** (a redesign of the former hero sub-listbox: a small labelled `<select>` of the country's ISO 3166-2 subdivisions, surfacing only when the selected country has subdivisions, absent otherwise). It reads/writes the same `subdivision` field (`OptimizeInput`/`AnnualOptimizeInput`) and follows the same "changing the country clears the region in the same commit" rule. See [§ CountryPicker](#countrypicker--phase-1--built-figma--slice-1-code) for the (now hero-only) country control and the relocated subdivision behaviour.

**Collapsed summary (panel variant).** When collapsed with filters active, the header renders a one-line **summary** of what's set plus a count badge — e.g. "3 months · No Fri · 1 blocked" with a `5` badge — so the user sees their constraints without expanding. Empty = just "Filters" and the chevron, no badge. The badge counts total constraints (`months.length + excludeWeekdays.length + blockedRanges.length`).

**When can you travel? / Exclude weekdays.** The travel window (the `months` field) is delegated to the `TravelWindowPicker` (UX-11) — "Any time" by default, expanding to quarter quick-picks + the month grid on demand (see § TravelWindowPicker). **Exclude weekdays** stays a wrap row of **toggle chips** (small-pill scale, `radius-full`). Unselected: `surface-sunken` fill, `border` hairline, `text-muted`. Selected: `accent` fill, `accent-contrast` text, with a leading check glyph — the check is the non-colour signal required by [ACCESSIBILITY.md §3](./ACCESSIBILITY.md) (colour alone never carries the state). Each chip is a `<label>` wrapping a visually-hidden native `<input type="checkbox">`, so multi-select semantics, keyboard focus, and `aria-checked` are native and screen-reader-correct.

**Blocked dates.** A start → end date pair + "Add". Committed ranges render as **removable chips** ("14 Mar – 20 Mar ✕"); the ✕ is a `<button aria-label="Remove blocked dates …">`. An end-before-start entry shows an inline error and never commits ([ERROR_STATES.md §2.7](./ERROR_STATES.md)) — unchanged behaviour, restyled.

**Clear filters.** When any filter is active, a `Button / Ghost` "Clear filters" ([VOICE.md §4.1](./VOICE.md)) sits in the panel footer (panel variant) / below the fieldsets (inline variant) and resets `value` to the empty `Filters`.

- **States:** collapsed-empty, collapsed-active (summary + badge), expanded, expanded-active (Clear visible), blocked-range-error, inline (no disclosure).
- **Props:**
  - `value: Filters` (from domain.ts)
  - `onChange: (next: Filters) => void`
  - `variant?: "panel" | "inline"` (default `"panel"`)
  - `defaultOpen?: boolean` (panel variant only)
- **Accessibility:** disclosure button with `aria-expanded` / `aria-controls`, `Space`/`Enter` toggles ([ACCESSIBILITY.md §4.4](./ACCESSIBILITY.md)); chips are real checkboxes (a multi-select group) with a non-colour selected signal; remove buttons and Clear are real `<button>`s with accessible names; touch targets ≥ 24 px ([ACCESSIBILITY.md §8](./ACCESSIBILITY.md)).
- **Voice / copy:** legends "Months", "Exclude weekdays", "Blocked dates"; weekday helper "We won't spend PTO on these days."; blocked helper "Trips that touch these dates are skipped."; reset "Clear filters". Sentence case ([VOICE.md §4.6](./VOICE.md)).
- **Depends on:** domain.ts (`Filters`), [DESIGN_SYSTEM.md §2.6](./DESIGN_SYSTEM.md#26-filter-chips-toggle) (filter-chip tokens/scale), [VOICE.md §4.6](./VOICE.md), [ACCESSIBILITY.md §3/§4.4](./ACCESSIBILITY.md), [ERROR_STATES.md §2.7](./ERROR_STATES.md).
- **Future groups (not built — tracked in [ROADMAP.md → Filter expansion](../../../ROADMAP.md)):** custom workweek, holiday-type inclusion, minimum-efficiency floor, trip-length bounds. (The "season quick-picks" group shipped as the `TravelWindowPicker`, UX-11.)

### `CalendarOverlayPicker` — Phase 3
The personal-calendar control for the planner's **Advanced settings** ([roamgap-backend CALENDAR_OVERLAYS.md §6/§7](../../backend/specs/CALENDAR_OVERLAYS.md)). **One list — the calendars you observe**, seeded from the country's granted set and edited freely. It reads and writes `Filters.observedCalendars` (which replaces the old `visibleCalendars` + `calendarOverlays` pair). **There are no visibility checkboxes.**

- **Seeded `granted` rows.** On mount the list is pre-filled from `defaultObservedCalendars(country)` (§ frontend helper below): one row per calendar the country grants, each `{ calendar, scope: "granted", mode: "free" }`. Labelled with the country in parentheses — e.g. *Gregorian (Kenya)*, *Islamic (Kenya)* — because these are the country's gazetted dates. Only granted calendars that actually have days render (incl. estimated future grants, CALENDAR_OVERLAYS.md §7); a never-granted calendar is simply absent.
- **Addable `full` datasets.** The user adds a calendar's complete dataset as a `{ calendar, scope: "full", mode }` row — labelled *Islamic (worldwide)*, *Hindu*, *Chinese*, *Hebrew*, *Gregorian (common)*. A `full` row carries a **worldwide** hint — "Worldwide date — your country may observe it ±1 day" — because its dates are the global reckoning, not the country's gazette ([CALENDAR_OVERLAYS.md §3/§6](../../backend/specs/CALENDAR_OVERLAYS.md)).
- **Every row** = a label + a **Free / PTO** segmented toggle + a remove (✕). The toggle flips that row's `mode` in place; the ✕ drops the row. `granted` and `full` of the **same** calendar may both appear as separate rows (they union — the country-accurate Eids plus the rest of the Islamic year, CALENDAR_OVERLAYS.md §5).
- **"Add calendar"** control offers the `(calendar, scope)` pairs **not already present** — each remaining granted calendar (re-addable if the user removed it) and each `full` dataset. Adding appends a `{ calendar, scope, mode: "free" }` row.

**Removing a row drops that `(calendar, scope)` entirely** — dropping the *Gregorian (Kenya)* `granted` row is how a user disables their country's public holidays ([CALENDAR_OVERLAYS.md §2](../../backend/specs/CALENDAR_OVERLAYS.md) "anything not in the list is not observed").

**Layout** ([Figma node `308:504`](./FIGMA_REFERENCE.md#phase-3--calendar-overlays-surface-added-2026-06-09)). A self-contained card — `surface-elevated` fill, `border` hairline, `radius-12` — titled *Calendars you observe*: a vertical stack of rows separated by `border` hairlines, with the **Add calendar** control in the card footer. In the planner's **Advanced settings** it is presented as a peer card **beside the `FilterPanel`** (the adjacent **Filters | Calendars** layout — adjacent on wide viewports, stacked on narrow), so the disclosure opens onto two tidy sections rather than one long flat stack. **Responsive:** on wide viewports the **Filters | Calendars** pair render at **equal height** (the grid row stretches both cards, `lg:items-stretch`) for a uniform look. Each observed-calendar row is `flex flex-wrap`: on mobile the row keeps its **remove (✕) on the label line** (pushed to the right edge, `ml-auto`) with the Free/PTO toggle dropping to a second line beneath — the ✕ never wraps to its own line; from `sm:` up the row is a single line *label … toggle ✕*.

- **States:** default (the seeded country `granted` rows, all `free`), edited (≥1 row removed/added/re-moded → explicit `observedCalendars`), has-full (≥1 `full` dataset row present, showing its worldwide hint), granted-and-full (a calendar's `granted` and `full` rows both present), empty (every row removed → explicit `[]`, the "I observe nothing my country grants" edge).
- **Props:**
  - `value: Filters` (from domain.ts — reads `observedCalendars`)
  - `onChange: (next: Filters) => void`
  - `country: Iso3166Alpha2` — drives the seed via the frontend `defaultObservedCalendars(country)` helper. Changing `country` re-seeds the default and, while `observedCalendars` is still the (omitted) default, the list follows the new country; once the user has edited away from the default the explicit list is kept.
- **Behaviour:**
  - **Omitted-by-default.** While the list equals `defaultObservedCalendars(country)`, the component emits `observedCalendars: undefined` (the canonical "country default" form, CALENDAR_OVERLAYS.md §2/§7) — an untouched picker keeps the URL/request clean. The first edit away from the default writes the explicit, complete array.
  - **Add / remove / toggle** all rewrite the whole `observedCalendars` array. Entries are unique by `(calendar, scope)`; the Add control never offers a pair already present, so a duplicate can't be formed. There is **no override** — adding a `full` dataset never removes the matching `granted` row.
  - Emitting the array uses the canonical sort order ([URL_FORMAT.md §3.1 `obs`](./URL_FORMAT.md)) so the value round-trips byte-stable.
- **Accessibility:** the list is a labelled group (`fieldset` + `legend` "Calendars you observe"); each row exposes its label as the row's accessible name. The Free / PTO toggle is a radio-group pair (`role="radiogroup"`, `aria-label` "{label} days") with `aria-checked`; the active mode carries the check glyph as the non-colour signal (§3). The worldwide hint on a `full` row is text (reachable via the row's accessible description), never colour alone. Remove (✕) and Add are real `<button>`s with accessible names ("Remove {label}", "Add calendar"). Touch targets ≥ 24 px (§8).
- **Voice / copy:** card legend "Calendars you observe" with the lead "Your country's are added for you." ([VOICE.md §4.7](./VOICE.md)); granted vs full labels distinguished as "… (Kenya)" vs "… (worldwide)"; the per-row toggle reads "Free day" / "Spend PTO" with helper "Free = a day off; PTO = you spend a leave day."; the worldwide hint copy per [VOICE.md §4.7](./VOICE.md). Sentence case (§8). No religious framing beyond the calendar's proper name — we never infer a belief.
- **Type note:** consumes `Filters`, `ObservedCalendar`, `ObservableCalendar`, `CalendarScope`, `CalendarMode`, `Iso3166Alpha2` from [domain.ts](./domain.ts) — UI never re-defines them. `defaultObservedCalendars(country)` is a UI-local helper (mirrors the backend country→granted-calendars map, CALENDAR_OVERLAYS.md §7); it returns `ObservedCalendar[]` and never crosses an HTTP/DB boundary, so it is not in `domain.ts`.
- **Depends on:** domain.ts (`Filters.observedCalendars`, `ObservedCalendar`, `ObservableCalendar`, `CalendarScope`, `CalendarMode`, `Iso3166Alpha2`), [DESIGN_SYSTEM.md §2.6](./DESIGN_SYSTEM.md#26-filter-chips-toggle) (chip/toggle scale), [VOICE.md §4.7](./VOICE.md), [ACCESSIBILITY.md §3/§4.5](./ACCESSIBILITY.md), [roamgap-backend CALENDAR_OVERLAYS.md §2/§3/§5/§6/§7](../../backend/specs/CALENDAR_OVERLAYS.md).
- **Hosted by:** the planner's Advanced disclosure (`AnnualPlannerForm`) and the optimizer form (`OptimizerForm`), beside the `FilterPanel`, so the values ride in the request `filters` (and the shareable `/year` URL — both modes, [URL_FORMAT.md](./URL_FORMAT.md)). The host passes the form's selected `country` so the seed tracks the country control. Pre-fills from a signed-in user's stored `observedCalendars` when present (CALENDAR_OVERLAYS.md §7, `UserPatch`).

### `SortToggle` — *new ([ADR-008](../docs/DECISIONS.md))*
The three-way result sort on the `/year` results ([PROJECT.md §4.2](./PROJECT.md), [VOICE.md §4.22](./VOICE.md)). A pill group of `efficiency` · `length` · `date`, labelled **"Most efficient"** / **"Longest first"** / **"By date"** (the existing `optimizer.result.sortEfficiency` / `sortLength` / `sortDate` copy, read via `tFor` so no `IntlProvider` is required). The active pill is `aria-pressed`. It re-uses the pills of the retired single-break `ResultsHeader`, but here the effect is a **client-side reorder** of the selected plan's already-computed trips (`ResultsClient`), not a re-query — it sets local state, not the URL. **By date** (chronological) is the default ([ADR-008](../docs/DECISIONS.md)).

- **States:** one of the three active.
- **Props:**
  - `value: SortKey` — the active sort.
  - `onChange: (next: SortKey) => void`.
  - `locale?: string` (default `en-KE`).
- **Depends on:** [domain.ts](./domain.ts) (`SortKey`), `@/i18n/terms` (`tFor`), [VOICE.md §4.22](./VOICE.md).
- **Tests:** renders all three options; the active one is `aria-pressed`; clicking one fires `onChange` with its key.

> `ResultsHeader` (the single-break query-echo + sort header) and `TripList` (the vertical `TripCard` list) are **REMOVED** ([ADR-008](../docs/DECISIONS.md)) with the single-break UI — the whole-year results render `TripCard`s directly in a grid (`ResultsClient`, [PROJECT.md §4.2](./PROJECT.md)).

### `TripCard` — Phase 1 · *built · header decluttered (§4.1.3) · selection checkbox (§4.2.1) → whole-card click-to-select (item 8) · grid `wide` variant (ADR-008)*

![TripCard — Rank 1 (5.00) and Rank 2 (3.00) for the German Easter cluster](./figma/trip-card.png)

A single ranked trip. **Header revamp (§4.1.3 declutter, then §4.2.1 selection).** Testers found the header carried too much — rank number, date, a prominent coloured efficiency badge, and a row of action buttons all competing. The header is now the **date** (the primary fact) + a single **selection checkbox** (when `selectable`), and the **efficiency moved out of the header into the footer stat row** (Total · Leave · **Efficiency**). Efficiency renders as a plain tabular footer stat (`5.00×`), not the coloured `ScoreBadge` pill — for annual plans the ratio is ~1.5 and a brick-red `score-low` pill read as a warning, which it isn't. The `#1` / `#2` rank stays as a faint eyebrow above the date row (with the over-budget marker). Body is a `CalendarStrip`; the bridge-day line stays.

**Selection, not per-card actions (§4.2.1).** The card no longer carries an inline "Add to itinerary" toggle or an `ExportCalendarButton` (Export `.ics` / Add to Google Calendar). Those stacked up to three buttons on every card; they are replaced by **one selection checkbox**, and the actions live in the shared `TripSelectionBar` (§ below) that appears once any card is selected. `selected` drives the checkbox's checked state; `onToggleSelect(trip)` toggles the **transient** selection set the list wrapper owns (selection is UI-only — it does not itself write the itinerary draft). **Responsive:** on mobile the header **stacks** (`flex-col`) — the date range takes its own full-width line above the checkbox — so a long localized date range is never squeezed; from `sm:` up the header is a single `sm:justify-between` row. Rendered on the whole-year results surface (`ResultsClient`) ([PROJECT.md §4.2.1](./PROJECT.md)).

**Whole-card click-to-select → transparent (item 8).** The `Select` checkbox is replaced by selecting **the card itself**: when `selectable`, the whole `article` is a toggle (`role="button"`, `aria-pressed={selected}`, Enter/Space) and a `selected` card — its whole surface and contents — drops to **transparent** (an `opacity-50` + `ring-accent` treatment) so the chosen set reads at a glance. Clicking a card did nothing before, so the gesture is free. `onToggleSelect(trip)` still owns the transient selection set; `selected` now drives the card's transparency instead of a checkbox's checked state. The accessible name remains the trip's date range. This same affordance is added to `OverlapResultCard` (groups) — one shared pattern ([PROJECT.md §4.2.1](./PROJECT.md), [GROUPS.md §4.2](./GROUPS.md)).

**`wide` variant (grid odd-card, [ADR-008](../docs/DECISIONS.md)).** The whole-year results lay cards out **two per row** (`ResultsClient`). When `wide` is set — used for the odd final card, which spans the full grid width — the card body switches from stacked (calendar above the stat row) to **side-by-side**: the `CalendarStrip` on the left and the Total · Leave · Efficiency stat row on the right (`sm:flex sm:items-center sm:gap-8`), filling the row instead of stranding an empty gutter. The header, checkbox, and bridge line are unchanged. `wide` defaults to `false` (two-up cards keep the stacked body).

- **Over budget** (`Trip.overBudget === true`) — the trip's PTO exceeds the budget because of `pto`-mode observed-calendar required days the user committed to ([CALENDAR_OVERLAYS.md §5.1](../../backend/specs/CALENDAR_OVERLAYS.md) "always take them, flag overflow"). The card surfaces a small, honest **"Over budget"** marker (a `status-warn` pill beside the rank eyebrow) with the explanation reachable via `title`. The word "Over budget" — not colour — carries the meaning (§3). It is never a rejection: the trip is still shown; the marker only tells the truth that the plan spends more leave than the stated budget.
- **States:** default, hover, focused, selected (checkbox checked), **over-budget**.
- **Props:**
  - `trip: Trip`
  - `rank: number` (1-based)
  - `bridgeOpportunities?: BridgeDay[]`
  - `selectable?: boolean` — render the selection checkbox (default off; existing non-selectable call sites unchanged).
  - `selected?: boolean` — the checkbox's checked state.
  - `onToggleSelect?: (trip: Trip) => void` — toggle this trip in the list wrapper's transient selection.
- **Voice / copy:** the over-budget marker reads "Over budget" with `title` "This trip spends more PTO than your budget — the extra is the days you chose to take off." ([VOICE.md §4.8](./VOICE.md)). The checkbox's accessible name is the trip's date range.
- **Depends on:** CalendarStrip, OverBudgetMarker, domain.ts (`Trip.overBudget`), DESIGN_SYSTEM (`status-warn`), [VOICE.md §4.8](./VOICE.md). (The per-card `ExportCalendarButton` was removed in §4.2.1 — export is now in `TripSelectionBar`.)

### Day cell variants — Phase 1 · *built (Figma) · Slice 1 (code)*

![DayCell — Weekend (W), Holiday (H), PTO (P), Blocked (B), Bridge (*)](./figma/day-cells.png)

The five day classifications. Each is rendered as a `DayCell / {Kind}` component. The single-letter kind code below the day number is **mandatory** — it's the non-colour signal required by [ACCESSIBILITY.md §3](./ACCESSIBILITY.md). Bridge cells additionally render with a dashed border.

### `CalendarStrip` — Phase 1 · *built (Figma) · Slice 1 (code)* · *estimated-day state built (Figma) Phase 3*

![CalendarStrip — Easter trip 2026, efficiency 5.00](./figma/calendar-strip.png)

Week-stacked grid for a Trip. The trip's days flow into **7-column weeks** (weekday-aligned via `weekStartsOn`; leading/trailing blanks pad the first and last weeks, exactly like `MonthGrid`), with **one shared weekday-label row along the bottom** rather than a weekday label repeated under every cell. Each day reuses `DayCell` (date number + kind letter + kind-coloured background). This mirrors `MonthGrid`'s grid language but flips the weekday header to the foot, so the strip reads as one family with the standalone calendar — and stacking keeps a long trip from overflowing its `TripCard` horizontally (the original single-row strip ran off the right edge for month-long trips). Keyboard-navigable ([ACCESSIBILITY.md §4.2](./ACCESSIBILITY.md)).

- **States:** default, focused-on-day(d), hovered-on-day(d), **estimated-day(d)**, **observance-day(d)**.
- **Estimated days** (`DayComposition.estimated === true`) — a holiday projected from last year, not yet granted this year (`roamgap-backend/specs/HOLIDAY_PROJECTION.md`) — render in the **holiday colour with a dashed outline** instead of a solid fill, and the day's accessible name appends "(estimated)". Non-colour signal per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md) — the dashed border + "(estimated)" word carry it, not colour alone.
- **Observance days** (`DayComposition.overlay === true`) — a **personal calendar** the user observes (`source: "overlay"`, [CALENDAR_OVERLAYS.md §6](../../backend/specs/CALENDAR_OVERLAYS.md)): a day *this user* chose to observe, neither granted nor projected. Rendered with a treatment **distinct from both granted (solid fill) and projected (dashed)** — the `accent` colour as a **dotted** outline — and the accessible name appends "(personal observance)". The dotted-vs-dashed border style plus the "(personal observance)" word are the non-colour signal (§3); this only governs a `free` observance surfaced on the holiday list (a `pto` observance is the user's own leave and renders as a normal `PTO` cell — see the limitation note below).
  - **Worldwide dates** (`DayComposition.worldwide === true`) — layered *on top of* the observance treatment when the day comes from a `full`-scope calendar (the global reckoning, not the country's gazette, [CALENDAR_OVERLAYS.md §3/§6](../../backend/specs/CALENDAR_OVERLAYS.md)). The accessible name additionally appends "(worldwide date)" and the cell carries a hint — "Worldwide date — your country may observe it ±1 day" — via `title`. The "(worldwide date)" word is the non-colour signal (§3); `worldwide` never affects `ptoDays`, efficiency, or the over-budget computation. It can ride on an `estimated` day too (a `full`-scope lunar date is both projected and worldwide).
  - **Limitation (v1).** A `pto` observance required-day is threaded into the algorithm as a bare date set (`requiredDaysOff`), carrying no provenance into the cell builder, so it renders as an ordinary `PTO` (`P`) cell — correct and honest (it *is* a leave day the user spends) but not visually tagged as an observance. Only `free` observances (which flow as `source: "overlay"` holidays) get the dotted observance treatment and any `worldwide` hint. Tagging the `pto` case would mean threading provenance through the pure budget path; deferred as it crosses the algorithm's required-days contract ([CALENDAR_OVERLAYS.md §5.1](../../backend/specs/CALENDAR_OVERLAYS.md)).
- **Props:**
  - `composition: DayComposition[]`
  - `weekStartsOn?: Weekday` (default 1 = Monday) — the first column of each week, and the day the leading-blank padding aligns to.
- **Depends on:** DESIGN_SYSTEM (day-* tokens, `accent`), domain.ts (`DayComposition.estimated`, `DayComposition.overlay`, `DayComposition.worldwide`), ACCESSIBILITY (keyboard pattern, §3 non-colour signal), `MonthGrid` (shared week-grid + letter convention).
- **Hard rule:** every cell renders its `kind` in **text** as well as colour. See ACCESSIBILITY §3.
- **Layout rule:** the weekday labels are a **single shared row at the bottom** (decorative, `aria-hidden` — each cell's accessible name already carries its weekday); the trip never renders one label per day. The grid is at most 7 columns wide, so a `TripCard` holds a month-long trip without horizontal overflow.

### `ScoreBadge` — Phase 1 · *built (Figma) · Slice 1 (code)* (3 variants: High, Mid, Low)
The efficiency number, rendered with tabular numerals. Colour comes from the `score-*` semantic role. Three variants: `/ High` (green, ≥3.0), `/ Mid` (amber, 2.0–2.99), `/ Low` (brick red, <2.0).

- **States:** default, hover (shows tooltip with formula).
- **Props:**
  - `efficiency: number`
  - `precision?: number` (default 2)
- **Depends on:** DESIGN_SYSTEM (`score-high|mid|low`), VOICE (tooltip copy).

### `EstimatedBadge` — Phase 3 · *built (Figma)*
A small inline badge rendered beside a holiday whose date is a forward-looking **estimate** (`Holiday.estimated === true`) — typically a future Islamic or Hindu date that depends on a moon sighting or lunar-calendar confirmation. It states honest uncertainty instead of false precision; it never predicts.

- **States:** default. Renders **nothing** when `estimated` is `false` (the common case), so it can be dropped beside any holiday unconditionally.
- **Props:**
  - `estimated: boolean`
  - `calendarBasis?: CalendarBasis` — tunes the tooltip (e.g. "subject to a moon sighting" for `islamic`).
- **Behaviour:** renders an inline pill labelled **"Estimated"** with a tooltip in the honest-never-predictive register of [VOICE.md §2](./VOICE.md) (historical-data uncertainty). The word "Estimated" — not colour — carries the meaning.
- **Accessibility:** non-colour signal per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md) (the text label); the explanation is reachable via `title` and the badge carries an accessible name.
- **Type note:** consumes `CalendarBasis` and `Holiday` from [domain.ts](./domain.ts) — UI never re-defines them.
- **Depends on:** domain.ts (`Holiday.estimated`, `CalendarBasis`), [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (small-pill/badge scale), [VOICE.md §2/§3](./VOICE.md), [ACCESSIBILITY.md §3](./ACCESSIBILITY.md).

### `ShareButton` — Phase 1 · *Slice 1 (code)*
Copies the current optimizer URL to clipboard. Shows toast on success.

- **States:** idle, copying, copied, error.
- **Props:**
  - `getUrl: () => string`
- **Depends on:** VOICE (toast copy).

---

## Calendar — standalone reference view

> The third tool surface, alongside the planner and optimizer — see [PROJECT.md §4.4](./PROJECT.md). A whole-year reference calendar showing weekends and public holidays in the standard day theme. No PTO, no trips, no optimization. Reuses the `day-*` tokens and the `DayCell` letter-code convention ([ACCESSIBILITY.md §3](./ACCESSIBILITY.md)), so it reads as part of the same family as `CalendarStrip`.

### `MonthGrid` — Phase 1

A single calendar month: a heading (localized month name), a weekday header row, and a 7-column grid of day cells (leading/trailing blanks pad the first and last weeks). Each in-month day shows its date number; **weekend** cells take `day-weekend` + a "W" code, **holiday** cells take `day-holiday` + an "H" code and surface the holiday name via `title` + accessible label, and regular **workday** cells are plain (`surface`, no code — there is no colour to disambiguate, so no non-colour signal is required per §3). Purely presentational and server-renderable. **Responsive:** the weekday header and each week are CSS grids of seven equal columns (`grid-cols-7`) whose cells are `aspect-square`, so the month **fills its container width** and stays square at any size — no fixed-`px` cell width leaving dead space on the right of the card. The roving-tabindex keyboard model is index-based and unaffected by the grid swap; on the wide year view each tile simply scales its cells to fill the tile.

- **States:** default, focused-on-day(d), estimated-day(d).
- **Props:**
  - `month: CalendarMonth` — UI-local shape `{ month: 1..12; days: CalendarDay[] }`, where `CalendarDay = { date: IsoDate; weekday: Weekday; kind: "weekend" | "holiday" | "workday"; holidayName?: string; estimated?: boolean }`. Derived server-side from a `Holiday[]` list (the `/api/holidays` response) plus the weekend pattern; **not** added to [`domain.ts`](./domain.ts) because it never crosses an HTTP/DB/AI boundary (per [AGENTS.md §6](../AGENTS.md)).
  - `weekStartsOn?: Weekday` (default 1 = Monday)
  - `locale?: string` (month + weekday names; default `en-KE`)
- **Hard rule:** every coloured cell renders its kind as a letter as well as colour (§3). Cells meet the 24 × 24 px target and expand to 44 × 44 px on coarse pointers (§8).
- **Estimated days** (`CalendarDay.estimated === true`) — a holiday whose date is a forward-looking estimate (lunar/observed, or projected; [roamgap-backend HOLIDAY_PROJECTION.md](../../backend/specs/HOLIDAY_PROJECTION.md)) — render in the **holiday colour as a dashed outline** instead of a solid fill, keep the "H" code, and append "(estimated)" to the cell's accessible name + `title`. Identical treatment to `DayCell / Estimated` / `CalendarStrip` (the estimated-day state), so the standalone calendar reads as one family. Non-colour signal per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md): the dashed border + "(estimated)" word carry it, not colour.
- **Depends on:** DESIGN_SYSTEM (`day-*` tokens), ACCESSIBILITY (§3, §4.6 grid pattern), I18N (month/weekday names).

### `YearCalendar` — Phase 1

The whole-year view: twelve `MonthGrid`s plus year navigation. **Responsive contract** ([PROJECT.md §4.4](./PROJECT.md)): on a wide viewport all twelve months tile in a grid; on a narrow viewport the view collapses to one month at a time with a month pager (prev/next + a direct month `<select>`) and prev/next-year controls — the visitor slices in and out of months and years. All twelve months are always in the DOM server-side (no-JS shows the full stacked year); the single-month paging is a client enhancement that is a no-op on the first render so there is no hydration mismatch.

- **States:** wide (all months tiled), paged (single month, narrow), loading (skeleton), empty (no country), error.
- **Props:**
  - `year: number`
  - `months: CalendarMonth[]` (length 12)
  - `prevYearHref: string` · `nextYearHref: string`
  - `initialMonth?: 0..11` (the month shown first when paged — the page passes the current month for the current year, else January)
  - `locale?: string`
- **Accessibility:** year nav and month pager are real `<a>` / `<button>` elements with accessible names ("Previous year", "Next month", "Jump to month"); the month `<select>` is a labelled native control. Each `MonthGrid` is an independent grid widget (§4.6).
- **Legend.** The non-colour day key lists Weekend (W), Holiday (H), and **Estimated** (a dashed-outline holiday swatch) — the explanation of `MonthGrid`'s estimated cells, in the honest-never-predictive register ([VOICE.md §3](./VOICE.md)).
- **Depends on:** MonthGrid, DESIGN_SYSTEM (breakpoints, `day-*`), ACCESSIBILITY (§4.6), VOICE (§4.7 nav + control copy).

### `CalendarForm` — Phase 1

The picker above the calendar: `CountryPicker` (with the inline subdivision sub-listbox) + a year input. On submit it navigates to the canonical `/calendar?country=…&sub=…&year=…` URL ([URL_FORMAT.md §3.1](./URL_FORMAT.md)). No PTO or filter inputs — the calendar ignores them.

- **States:** idle, country-selected, disabled (no country).
- **Props:**
  - `initial?: { country: Iso3166Alpha2 | null; subdivision: Iso3166Dash2 | null; year: number }`
  - `locale?: string`
- **Depends on:** CountryPicker, domain.ts (`Iso3166Alpha2`, `Iso3166Dash2`, `Year`), VOICE (§4.7).

---

## State surfaces

### `EmptyState` — Phase 1 · *Slice 1 (code)*
Generic empty pattern: a **dashed-border card** holding a `headline` (subtitle) and an
optional grey `body` line, centred. The single canonical empty/default state across the
app — `/solo` ("No itineraries yet."), `/groups` (signed-out + empty list), `/calendar`
("See a country's year."), and the group sub-results all render this same card. The full
Phase 1 inventory of empty states lives in [ERROR_STATES.md §2](./ERROR_STATES.md).

- **The CTA lives _outside_ the card.** When an empty state needs an action, the page
  composes the button as a **sibling below** the `EmptyState`, at the primary-button
  scale ([DESIGN_SYSTEM.md §11.1](./DESIGN_SYSTEM.md) — `text-sm`), the way `/groups`
  renders *Create group* under its empty card. The card itself carries **no** button —
  the old inside-the-card `action` prop is removed (it was unused, and an in-card CTA
  competes with the page's real action; item 13 declutter, [ERROR_STATES.md §2.20](./ERROR_STATES.md)).
- **States:** default.
- **Props:**
  - `headline: string`
  - `body?: string`
- **Depends on:** VOICE, DESIGN_SYSTEM (§11 button scale for any sibling CTA).

### `ErrorState` — Phase 1 · *Slice 1 (code)*
Generic error pattern. Used for 4xx/5xx and unexpected failures. Includes a "retry" affordance when appropriate. The full Phase 1 inventory of error states lives in [ERROR_STATES.md §2](./ERROR_STATES.md).

- **States:** default.
- **Props:**
  - `error: ApiError | { message: string }`
  - `onRetry?: () => void`
- **Depends on:** domain.ts (`ApiError`), VOICE (error copy), ERROR_STATES (taxonomy).

### `Toast` — Phase 1 · *Slice 1 (code)*
Transient notification. One at a time, top-right on desktop, top-center on mobile.

- **States:** entering, visible, exiting.
- **Props:**
  - `kind: "success" | "warn" | "danger"`
  - `message: string`
  - `duration?: number` (default 4000 ms)
- **Depends on:** ACCESSIBILITY (live region).

### `CalendarLoader` — *new · tester feedback (loading + mobile visibility)*
The first-class loading indicator for the optimizer compute ([VOICE.md §4.2](./VOICE.md)) — an animated, on-brand year calendar that reads as "scanning the year for trips," replacing a bare spinner for the one wait that always crosses the threshold. Twelve month cells (Jan→Dec) brighten in sequence — a shimmer that sweeps **forward through the year** — beneath a present-continuous caption. Stands in the results region while a `/year` request is in flight (either mode), and is the scroll target that brings a mobile user to where the result will land.

- **States:** animating (default); reduced-motion — the sweep collapses to a static strip because the global `prefers-reduced-motion` rule ([DESIGN_SYSTEM.md §7.3](./DESIGN_SYSTEM.md)) kills the animation; the caption + month strip stay.
- **Props:**
  - `label?: string` (default "Calculating trips…", the [VOICE.md §4.2](./VOICE.md) caption; a caller may pass a translated/locale string)
- **Behaviour:** the animated strip is decorative (`aria-hidden`); the component is a `role="status"` `aria-live="polite"` region that announces the caption to assistive tech. Motion uses the [DESIGN_SYSTEM.md §7](./DESIGN_SYSTEM.md) duration/easing scale and composes from the built-in pulse — no bespoke timer — so the global reduced-motion rule neutralises it for free.
- **Used by:** `AnnualPlannerForm` (`/year`) while the optimizer request is pending (§ Annual planner — Finding state).
- **Depends on:** DESIGN_SYSTEM (`day-*` colours, motion §7), ACCESSIBILITY (live region §7), VOICE (§4.2 caption).
- **Stage-2 Figma:** the month strip mid-sweep + caption (Light + Dark).
- **Tests:** renders a `role="status"` carrying the caption; the animated strip is `aria-hidden`; a custom `label` overrides the default caption.

---

## Content

### `DocPage` — Phase 1 · *Slice 1 (code)*
Wraps a server-rendered Markdown content page. Adds typographic styling (`prose`-like) and the JSON-LD `Article` block.

- **States:** default.
- **Props:**
  - `title: string`
  - `description: string`
  - `datePublished: string`
  - `dateModified: string`
  - `children: ReactNode`
- **Depends on:** `src/lib/seo/json-ld.ts`.

### `CalloutBox` — Phase 1 · *built (Figma) · Slice 1 (code)*
Inline content block for callouts inside docs and result pages. Drawn as the `CalloutBox` ComponentSet (Figma node `411:38`, three `kind=` variants); see [FIGMA_REFERENCE.md § CalloutBox + /trip results pattern](./FIGMA_REFERENCE.md#calloutbox--trip-results-pattern-added-2026-06-11). The 4px left bar carries the kind — `accent` (info), `status-warn` (warn), `status-success` (success) — alongside the title text, never colour alone.

- **States:** info, warn, success.
- **Props:**
  - `kind: "info" | "warn" | "success"`
  - `title?: string`
  - `children: ReactNode`
- **Used by:** docs and result pages for inline callouts. (The single-break lost-to-weekend + efficiency-sort budget callouts it formerly hosted were retired with the single-break UI, [ADR-008](../docs/DECISIONS.md).)
- **Depends on:** DESIGN_SYSTEM (`status-*`), VOICE.

### Legal pages — Phase 2 (route composition)

The GDPR/compliance + commercial legal surface. **Five** durable, **indexable** content routes built on the `DocPage` pattern (like `/about/*`), each paraphrasing its canonical spec — the spec stays the source of truth. They are the public counterpart to the auth-gated, `noindex` account pages, and the destinations the `CookieConsentBanner` (§ Consent) and the footer legal nav (§ Footer) link to. Each ships the full AI-discoverability surface ([AGENTS.md §9](../AGENTS.md)): a Markdown twin (`/legal/<x>.md`), a `sitemap.ts` entry, a `public/llms.txt` line, and a JSON-LD `Article` block.

- **`/legal/privacy`** — the privacy policy. Paraphrases [PRIVACY.md](./PRIVACY.md) (scope, data inventory §2, data-subject rights §6/§13, retention §13.5, legal basis §13.6, contact `citizensdraft@gmail.com`).
- **`/legal/cookies`** — the cookie inventory. Renders the PRIVACY.md §4 table (cookie, setter, purpose, lifetime, type) plus the §13.7 logged-in storage deltas; offers a "Manage cookies" affordance that re-opens the consent banner.
- **`/legal/sub-processors`** — the sub-processor register. Renders the PRIVACY.md §3 table (sub-processor, role, region, DPA). PRIVACY.md §3 already promises "a current list also lives at `/legal/sub-processors`."
- **`/legal/terms`** *(Phase 3)* — the Terms of Service. Paraphrases [TERMS.md](./TERMS.md) (agreement, the service, accounts, acceptable use, subscriptions & billing, cancellation & refunds cross-link, the **holiday-data disclaimer**, IP, liability, governing law Kenya, contact). Added for the paid-tier go-to-market.
- **`/legal/refunds`** *(Phase 3)* — the Refund & Cancellation policy. Restates [PAYMENTS.md §28](./PAYMENTS.md) (cancel anytime from `/account/billing`, access to period end, card auto-renew vs. M-Pesa one-time pass, **no pro-rata cash refunds**). Added for the paid-tier go-to-market.

- **States:** default (static content).
- **Behaviour:** server-rendered under `AppShell`, **indexable** (canonical + default robots — unlike the `noindex` account pages), each emitting `articleJsonLd(...)` via [`src/lib/seo/json-ld.ts`](../src/lib/seo/json-ld.ts). The `.md` twins are `GET` route handlers returning `text/markdown`, mirroring [`/about/how-it-works.md`](../src/app/about/how-it-works.md/route.ts).
- **Voice / copy:** plain legal register (VOICE.md §2 "Legal page lead"), no dark patterns ([VOICE.md §10](./VOICE.md)). The cookie page's "Manage cookies" re-opens the consent banner rather than burying the control.
- **Depends on:** [PRIVACY.md](./PRIVACY.md) / [TERMS.md](./TERMS.md) / [PAYMENTS.md §28](./PAYMENTS.md) (content sources), `DocPage`, [`src/lib/seo/json-ld.ts`](../src/lib/seo/json-ld.ts), [`src/app/sitemap.ts`](../src/app/sitemap.ts), [`public/llms.txt`](../public/llms.txt), [VOICE.md](./VOICE.md).
- **Tests:** each page renders and emits a valid `Article` JSON-LD block; each `.md` route returns `text/markdown`; `sitemap.ts` includes the five HTML routes + their `.md` twins; the sub-processor and cookie tables carry the PRIVACY.md §3/§4 rows; the refunds page states the cancel-anytime / no-cash-refund terms and the terms page carries the holiday-data disclaimer.

---

## Consent (GDPR gate)

### `CookieConsentBanner` — Phase 1 contract · built Phase 2
Realises the [TELEMETRY.md §7](./TELEMETRY.md) / [PRIVACY.md §5](./PRIVACY.md) consent contract: nothing fires until the user decides. This is the GDPR gate — it must be live **before** sign-up goes live ([ROADMAP](../../../ROADMAP.md) Milestone 0).

- **Appears on:** every page, mounted globally in [`src/app/layout.tsx`](../src/app/layout.tsx) as a bottom-anchored bar. Single instance.
- **States:** hidden (a decision is already stored, or the visitor is under Do Not Track / Global Privacy Control), shown (no decision yet), expanded (granular "Manage" switches visible).
- **Props:** none — reads/writes consent via [`src/lib/telemetry/consent.ts`](../src/lib/telemetry/consent.ts).
- **Behaviour:**
  - On mount, shows only when `hasConsentDecision()` is false **and** the visitor is not under DNT/GPC (those are an implicit reject per [TELEMETRY.md §7](./TELEMETRY.md) — `consent.ts` already returns `denied`, and the banner stays hidden; no nagging).
  - "Accept all" → `grantAll()`; "Reject" → `revokeAll()`; "Manage" reveals two switches (analytics, session replay) → `setConsent({ analytics, replay })`. Any choice persists `rg_consent` and hides the banner; the existing `onConsentChange` → `applyConsent` wiring flips PostHog persistence + opt-in.
  - Re-openable: listens for the `roamgap:open-consent` trip event (dispatched by the `/legal/cookies` "Manage cookies" control); on it, the banner re-shows even after a prior decision.
  - Telemetry: **none** — by construction the banner fires no events (consent is not yet granted when it shows).
- **Accessibility:** `role="region"`, `aria-label="Cookie consent"`; the actions are `<button>`s, keyboard-focusable. No auto-dismiss on Esc (a choice is expected; DNT/GPC users never see it). Accept and Reject carry **equal** visual weight ([VOICE.md §2](./VOICE.md)) — no dark pattern.
- **Voice / copy:** per [VOICE.md §2 "Cookie consent banner"](./VOICE.md). Body "We use cookies for analytics. Accept, or reject and we won't set them." Buttons: "Accept all", "Reject", "Manage". No pre-ticked boxes, no "by using this site you agree".
- **Depends on:** [`src/lib/telemetry/consent.ts`](../src/lib/telemetry/consent.ts) (`getConsent`/`setConsent`/`grantAll`/`revokeAll`/`hasConsentDecision`), [TELEMETRY.md §7](./TELEMETRY.md), [PRIVACY.md §4/§5](./PRIVACY.md), [VOICE.md](./VOICE.md).
- **Tests:** shown when no decision is stored; hidden when a decision exists; hidden under DNT/GPC; "Accept all" calls `grantAll` and hides; "Reject" calls `revokeAll` and hides; "Manage" → `setConsent` with the toggled values; the `roamgap:open-consent` event re-shows it after a decision; fires no telemetry.

---

## Auth + paid (Phase 2+)

Phase 2 **adds** a logged-in path; the free anonymous-token flow stays intact. An authenticated user obtains a Firebase ID token client-side (Google OAuth or email/password) and exchanges it at `POST /auth/token { firebaseIdToken }`; the backend verifies via the `firebase-admin` SDK and replaces the anonymous cache entry with a user-scoped one (`scope: "user:<uid>"`). The backend never sees Firebase credentials. See [roamgap-backend AUTH.md §2](../../roamgap-backend/specs/AUTH.md) for the wire-level contract; if the shape of the exchange changes, `specs/domain.ts` and the backend mirror must update in the same PR.

Detailed contracts for components shipping later phases (`DestinationsList`) ship with their phase. (`UpgradePrompt` and the Phase-4 overlap components now have full entries below.)

### `SignInForm` — Phase 2
Authenticates a visitor and exchanges the resulting Firebase ID token for a user-scoped Bearer.

- **Appears on:** `/sign-in` (full-page); also embedded by `UpgradePrompt` (Phase 3) inside a modal slot when an anonymous user accepts a paid CTA. Parent in the page route is `AppShell`.
- **Order:** Google OAuth button on top; then the **"or sign in with email" divider** — a centred label between two hairlines (per the i18n note below) that doubles as the expand affordance while the email panel is collapsed; email + password fields sit under that divider, collapsed by default and revealed on click. The divider is the *only* email affordance: there is no second filled button competing with "Continue with Google" for the same visual weight. Two equally-weighted, unspaced "sign in" buttons stacked together — which the visitor can't tell apart — is the exact failure mode this layout avoids.
- **States:** idle, submitting (button spinner, inputs disabled, blocks double-submit), error-invalid-credentials, error-email-not-verified (renders a "Resend verification" inline action), error-rate-limited (renders `retryAfterSeconds` countdown from the error envelope), error-network, success (resolved before the parent redirects; no terminal "success" UI lives in the form itself).
- **Props:**
  - `redirectTo?: string` (default `/account/saved` — a user signing in with no explicit `?redirect=` lands on their saved searches, the natural signed-in home, rather than back on a page that looks unchanged; an explicit same-origin `?redirect=` always wins)
  - `onSuccess?: (session: AuthSession) => void` (caller can override the default redirect)
  - `initialMode?: "oauth" | "email"` (default `"oauth"` — email panel collapsed)
- **Behaviour:**
  - Google button calls `signInWithPopup(googleProvider)` from the Firebase Auth client SDK.
  - Email form calls `signInWithEmailAndPassword(email, password)`.
  - On either success, the form calls `exchangeFirebaseIdToken(idToken)` from `src/lib/auth/exchange.ts`, which POSTs to `/auth/token` with `{ firebaseIdToken }`. The returned Bearer replaces the anonymous token in the existing token cache (see `src/lib/backend.ts`). Then `onSuccess` fires (or the default redirect to `redirectTo` runs).
  - On Firebase error, map the error code → user-facing state per the table below; **never** echo the raw Firebase message.
  - Submitting state is held until the exchange resolves — a failed exchange (5xx from backend) renders error-network, not a success.
  - On a `403 account_deleted` from the exchange (a soft-deleted account signing back in), the form calls `onAccountDeleted` (the sign-in call site routes to `/account/restore`) instead of rendering an error — the only place a `403 account_deleted` surfaces to a signing-in user ([PRIVACY.md §13.4](./PRIVACY.md)). The routing lives at the call site, NOT inside the session's silent re-mint.
- **Firebase error → state map:**
  - `auth/invalid-credential`, `auth/wrong-password`, `auth/user-not-found` → error-invalid-credentials
  - `auth/too-many-requests` → error-rate-limited
  - `auth/network-request-failed` → error-network
  - `auth/user-disabled` → error-invalid-credentials (do not leak account status)
  - Custom verified-email gate (set client-side after `signInWithEmailAndPassword`, checking `user.emailVerified`) → error-email-not-verified
- **Accessibility:**
  - Form is a single `<form>` with `aria-busy` reflecting submitting.
  - Errors render in a `role="alert"` region directly above the submit button; screen-reader announces on transition into an error state.
  - Password field has a visibility toggle button with `aria-pressed` reflecting state and `aria-label="Show password" / "Hide password"`.
  - Email field uses `type="email"`, `autocomplete="email"`; password uses `autocomplete="current-password"`.
  - Focus moves to the first error region on submit failure; on initial render focus is on the Google button (the primary path).
  - Per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md): all error states are conveyed in text plus a non-colour signal (icon + bold weight on the field label).
- **Telemetry (PostHog, manual events, `snake_case`, past tense — matches [TELEMETRY.md §3.2](./TELEMETRY.md)):**
  - `auth_signin_attempted` — `{ provider: "google" | "email" }`
  - `auth_signin_succeeded` — `{ provider, exchange_duration_ms }` (fired after the `/auth/token` exchange resolves; this is the trigger for the `posthog.identify(hashedUid, …)` call described in TELEMETRY.md §6 — the `distinct_id` is a SHA-256 surrogate of the Firebase `uid`, never the raw uid, per TELEMETRY.md §8)
  - `auth_signin_failed` — `{ provider, error_code }` (only the mapped code, never the raw Firebase string)
  - Inputs carry `data-private` per [TELEMETRY.md §5](./TELEMETRY.md); replays must mask credentials.
- **Voice / copy notes:** per [VOICE.md §4](./VOICE.md). The CTAs are "Continue with Google" and the "Sign in" submit; the email affordance is presented as the **"or sign in with email" divider** (see Order above), not a second filled CTA — so the visitor never has to discern between two equally-weighted "sign in" buttons. Errors follow the "what happened + what to do" pattern:
  - invalid-credentials: "That email and password don't match. Try again, or reset your password."
  - email-not-verified: "Check your inbox to verify your email. Didn't get it? Resend verification."
  - rate-limited: "Too many attempts. Try again in {seconds}s."
  - network: "Couldn't reach the sign-in service. Try again in a moment."
- **i18n:** per [I18N.md](./I18N.md), all labels, button copy, error strings, and the visibility-toggle `aria-label` are translated. The divider string "or sign in with email" is layout-sensitive — German and Finnish expand by ~40%; the divider lays out as flexbox `min-content` between two `hr` lines so the label can wrap to two lines without breaking alignment.
- **Tests:**
  - Google success path: button click → `signInWithPopup` resolves → `/auth/token` mocked 200 → `onSuccess` called with the user-scoped session.
  - Email success path: form submit → `signInWithEmailAndPassword` resolves with `emailVerified: true` → exchange 200 → redirect.
  - Email-unverified gate: `emailVerified: false` does not call the exchange and renders the resend-verification action.
  - Double-submit is blocked while `submitting`.
  - Each error mapping (invalid-credential, too-many-requests, network-request-failed) renders its mapped state and fires `auth_signin_failed` with the correct `error_code`.
  - The form does not POST anywhere directly — only Firebase Auth SDK + `exchangeFirebaseIdToken` are called.
  - Password visibility toggle flips `type` between `password` and `text` and updates `aria-pressed`.

### `AccountMenu` — Phase 2
Identity surface in the `TopNav`. Only renders when a user-scoped session is present; renders nothing in the anonymous (free-tier) state.

- **Appears in:** `TopNav` right slot, replacing the "Sign in" link once authenticated. Single instance per page.
- **States:** signed-out (renders nothing — the "Sign in" link in `TopNav` covers this slot instead), closed (avatar trigger only), open (popover visible), signing-out (trigger disabled, spinner on the "Sign out" item).
- **Props:**
  - `user: { uid: string; email: string; displayName?: string; photoUrl?: string }`
- **Behaviour:**
  - Trigger is a 32×32 round button showing `photoUrl`, or the first grapheme of `displayName ?? email` over the `accent-1` surface as fallback.
  - Click / Enter / Space on the trigger opens a popover anchored bottom-right of the trigger.
  - Contents, top-to-bottom:
    1. Header row — user email, truncated with `text-overflow: ellipsis`, full email exposed via `title` and as the accessible name of the row. Carries `data-private`.
    2. "Saved searches" — link to `/account/saved`, which renders the `SavedSearchesList` (specified below).
    3. "Settings" — link to `/account/settings` (locale toggle now; reserved for future preferences).
    4. "Subscription" — link to `/account/billing`, which renders the `BillingStatusCard` (status + manage; [PAYMENTS.md](./PAYMENTS.md)).
    5. "Download my data" — link to `/account/data`. Triggers the self-serve DSAR access export per [PRIVACY.md §6](./PRIVACY.md).
    5. "Delete my account" — link to `/account/delete`. Triggers the DSAR erasure cascade per [PRIVACY.md §6](./PRIVACY.md) and §13.4. Item renders in `status-danger` foreground.
    6. Divider.
    7. "Sign out" — runs the sign-out cascade internally, in order: revoke the user-scoped Bearer via `POST /auth/revoke` (so the backend cache entry is gone before any client-side artefact is dropped), call **Firebase Auth `signOut()`** to clear the IndexedDB session (per [`../../roamgap-backend/specs/AUTH.md`](../../roamgap-backend/specs/AUTH.md) §11.5.2 — without this step a Firebase session leaks across the revoke), call `posthog.reset()` (per [TELEMETRY.md §6](./TELEMETRY.md)), and route to `/`. The cascade lives in `AccountMenu` rather than a caller-supplied `onSignOut` prop: keeping it inside the component lets the test pin the strict ordering (revoke → signOut → reset → route) without depending on the call-site's plumbing.
  - On mobile (`< md` per [DESIGN_SYSTEM.md §8](./DESIGN_SYSTEM.md)), the popover does **not** render as a bottom-sheet — no existing component uses that pattern (the inventory currently has popovers and toasts, no sheet primitive). Instead, the popover anchors full-width below the top nav with the same vertical list. If a `BottomSheet` primitive is added later, this section must be updated.
- **Visual:** realises Figma `AccountMenu` (`210:454`). The popover is a `surface-elevated` card — `border` hairline, `radius-md`, `elevation-2`, `min-width: 240px` — anchored to the bottom-right of the trigger. Items are comfortably-padded full-width rows, never a cramped flush list: the email header is a quiet `text-muted` caption (truncated), the four navigation rows carry a `surface-sunken` hover, "Delete my account" carries the `status-danger` foreground, and a `border` separator sits above "Sign out". The trigger itself is the 32×32 round avatar (`photoUrl`, or the initial grapheme on an `accent` disc).
- **Accessibility:**
  - Trigger is a `<button>` with `aria-haspopup="menu"`, `aria-expanded` reflecting open state, `aria-controls="account-menu"`.
  - Popover is `role="menu"` with `aria-labelledby` pointing at the trigger; each item is `role="menuitem"`.
  - On open, focus moves to the first menu item (the email header row, which is focusable for screen-reader announcement of identity).
  - `Tab` cycles within the menu; `Shift+Tab` reverses; `Esc` closes and returns focus to the trigger. Click-outside also closes.
  - "Delete my account" carries `aria-describedby` pointing at a visually hidden helper: "This permanently removes your account and data."
  - Avatar `img`/initial fallback has `alt=""` — the trigger's accessible name is "Account menu for {email}", not the avatar.
- **Telemetry:**
  - `account_menu_opened` — `{ source: "click" | "keyboard" }`
  - `account_menu_item_clicked` — `{ item: "saved_searches" | "itineraries" | "settings" | "subscription" | "download_data" | "delete_account" | "sign_out" }`
  - `auth_signout_succeeded` — fires after the cascade completes (Bearer revoked, Firebase signed out, PostHog reset). On any failure within the cascade, `auth_signout_failed` fires with `{ error_code }`.
  - Email row and any element rendering identity must carry `data-private`.
- **Voice / copy notes:** per [VOICE.md](./VOICE.md). Labels in order: "Saved searches", "Settings", "Subscription", "Download my data", "Delete my account", "Sign out". (The "Itineraries" item was **promoted to the primary nav as "Solo trips"**, [VOICE.md §4.11](./VOICE.md), and removed from this dropdown.) Trigger tooltip: "Account". The "Delete my account" item's hover/focus state surfaces a sub-label "Permanent. Removes your data." — this is the only menu item with a sub-label. The first-person framing ("Delete **my** account", "Download **my** data") is deliberate: it puts the destructive action in the user's voice, not an imperative from the product.
- **i18n:** every visible string is translated. "Download my data" and "Delete my account" are the longest items — German "Meine Daten herunterladen" / "Mein Konto löschen" fit within the menu's `min-width: 240px`. Email truncation is locale-agnostic (ellipsis on character overflow, not word).
- **Tests:**
  - Renders nothing when `user` prop is absent (signed-out).
  - Trigger click opens the popover and moves focus to the first item.
  - `Esc` closes the popover and returns focus to the trigger.
  - `Tab` cycles within open menu, does not leak to page content.
  - Click-outside closes.
  - "Sign out" runs the internal cascade; while pending, trigger is disabled and the item shows a spinner; on resolve, `auth_signout_succeeded` fires; on reject (cascade fails at any step), `auth_signout_failed` fires and the menu reopens with an inline error.
  - Email row carries `data-private` and the full email is exposed only via accessible name + `title`, never as plain text content that exceeds the truncation width.
  - "Download my data" and "Delete my account" link to the routes defined in [PRIVACY.md §6](./PRIVACY.md).
  - "Sign out" cascade calls `POST /auth/revoke`, then Firebase Auth `signOut()`, then `posthog.reset()` in that order. The Firebase `signOut()` step is asserted by spying on the Firebase SDK in the test.

### `SavedSearchesList` — Phase 2 · revamp · *built (Figma)*
Renders the authenticated user's saved searches — each a persisted optimizer or annual-planner query — and lets them open one (navigating to the reconstructed shareable URL) or delete one. The paid counterpart to the free tier's URL-only persistence (see [PROJECT.md §5](./PROJECT.md)); the backend wire contract is [roamgap-backend SAVED_SEARCHES.md](../../roamgap-backend/specs/SAVED_SEARCHES.md).

> **Revamp (2026-06-25).** The first cut read as "very basic" — thin, near-empty rows with a cryptic dot-string summary (`KE · 24 PTO · 2026`) and a lone trash icon stranded in a wide column. The revamp keeps every behaviour (open / inline-confirm delete / states / telemetry) and re-dresses the surface: a **leading mode tile**, **human-readable place names** (Kenya, not `KE`), an explicit **Open** affordance, a card **hover lift**, a **count caption**, and a **responsive two-column grid** so the wide account column reads as a considered gallery.

- **Appears on:** `/account/saved` (full-page, under `AppShell`); the "Saved searches" item in `AccountMenu` links here. The page route and its data wiring (fetching `GET /searches` through a user-scoped Bearer) shipped with the account-pages integration slice; this entry specifies the **presentational** component, unit-tested in isolation exactly as `SignInForm` and `AccountMenu` are.
- **States:** loading (skeleton **cards** in the grid), ready-populated (a responsive grid of cards, one per search, under a count caption), ready-empty (shared `EmptyState`, full-width), error (`ErrorState` with retry, full-width), deleting (the affected card shows a spinner and is disabled; the rest of the list stays interactive).
- **Props:**
  - `status: "loading" | "ready" | "error"`
  - `searches: SavedSearch[]` — from `@/lib/domain`; honoured when `status === "ready"`. Rendered in the order given (the backend returns `createdAt` descending); the component does **not** re-sort.
  - `onDelete: (id: string) => Promise<void>` — parent performs `DELETE /searches/:id` and removes the row on resolve.
  - `onOpen?: (search: SavedSearch) => void` — overrides the default navigation (used when embedding).
  - `onRetry?: () => void` — invoked from the error state.
  - `locale?: string` — **new**; defaults to `"en-KE"`. Used only to resolve the place display name via `Intl.DisplayNames` (the same house pattern as `GroupRoster` / `CountryPicker`).
- **Layout:** the ready-populated list is a **responsive grid** — one column on small screens, two from `lg` (`grid-cols-1 lg:grid-cols-2`) — of self-contained cards, so the wide account column reads as a considered gallery rather than a thin stack of near-empty rows (the "looks basic" defect this revamp closes). A quiet **count caption** ("{n} saved {search\|searches}") sits above the grid. Empty, error, and loading states span the full column (the grid is only for real rows); loading renders skeleton **cards** in the same grid shape.
- **Behaviour:**
  - Each search renders as a card with three zones: a **leading mode tile** (a 44px `surface-sunken` rounded square holding a calendar glyph for a `year`/Plan search or a travel glyph for a `trip`/Trip search — an at-a-glance, non-colour mode signal); a **body** with `name` as the primary line and a secondary summary line; and a **trailing** open-and-delete cluster.
  - The summary line is derived from `search.query.input` by a pure formatter — a mode badge ("Trip" for `trip`, "Plan" for `year`), the **place as a human-readable display name** (the country's name via `Intl.DisplayNames({type:"region"})` — "Kenya", "United States", never the raw `KE` code; `Intl.DisplayNames` resolves country codes but throws on ISO-3166-2 *subdivision* codes, so a set subdivision is appended in parentheses as its post-dash region code — "Germany (BY)"), and the headline params (PTO; year). The summary **never** re-runs the optimizer; it only reads the stored input.
  - The whole card is the **primary action**: a link to a `/year` URL built from `search.query` via [`src/lib/url/year.ts`](../src/lib/url/year.ts), with an explicit **"Open" chevron** in the trailing zone that advances on hover (the card lifts to `elevation-2` + `border-strong` and the `name` takes the `accent` "link on hover" colour, [DESIGN_SYSTEM.md §2.2](./DESIGN_SYSTEM.md)). A `year`/Plan search links to its canonical whole-year URL ([URL_FORMAT.md](./URL_FORMAT.md)). A `trip` (single-break) search — after the single-break UI's removal ([ADR-008](../docs/DECISIONS.md)) — opens the **whole-year** planner pre-filled with its country/region/leave (`max` defaulting to one week), since there is no longer a single-break view to open; the "Trip" badge stays as the honest record of what was saved. `onOpen` overrides navigation.
  - Delete is a per-card icon button in the trailing zone (a sibling of the link, never nested inside it). Click swaps the card into an **inline** confirm affordance ("Delete this search?" + "Cancel" / "Delete") — no modal; this list has no destructive-account semantics, so it uses the lightweight inline pattern rather than the typed-confirmation modal `AccountMenu` mandates for account deletion. Confirm calls `onDelete(id)`; while pending the card shows a spinner and is disabled. On reject the card returns to normal and surfaces an inline error with a retry.
- **Visual:** realises Figma `SavedSearchesList` `148:2` (revamped) + `Pattern · Saved searches` `186:424` (Light) / `219:451` (Dark). Card — `surface-elevated` fill, `border` hairline, `radius-lg`, `elevation-1` at rest → `elevation-2` + `border-strong` on hover. Leading tile — `surface-sunken`, `border`, `radius-md`, `text-muted` glyph. `name` in `text` Semibold; mode badge a `surface-sunken` pill with a `border` stroke; summary in `text-muted`. Open chevron + delete in `text-faint`, brightening on hover/focus. **One accent only** — the `name`'s hover colour ([DESIGN_SYSTEM.md §1](./DESIGN_SYSTEM.md)); tiles and badges stay neutral so a populated grid reads calm, not busy.
- **Accessibility:**
  - The list is a `<ul>`; each search an `<li>`. Open is a link; delete is a `<button>` with `aria-label` "Delete saved search: {name}". The mode tile and chevron are decorative (`aria-hidden`); the mode is named in text by the badge.
  - Entering inline-confirm moves focus to the "Delete" confirm button; "Cancel" returns focus to the delete trigger.
  - Empty and error states reuse the shared `EmptyState` / `ErrorState` components.
  - Per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md): the deleting state is conveyed in text plus a non-colour signal, never colour alone.
- **Telemetry (PostHog, manual events, `snake_case`, past tense):**
  - `saved_search_opened` — `{ mode: "optimize" | "plan" }`
  - `saved_search_deleted` — `{ mode }`
  - `saved_search_delete_failed` — `{ error_code }`
  - Properties carry **no** query contents and **no** identity, per the [TELEMETRY.md](./TELEMETRY.md) allowlist.
- **Voice / copy notes:** per [VOICE.md](./VOICE.md). Canonical term "Saved search". Count caption — "{n} saved {search\|searches}" (singular "1 saved search"). Empty state — headline "No saved searches yet.", body "Run a search while signed in and it shows up here automatically, on any device." (the post-autosave copy — searches autosave, so the body points at running a search, not a removed "Save" button; see § `AutosaveSearch`). Delete trigger label "Delete"; inline confirm prompt "Delete this search?" with "Cancel" and "Delete". No "Are you sure?", no exclamation marks. Mode badge: "Trip" (`trip`) / "Plan" (`year`), matching the canonical terms in VOICE.md §3. The place is the localised country **display name** (en-KE → English names; a set subdivision is appended as its post-dash code in parentheses, e.g. "Germany (BY)"), never the raw country code.
- **Depends on:** [domain.ts](./domain.ts) (`SavedSearch`), [URL_FORMAT.md](./URL_FORMAT.md), [VOICE.md](./VOICE.md), [ACCESSIBILITY.md](./ACCESSIBILITY.md), [ERROR_STATES.md](./ERROR_STATES.md), and backend [SAVED_SEARCHES.md](../../roamgap-backend/specs/SAVED_SEARCHES.md).
- **Tests:**
  - `status="loading"` renders skeleton cards and no list.
  - `status="ready"` with searches renders one card per search, preserves the given (newest-first) order, and each card links to the URL built from its query.
  - The summary renders the **country display name** (e.g. "Kenya" / "United States"), not the raw ISO code, and the count caption reads "{n} saved searches" (singular for one).
  - Each card carries a decorative mode tile and an "Open" affordance; the mode badge reads "Trip" / "Plan".
  - `status="ready"` with `searches=[]` renders the empty-state copy.
  - `status="error"` renders `ErrorState`; its retry calls `onRetry`.
  - Delete: icon → inline confirm → confirm calls `onDelete(id)`; the card is disabled with a spinner while pending; `saved_search_deleted` fires on resolve.
  - Delete reject: the card returns to normal, an inline error shows, and `saved_search_delete_failed` fires with the mapped `error_code`.
  - A `year`-mode search links to `/year?…`; a `trip`-mode search links to a whole-year `/year?…` pre-filled with its country/leave (no `view=trip`, [ADR-008](../docs/DECISIONS.md)).
  - No telemetry property carries query contents or identity.
- **SHIPPED (2026-06-25).** Revamp landed: leading mode tile (calendar / paper-plane), `Intl.DisplayNames` country names, "Open" chevron, `elevation-1 → -2` hover lift with the `name` taking `accent`, pluralised count caption, and the responsive `grid-cols-1 lg:grid-cols-2` grid; loading renders 4 skeleton cards. New optional `locale` prop (default `en-KE`). Figma `148:2` + patterns `186:424` / `219:451` redrawn (Light + Dark). `npm run check` green (typecheck + lint + 1110 tests). **Deviations:** (1) a set subdivision renders as its code in parens ("Germany (BY)"), not a region name — `Intl.DisplayNames({type:"region"})` throws on ISO-3166-2 subdivisions; (2) the `locale` prop is not threaded from the page (country names are locale-invariant across `en-*`); (3) the §telemetry `mode` example reads `"optimize" | "plan"` but the shipped event carries the schema's `"trip" | "year"` — a pre-existing label, left unchanged here.

### `AutosaveSearch` — Phase 2 · *(supersedes `SaveSearchButton`)*
Searches **autosave** — there is no "Save search" button and no session-only note. When a signed-in user views a results page, their query is persisted to their account automatically, so it appears on `/account/saved` with no extra action. The free-tier counterpart is still the shareable URL. Backend contract: [roamgap-backend SAVED_SEARCHES.md §4/§5.1](../../roamgap-backend/specs/SAVED_SEARCHES.md) (dedup + rolling window).

- **Appears on:** `/year` results. One instance per results view. **Renders nothing** — it is a side-effect-only client component (declutter: the old button + "Free searches live only in this tab" note are gone).
- **Props:**
  - `query: SavedSearchQuery` — the `{ mode, input }` to persist (from `@/lib/domain`).
- **Behaviour:**
  - Reads the session via `useSession()` ([docs/ARCHITECTURE.md § Authenticated requests](../docs/ARCHITECTURE.md)). **Anon / loading → no-op** (nothing to persist server-side; the anon "returning searcher" still gets the localStorage sticky search, PROJECT.md §4.2.2).
  - **Authed:** once per distinct query (guarded by a ref keyed on the canonical query so a re-render / StrictMode double-mount does not re-POST), `POST /searches { name, query }` via `authedFetch`, with a name derived from the query (e.g. "Germany 2026 plan"). Failures are swallowed (best-effort; a dropped autosave is silent — the URL is always the durable copy).
  - **Dedup + rolling window are server-side** (SAVED_SEARCHES.md §4): an identical query returns the existing record (no duplicate); a new distinct query at the cap evicts the oldest. The client never sees `search_limit_reached`.
- **Accessibility:** no rendered surface — nothing to label.
- **Telemetry:** none from the autosave path (the search itself is already captured by `optimizer_submitted`, TELEMETRY.md §3.2).
- **Tests:** authed mount POSTs `{ name, query }` once (not twice on re-render); anon/loading mount POSTs nothing; a POST failure is swallowed (no throw, nothing rendered).
- **SHIPPED (2026-06-23).** `AutosaveSearch` mounted on `/year` results; `SaveSearchButton` + the "Free searches live only in this tab" note deleted. Dedup + rolling window are server-side ([SAVED_SEARCHES.md §4](../../backend/specs/SAVED_SEARCHES.md)). `npm run check` green both apps.

### `AvailabilityProfileEditor` — UX-7b · revised UX-11 · *built (Figma)*
The single editor for the signed-in user's **availability profile** — the reusable country / region / annual leave / max trip length / workweek / also-observes template ([AVAILABILITY_PROFILE.md §5.1](./AVAILABILITY_PROFILE.md)). **Settings only.** The planner revamp (UX-11) removed the old per-page `AvailabilityProfileControls` two-button cluster: the planners now **autoload** from the profile ([AVAILABILITY_PROFILE.md §5.2](./AVAILABILITY_PROFILE.md)), so the one place a user *edits* their saved availability is here.

- **Appears on:** `/account/settings` only (login-required; anonymous → sign-in nudge), as a tile in the `SettingsTiles` masonry.
- **Renders:** a section card (`surface-elevated`, `border`, `radius-12`) titled "Availability profile" + a one-line lead, then the fields — `CountryPicker` (country + region), `PtoInput` (annual leave), `MaxTripLengthInput` (max trip length), a workweek weekday-toggle row, and an "also off on" holiday-source picker — and a footer with **Save** (accent) + **Forget** (ghost).
- **States:** anon (sign-in nudge); loading; loaded (fields seeded from the saved profile, or empty defaults when none); dirty (Save enabled); saving (`aria-busy`); saved ("Saved to your profile."); forgetting; forgotten ("Your profile was forgotten."); error ("Couldn't save — try again.").
- **Props:** none — owns its data via `useAvailabilityProfile()` (`{ profile, signedIn, save, forget }`). Validates the assembled record against `AvailabilityProfileInput` before `save`.
- **Behaviour:** Save merges + `PUT`s (AVAILABILITY_PROFILE.md §5.3); Forget `DELETE`s and resets the form to empty defaults. **Free** — no `@RequirePaidTier`.
- **Accessibility:** labelled section; each field keyboard-reachable; the saved/forgotten/error feedback announced via an `aria-live` `role="status"` region.
- **Voice / copy:** [VOICE.md §4.16](./VOICE.md).
- **Stage-2 Figma:** the settings card with all fields + the Save/Forget footer + saved/forgotten states (Light + Dark).
- **Tests:** sign-in nudge when anon; seeds fields from the loaded profile; Save PUTs the merged record + shows "Saved…"; Forget DELETEs + shows the forgotten state; a save error shows the error state.

### `PlannerPresets` — UX-11 · *REMOVED (§4.1.3 declutter, 2026-06-22)*
The one-tap "Quick start" preset row (Long weekends / A balanced year / One big trip) was **removed** — testers found it added clutter to the highest-traffic surface, and the plan shape it set (`maxTripLength`, `budgetMode`, `longTripCount`) is fully reachable via `MaxTripLengthInput` and **Advanced settings** (`TripStyleSlider`, `BudgetModeToggle`). Component + test deleted; the Figma `Pattern · Planner revamp` Quick-start node `698:1544` is superseded ([PROJECT.md §4.1.3](./PROJECT.md), [VOICE.md §4.20](./VOICE.md)). The full-width-on-mobile preset idiom it modelled is still documented on `BudgetModeToggle` (§ `BudgetModeToggle` Responsive).

### `TripStyleSlider` — UX-11 · *built (Figma)*
Replaces the `LongTripCountInput` stepper **and its explanatory paragraph** in Advanced settings ([PROJECT.md §4.1.2](./PROJECT.md)) with a single labelled slider whose end captions carry the meaning — **"Fewer, longer trips" ↔ "More, shorter trips"** — mapping left→right onto `longTripCount` (`MIN_LONG_TRIP_COUNT` .. `MAX_LONG_TRIP_COUNT`). One control, no paragraph to read. **Responsive:** full-width on mobile but **width-capped and left-aligned on desktop** (`sm:max-w-md`) so the slider doesn't stretch edge-to-edge across the wide Advanced-settings column — keeping it aligned with the year row above and the budget-mode control below.

- **Appears on:** `/year` Advanced settings (replaces `LongTripCountInput` there).
- **Props:** `value: number` (longTripCount), `onChange`, `min?` (default 1), `max?` (default 8).
- **Behaviour:** seeded with `smartLongTripDefault(pto)` and tracks the PTO budget until the user moves it (then the choice sticks + the `lt` URL param is emitted, unchanged from `LongTripCountInput`). `LongTripCountInput` stays in the inventory as the underlying numeric model; the slider is its planner presentation.
- **Accessibility:** a native `<input type="range">` (`role="slider"`, `aria-valuemin/max/now`, `aria-label` "Trip style") with visible end captions; fully keyboard-operable (arrows step `longTripCount`).
- **Voice / copy:** [VOICE.md §4.20](./VOICE.md).
- **Stage-2 Figma:** the slider with end captions + filled track (Light + Dark).
- **Tests:** moving the slider emits the mapped `longTripCount`; seeds + tracks the smart default like the stepper did; emits `lt` only once moved.

### `TravelWindowPicker` — UX-11 · *built (Figma)*
Replaces the always-on **12 month-chips** in `FilterPanel` with a progressive "when can you travel?" control ([PROJECT.md §4.1.2](./PROJECT.md); realises the tracked "season quick-picks" filter group named in § FilterPanel). Reads/writes `Filters.months`.

- **Default "Any time"** — `months: []`, nothing expanded. A single segmented toggle: **Any time** / **Pick months**.
- **"Pick months"** reveals a compact panel: **quarter quick-toggles** (Q1 Jan–Mar … Q4 Oct–Dec, each toggling its three months) above the **month grid** (the former 12 chips, now disclosed on demand). Hemisphere-neutral — quarters by month number, no "summer/winter" (roamgap is global, incl. the equator).
- The header summarises the selection when collapsed ("3 months").

- **Appears on:** `/year` (both modes), inside `FilterPanel` (replacing its Months fieldset).
- **Props:** `value: number[]` (months), `onChange(next)`.
- **Accessibility:** the Any-time/Pick toggle is a `radiogroup`; quarter toggles + month chips are real checkboxes with the non-colour check signal (§3); the disclosure uses `aria-expanded`/`aria-controls`.
- **Voice / copy:** [VOICE.md §4.20](./VOICE.md). Legend "When can you travel?".
- **Stage-2 Figma:** the collapsed (Any time) + expanded (quarters + grid) states (Light + Dark).
- **Tests:** default emits `[]`; a quarter toggle emits its three months; the month grid round-trips; collapsing keeps the selection.

### `PlanSummary` — UX-11 · *built (Figma)*
A one-line **live preview** of the plan the current inputs will produce ([PROJECT.md §4.1.2](./PROJECT.md)), sitting just above the Find-trips button so a visitor trusts the defaults and feels no need to open Advanced. Pure presentation — derived from form state, no backend call.

- **Renders:** e.g. in whole-year *"Turning 30 days of leave into trips up to 2 weeks long, any time this year."* Phrased per [VOICE.md §4.20](./VOICE.md), locale-aware ("leave" vs "PTO", I18N.md). (The `mode="trip"` variant — *"Finding your best single break in Kenya…"* — is retained in the component + its test but no longer rendered after the single-break UI's removal, [ADR-008](../docs/DECISIONS.md).)
- **Appears on:** `/year` (both modes).
- **Props:** the relevant subset of form state (country label, leave, maxTripLength, travel window, budget mode, year) — read-only.
- **Accessibility:** plain text in a `role="status"` `aria-live="polite"` region so the summary is announced as inputs change; never colour-only.
- **Voice / copy:** [VOICE.md §4.20](./VOICE.md).
- **Stage-2 Figma:** the summary line in the planner hero (Light + Dark).
- **Tests:** reflects leave / length / window / budget changes; uses the locale leave-term.

### `OffDayLedger` — UX-10 · *built (Figma)*
The yearly **leave ledger** page body ([OFF_DAY_MANAGER.md](./OFF_DAY_MANAGER.md)) — the login-required off-day manager: track taken / granted days against an annual allowance.

- **Appears on:** `/account/off-days` (login-required; anonymous → sign-in).
- **States:** `empty` (no days yet — budget seeded from the availability profile); `tracking` (summary + day list); `over-budget` (remaining < 0 → "Over by N", warn tone); `saving`/`saved`/`error` (the shared feedback).
- **Props:** `initial?: OffDayLedger`, `year: number`; the annual allowance defaults from the availability profile.
- **Behaviour:** computes `remaining = annualPtoBudget − count(pto)` + `grantedTaken` client-side; mark/unmark a date with a category (PTO / Medical / Compassionate / Other) + optional note; Save `PUT`s the whole ledger via `saveOffDays`. **Free** — no `@RequirePaidTier`.
- **Depends on:** `@/lib/off-days/client`, `@/lib/domain` (`OffDayLedger`, `OffDayCategory`), `useAvailabilityProfile` (budget seed), [OFF_DAY_MANAGER.md](./OFF_DAY_MANAGER.md).
- **Stage-2 Figma:** the summary (remaining/taken/granted) + day list + add-a-day + over-budget state (Light + Dark).
- **Tests:** marking a `pto` day decrements remaining; a granted day does not; Save PUTs the ledger; the over-budget + error states.

### `NavAuthSlot` — Phase 2
The `TopNav` right-slot that swaps between the signed-out and signed-in identity surface. Keeps `TopNav` a server component while the auth state is client-only.

- **Appears in:** `TopNav` right slot, replacing the static "Sign in" CTA.
- **States:** loading (renders the `NavGuestMenu` to avoid a layout shift before the session resolves), anon (`NavGuestMenu` — "Sign in" link + a "▾" guest disclosure), authed (`AccountMenu`).
- **Responsive (the mobile Sign-in overshadow fix):** the standalone anon affordance (`NavGuestMenu`) is **hidden below `sm`** (`hidden sm:…`) — on a phone the Sign in action **and** the guest pitch live inside the `NavMenu` hamburger instead, so nothing sits in the corner overshadowing and displacing the mobile menu. The signed-in `AccountMenu` avatar shows at **all** sizes (it is the identity anchor and its own dropdown is edge-aligned as the right-most control).
- **Props:** none — reads `useSession()`.
- **Behaviour:** `status === "authed"` → `<AccountMenu user={{ uid, email, displayName, photoUrl }} />`; otherwise `<NavGuestMenu />` (mobile-hidden). `AccountMenu`'s own sign-out cascade ([§ AccountMenu](#accountmenu--phase-2)) runs on sign-out.
- **Tests:** renders `NavGuestMenu` (a "Sign in" link, mobile-hidden) when anon/loading; renders `AccountMenu` (with the session user) when authed.

### `NavGuestMenu` — *the anon counterpart to `AccountMenu`*
The signed-out right-slot affordance — the symmetric partner of `AccountMenu`. It exists because a guest previously got only a bare "Sign in" pill: the account options (`AccountMenu`'s Saved searches / Settings / Subscription…) were invisible until you signed in, so the nav read as "sign in to see anything." `NavGuestMenu` keeps Sign in one obvious click **and** tells a guest what an account adds — without faking account links a guest doesn't have.

- **Appears in:** `NavAuthSlot` (anon / loading), `sm`+ only. Below `sm` the same pitch renders inside the `NavMenu` hamburger foot (no nested popover).
- **Split control — Sign in stays one click.** A pill-group reading **"Sign in"** (a `Link` → `/sign-in`, navigates on click) joined to a small **"▾" disclosure `<button>`** (accessible name "What an account adds") that toggles the guest panel. They share the pill chrome (`surface/sunken` + `border`, `cornerRadius: 9999`) so they read as one control; the "Sign in" half never opens a menu (no two-click-to-sign-in surprise).
- **Guest panel (informational, not `role="menu"`).** Edge-aligned popover, `surface/elevated` + `border` + `elevation/2`: an eyebrow **"Browsing as a guest"** (`text/xs` muted) then the pitch — **"Every planner here is free — no account needed."** and **"Sign in to save your searches across devices and plan group trips."** (`text/sm`) — and a foot **"Sign in"** link (accent) for convenience. Copy: [VOICE.md §4.24](./VOICE.md).
- **States:** closed (the split pill only), open (pill + panel). Closes on `Esc` (returns focus to the ▾ trigger), pointer-down outside, or choosing a link.
- **Props:** none — it's anon-only; `NavAuthSlot` decides when to render it.
- **Accessibility:** [disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) ([ACCESSIBILITY.md §4.7](./ACCESSIBILITY.md)) — the ▾ `<button>` carries `aria-expanded` + `aria-controls`; the panel is a region of plain text + links, **not** an application menu. The "Sign in" link and the ▾ button are separately focusable.
- **Depends on:** DESIGN_SYSTEM (breakpoints §8, elevation), VOICE (§4.24), ACCESSIBILITY (§4.7).
- **SHIPPED 2026-06-26.** `NavGuestMenu` + the shared `GuestPitch` render in `NavAuthSlot` (`sm`+) and at the `NavMenu` hamburger foot (`< sm`). Drawn `Pattern · Anon nav — guest menu` Light `826:1580` / Dark `827:1580` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). `npm run check` green — 1130 web tests.

### `AccountSettingsForm` — Phase 2
Profile rectification — the DSAR "Rectification" right ([PRIVACY.md §13.2](./PRIVACY.md)). Edits the mutable `UserPatch` fields; theme is a separate client-only control.

- **Appears on:** `/account/settings` (full-page, under `AppShell`); reached from `AccountMenu` → "Settings".
- **States:** idle, dirty (unsaved edits), saving, saved (brief confirmation), error.
- **Props:**
  - `initial: { displayName?, locale?, country?, subdivision?, observedCalendars?, emailOptOut? }` — seeded from the session user / profile.
  - `onSave?: (patch: UserPatch) => Promise<void>` — defaults to `patchMe(authedFetch, patch)`.
- **Behaviour:**
  - Fields: display name (text), locale (select), and **Country + Region via the shared [`CountryPicker`](#countrypicker--phase-1)** (the same dropdown the optimizer/planner use, fed by `SUPPORTED_COUNTRIES` / `SUPPORTED_SUBDIVISIONS`). Country/Region are no longer free-text — that prevents a country/region mismatch (e.g. `KE` + `DE-BY`) and reuses the picker's chevron-gutter padding. The locale `<select>` shares the same `appearance-none` + chevron gutter so its dropdown arrow has matching right-edge padding. Theme is a separate `ThemeSwitch` (client-only; persists itself, not part of the PATCH).
  - **Default calendars** ([CALENDAR_OVERLAYS.md §7](../../backend/specs/CALENDAR_OVERLAYS.md)): a `CalendarOverlayPicker` (fed the form's selected `country`) lets the user set their persistent default `observedCalendars`. This is stored on the `UserDoc` and **pre-fills the planner/optimizer forms** when present, so a user who always observes the full Islamic calendar doesn't re-add it every search. The picker edits a `Filters`-shaped view; on save the field is lifted onto the `UserPatch`. It clears back to *unset* (`undefined`) when the user returns to the country default — the same canonical-omitted rule as the URL/Filters form.
  - **Email alerts** ([EMAIL_ALERTS.md §3](../../roamgap-backend/specs/EMAIL_ALERTS.md)): a single checkbox — "on" = opted in — controlling the opt-OUT `emailOptOut` field. It governs **all** roamgap email (new-holiday alerts in a saved country + roamgap Plus renewal reminders). Toggling it sends `emailOptOut` on the `UserPatch` (`true` when unchecked); unchanged ⇒ omitted.
  - **Region is gated on Country** ([USERS.md §3.4](../../roamgap-backend/specs/USERS.md)): the Region dropdown renders only when the selected country has subdivisions — so it is absent before a country is picked and **disappears for a country with no regions**. Changing the country clears the region in the same commit.
  - Save sends only the changed fields as a `UserPatch` via `patchMe`. Clearing the region (picking "National only", or selecting a country with no regions) sends `subdivision: null` so the stored value is removed in the DB. A change to the calendar prefs counts as dirty (incl. clearing them back to unset, sent as an explicit `[]` so the backend unsets the stored `observedCalendars`). On success → "Saved."; on `400 invalid_input` → inline field error; on other failure → `ErrorState`/toast with retry.
- **Accessibility:** a single `<form>`; `aria-busy` while saving; errors in a `role="alert"`; every field labelled.
- **Telemetry:** `account_settings_saved` (no properties — a per-field breakdown would require adding `fields` to the [TELEMETRY.md](./TELEMETRY.md) §8 allowlist; deferred). Never values or identity.
- **Voice / copy:** per [VOICE.md §4](./VOICE.md). Button "Save changes"; success "Saved."; subdivision helper "Pick a country first."; calendar section per [VOICE.md §4.7](./VOICE.md) with the lead "Your default calendars — applied to new searches."
- **Depends on:** [domain.ts](./domain.ts) (`UserPatch`, `UserSchema`, `ObservedCalendar`), `src/lib/me/client.ts` (`patchMe`), [`CountryPicker`](#countrypicker--phase-1) + `src/lib/data/supported-countries.ts`, `CalendarOverlayPicker`, `ThemeSwitch`, [USERS.md §3.4](../../roamgap-backend/specs/USERS.md), [CALENDAR_OVERLAYS.md §7](../../backend/specs/CALENDAR_OVERLAYS.md), [VOICE.md §4.7](./VOICE.md), [ACCESSIBILITY.md](./ACCESSIBILITY.md).
- **Tests:** renders seeded from `initial`; editing marks dirty; Region absent until a region-supporting country is selected and absent for a no-region country; changing country clears the region; Save calls `patchMe` with only the changed fields; clearing a seeded region sends `subdivision: null`; adding a `full` dataset to / removing a granted row from the default `observedCalendars` is included in the patch (clearing back to the country default sends `[]`); saving/saved/error states; `400` surfaces an inline error.

### `DefaultStrategyPreference` — *new · tester feedback (default strategy)*
A per-device planner preference on `/account/settings`: which of the five plans is selected first when results load ([§ YearPlanComparison](#yearplancomparison--phase-1)). A labelled `<select>` of the five strategies, defaulting to **Longest breaks** (`packed-length`) — the testers' preferred starting point.

- **Appears on:** `/account/settings`, as its own "Planner" tile in the `SettingsTiles` masonry.
- **States:** default (Longest breaks); a stored choice (hydrated from `localStorage` after mount).
- **Props:** none — it reads/writes the preference itself.
- **Behaviour:** client-only. Reads `getDefaultStrategyPref()` ([src/lib/planner/strategy-pref.ts](../src/lib/planner/strategy-pref.ts)) after mount and persists each change immediately (no Save button — a single local control). `ResultsClient` reads the same preference via `pickDefaultStrategy(plans, pref)` ([src/lib/planner/default-strategy.ts](../src/lib/planner/default-strategy.ts)) to choose the initially-selected card. Deliberately **not** a server profile field yet (no schema-parity / persisted-doc-migration cost); promoting it to the user profile is tracked for the `pto`→`leave` schema change.
- **Accessibility:** a labelled `<select>` (accessible name "Default strategy"); the `appearance-none` + chevron gutter matches the settings form's other selects.

### `SettingsTiles` — *new · settings masonry (reduce vertical scroll)*
The layout wrapper for `/account/settings`. Lays its children out as a **masonry of tiles** so the settings cards — which vary a lot in height (the tall Account-settings form vs the short Theme card) — **interlock** instead of stacking in one long column, cutting the page's vertical scroll roughly in half on desktop.

- **Appears on:** `/account/settings` only (the page composes the four section tiles as its children).
- **Renders:** a container that is **one column on mobile** and **two columns on `lg`+** (CSS multi-column — `lg:columns-2` — so the fill is column-major and whole cards pack top-to-bottom, the "Metro-tile" effect). Each child is wrapped in a tile with `break-inside-avoid` so a card is never split across a column, and a consistent vertical gap. Width widens from `max-w-xl` (mobile, comfortable single column) to `max-w-4xl` (`lg`+, room for two columns) within `AppShell`'s `max-w-6xl` main.
- **Props:** `children: ReactNode` — the section cards, in DOM/reading order (Account settings → Theme → Availability profile → Planner). Order is column-major: the first column fills before the second.
- **A11y:** purely presentational (no role); DOM order = reading/tab order, so the column-major flow keeps a sensible focus sequence (down the left column, then the right).
- **Stage-2 Figma:** the two-column tile layout (Light + Dark), [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md).
- **Tests:** renders each child wrapped in a `break-inside-avoid` tile inside the responsive `lg:columns-2` container; preserves child order.

> **SHIPPED 2026-06-23.** `SettingsTiles` (`src/components/account/SettingsTiles.tsx`) wraps each child in a `mb-6 break-inside-avoid` tile inside a `mt-6 max-w-xl lg:max-w-4xl lg:columns-2 lg:gap-6` container. The `/account/settings` page composes four tiles through it (Account settings · Theme · Availability profile · Planner); the **Theme** card was un-nested from `AccountSettingsPanel` into its own page-level tile. Drawn `Pattern · /account/settings — tile masonry` Light `806:1580` / Dark `808:1580` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). `npm run check` green (1102 web tests).
- **Voice / copy:** label "Default strategy"; option labels are the canonical strategy names ("Best leverage" / "Longest breaks" / "Quarterly" / "Evenly spaced" / "One big trip"); help "Which plan is selected first when your trips load. Saved on this device."
- **Depends on:** [domain.ts](./domain.ts) (`PlanStrategy`), `src/lib/planner/strategy-pref.ts`, `src/lib/planner/default-strategy.ts` (`STRATEGY_LABELS`), `CountryPicker` (`ChevronGutter`).
- **Stage-2 Figma:** the labelled select + help text (Light + Dark).
- **Tests:** defaults the select to Longest breaks; persists a chosen strategy; hydrates the select from a stored preference.

### Settings — form-control consistency (revamp · 2026-06-25)
`/account/settings` is the densest form surface in the app (Account settings · Theme · Availability profile · Planner, tiled by `SettingsTiles`). Because each card was built by a different component — some with their own labels and buttons, some composing the shared pickers — the controls drifted out of alignment: sentence-case labels next to uppercase ones, `rounded-lg` inputs next to `rounded-md` ones, and a pill **Save changes** next to a `rounded-md` **Save profile** with a third padding on **Forget**. That mixed-control look is the "cluttered… different sized buttons and inputs" a tester flagged. The revamp is a **consistency pass** (no new fields, no behaviour change): every control on the page is brought onto the **one** label / input / button treatment that [DESIGN_SYSTEM.md §11–§12](./DESIGN_SYSTEM.md) makes normative.

- **Field labels** — every micro-label is the eyebrow `text-xs font-medium uppercase tracking-wide text-text-faint` (DESIGN_SYSTEM §12.2). This is what the shared `CountryPicker` / `AnnualPtoInput` / `MaxTripLengthInput` already render, so the change is to the **settings-local** labels — `AccountSettingsForm`'s *Display name* / *Language* and `DefaultStrategyPreference`'s *Default strategy* — which moved off the old sentence-case `text-text-muted` treatment onto the eyebrow.
- **Inputs & selects** — one geometry, `rounded-md … py-2 text-sm` (DESIGN_SYSTEM §12.1). `AccountSettingsForm`'s text input + language select, `DefaultStrategyPreference`'s select, and `CalendarOverlayPicker`'s add-select moved `rounded-lg → rounded-md`. The tall `h-[70px]` steppers stay (a distinct control class, §12.1).
- **Buttons** — one family, `rounded-md … px-4 py-2 text-sm font-medium` (DESIGN_SYSTEM §11.1/§11.3/§11.4). **Save changes** `rounded-full → rounded-md`; **Save profile** / **Forget profile** `px-5 py-2.5 → px-4 py-2`; `CalendarOverlayPicker`'s **Add calendar** `rounded-full → rounded-md` (also benefiting the planner's Advanced settings, where the same picker renders — no behaviour change). `rounded-full` stays only on the genuine pills/segmented controls (`ThemeSwitch`, the Free/PTO mode toggle, the "also off on" chips).
- **Rhythm** — the two form cards (`AccountSettingsForm`, `AvailabilityProfileEditor`) share a `gap-5` field stack and a `gap-1` label→control gap, so the cards read as one family across the masonry.
- **Scope guard:** the shared pickers' *internals* are untouched except `CalendarOverlayPicker`'s two cosmetic radii above; `SettingsTiles`' masonry layout, every field, and every save/validation path are unchanged. This is purely a visual-consistency pass.
- **Stage-2 Figma:** the `Pattern · /account/settings` tile-masonry frame pair refreshed to the unified treatment (uppercase labels, one button geometry) — Light + Dark ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)).
- **Tests:** `AccountSettingsForm` *Save changes* carries `rounded-md` (not `rounded-full`) at the `px-4 py-2 text-sm` size and its field labels carry the uppercase eyebrow; `AvailabilityProfileEditor` *Save profile* / *Forget profile* carry `px-4 py-2` (not `px-5 py-2.5`); `DefaultStrategyPreference`'s select carries `rounded-md` (not `rounded-lg`) and an uppercase label; `CalendarOverlayPicker`'s *Add calendar* carries `rounded-md`.

> **SHIPPED 2026-06-25.** Realized as class-only edits in four components (no behaviour change): `AccountSettingsForm.tsx` (Display name / Language labels → uppercase eyebrow; text input + language select `rounded-lg → rounded-md` + `text-sm`; Save changes `rounded-full → rounded-md`; form `gap-4 → gap-5`, label gap `gap-1.5 → gap-1`), `DefaultStrategyPreference.tsx` (label → eyebrow; select `rounded-lg → rounded-md`), `AvailabilityProfileEditor.tsx` (Save / Forget `px-5 py-2.5 → px-4 py-2`), `CalendarOverlayPicker.tsx` (Add-calendar button `rounded-full → rounded-md`; add-select `rounded-lg → rounded-md` — also tidies the planner's Advanced settings, which renders the same picker). `rounded-full` retained only on the genuine pills (`ThemeSwitch`, the Free/PTO mode toggle, the "also off on" chips) and the dashed chip-style "+ Add calendar" add-select. Figma frame pair refreshed to match (`806:1580` / `808:1580`). `npm run check` green (1112 web tests). **Deviation:** none — the schematic's buttons were already one geometry, so only the label eyebrow was redrawn in Figma; the live code is what had drifted.

### `DataExportPanel` — Phase 2
The DSAR "Access" right ([PRIVACY.md §13.3](./PRIVACY.md)). Requests `POST /me/export` and downloads the returned `ExportEnvelope` as a JSON file.

- **Appears on:** `/account/data` (full-page, under `AppShell`); reached from `AccountMenu` → "Download my data".
- **States:** idle, assembling (determinate progress while the export builds), done (download triggered + manifest summary), error (`ErrorState` with "Try again" + a `mailto:citizensdraft@gmail.com` fallback).
- **Props:** `onExport?: () => Promise<ExportEnvelope>` — defaults to `exportMe(authedFetch)`.
- **Behaviour:**
  - "Download my data" → `exportMe`. On success, serialise the envelope and trigger a client-side download named `roamgap-export-<uid>-<iso>.json` (from `subject.uid` + `generatedAt`); show the manifest summary (which subsystems are bundled vs linked).
  - **No silent failure** ([PRIVACY.md §13.8](./PRIVACY.md)): a failed export shows the error inline with retry + the mailto fallback.
  - Progress is determinate; if the export exceeds ~5s the spec permits a job+email pattern (not needed yet).
- **Accessibility:** the action is a `<button>`; `aria-busy` while assembling; progress conveyed in text; error in a `role="alert"`.
- **Telemetry:** `account_export_requested`, `account_export_succeeded`, `account_export_failed` `{ error_code }`. No data contents.
- **Voice / copy:** per [VOICE.md](./VOICE.md). Button "Download my data"; progress "Preparing your data…"; lead "A machine-readable JSON copy of everything tied to your account."
- **Depends on:** [domain.ts](./domain.ts) (`ExportEnvelope`), `src/lib/me/client.ts` (`exportMe`), `ErrorState`, [PRIVACY.md §13.3/§13.8](./PRIVACY.md).
- **Tests:** "Download my data" calls `exportMe`, triggers a blob download, and shows progress; a failure renders `ErrorState` with retry + a mailto; the download filename derives from the envelope's uid + timestamp.

### `TripSelectionBar` — Phase 3 · *new (§4.2.1) · supersedes per-card `ExportCalendarButton`*
The single, selection-scoped action surface for trip results ([PROJECT.md §4.2.1](./PROJECT.md), [CALENDAR_EXPORT.md §5/§7.1](./CALENDAR_EXPORT.md), [VOICE.md §4.1](./VOICE.md)). Result cards carry only a **selection checkbox** (§ `TripCard`); this bar appears once **at least one** card is selected and applies one action to the whole selection — replacing the old row of three buttons (`Add to itinerary` · Export `.ics` · Add to Google Calendar) on **every** card.

- **Appears on:** the whole-year results surface (`ResultsClient`). Rendered once per list (not per card), pinned above/with the results.
- **States:** hidden (0 selected); shown with **"{n} selected"** (≥1 selected); the Google action enabled only when **exactly 1** is selected.
- **Props:** `selected: Trip[]` (the transient selection from the list wrapper); `context: ItineraryContext` (country/subdivision/year/workweek — the provenance each itinerary selection carries, and the ISO country for the `.ics`/Google event); `sourceLabel?: string`; `onAddToItinerary: (trips: Trip[]) => void` (commits the selection to the draft); `onClear: () => void`; `generatedAt?: number` (injected clock for deterministic `.ics` `DTSTAMP` in tests); `download?: (filename, ics) => void` (the object-URL + `<a download>` seam, overridable in tests).
- **Actions (all over the current selection):**
  - **Add to itinerary** — `onAddToItinerary(selected)` pins every selected break into the browser-local itinerary draft ([ITINERARIES.md §2](../../backend/specs/ITINERARIES.md)), then clears the selection. (No new telemetry — the draft is browser-local.)
  - **Download `.ics`** — `tripsToIcs(selected, { country, countryName, generatedAt })` → one `VCALENDAR` with a `VEVENT` per break → download as `roamgap-<country>-<n>-breaks.ics` (or `icsFileName` for `n === 1`) with a `text/calendar` Blob. Emits the existing `calendar_exported { format: "ics" }`.
  - **Add to Google Calendar** — shown only when `selected.length === 1`: an `<a href={tripToGoogleCalendarUrl(trip, { countryName })} target="_blank" rel="noopener noreferrer">` opening Google's prefilled composer (§7.1; Google's link carries one event, so it is single-break only — the `.ics` is the multi-break path, and the bar says so). Emits the existing `calendar_exported { format: "google" }`. Reuses the same `Trip` SUMMARY/DESCRIPTION as the `.ics` ([CALENDAR_EXPORT.md §2](./CALENDAR_EXPORT.md)).
  - **Clear** — `onClear()` empties the selection (the bar hides).
- **Gating:** calendar export is **paid** ([PROJECT.md §5](./PROJECT.md)); the export actions are wrapped in `PaidGate` (UX-only) when the entitlement UI lands ([PAYMENTS.md §9](./PAYMENTS.md)) — `.ics`/Google build from a `Trip` already on the client, so this is inherently UX-only, not server-enforceable. Ships **ungated** until `PaidGate` exists. "Add to itinerary" stays free.
- **Accessibility:** a `role="region"` labelled "Selected trips"; each action is a text-labelled `<button>` / `<a>` (no icon-only affordance); the count is announced (`aria-live="polite"`).
- **Depends on:** `src/lib/calendar/ics.ts` (`tripToIcs`, `tripsToIcs`, `icsFileName`), `src/lib/calendar/google.ts` (`tripToGoogleCalendarUrl`), `src/lib/itineraries/store.ts` (`makeSelection`), [domain.ts](./domain.ts) (`Trip`, `ItineraryContext`), [CALENDAR_EXPORT.md](./CALENDAR_EXPORT.md).
- **Tests:** the bar is hidden at 0 selected and shows "{n} selected" at ≥1; "Download .ics" calls the injected `download` with an `.ics` string containing one `VEVENT` **per** selected trip; the Google action renders only at exactly 1 selected and its `href` equals `tripToGoogleCalendarUrl(...)`; "Add to itinerary" calls `onAddToItinerary` with the selection and clears it.

### `DeleteAccountForm` — Phase 2
The DSAR "Erasure" right ([PRIVACY.md §13.4](./PRIVACY.md), §13.8). A typed-email confirmation that triggers `POST /me/delete`.

- **Appears on:** `/account/delete` (full-page, under `AppShell`); reached from `AccountMenu` → "Delete my account" (the menu item renders in `status-danger`).
- **States:** idle (confirm disabled), armed (typed email matches → confirm enabled), deleting, done (confirmation + the 30-day note), error (account untouched).
- **Props:**
  - `email: string` — the signed-in user's email; the typed value must match it exactly.
  - `onConfirm: () => Promise<void>` — performs the `POST /me/delete` cascade + Firebase `signOut()`.
- **Behaviour:**
  - The user must **type their exact email** to enable the confirm button — no pre-filled value, no checkbox.
  - **No dark patterns** ([PRIVACY.md §13.8](./PRIVACY.md)): no countdown, no "we'll miss you", no last-chance discount; the field and the destructive button carry equal weight.
  - Confirm → `onConfirm`. On success → a confirmation interstitial "Account deleted. Hard-delete in 30 days." with a link home (the user-facing realisation of §13.4's redirect+toast — a clear interstitial rather than a transient cross-page toast). On failure → inline `ErrorState` with retry + a `mailto:citizensdraft@gmail.com` fallback; **the account is untouched** (a failed delete leaves everything in place).
- **Accessibility:** a single `<form>`; the confirm button is `disabled` until the email matches; `aria-busy` while deleting; errors in a `role="alert"`.
- **Telemetry:** `account_delete_confirmed`, `account_delete_succeeded`, `account_delete_failed` `{ error_code }`. No identity.
- **Voice / copy:** per [VOICE.md §2 "Account deletion"](./VOICE.md). Heading "Delete my account"; helper "Type your email to confirm."; confirm "Delete my account"; success "Account deleted. Hard-delete in 30 days."
- **Depends on:** `src/lib/me/client.ts` (`deleteMe`), `src/lib/auth/session.ts` (`signOut`), `ErrorState`, [PRIVACY.md §13.4/§13.8](./PRIVACY.md).
- **Tests:** confirm disabled until the typed value equals `email`; a wrong value keeps it disabled; confirm calls `onConfirm` and shows the confirmation on success; a failure shows `ErrorState` + mailto and leaves the form usable (account intact).

### `RestoreAccountInterstitial` — Phase 2
The "undo deletion" path ([PRIVACY.md §13.4](./PRIVACY.md) "Undo deletion"). Where a `403 account_deleted` from `POST /auth/token` lands — **not** a generic error page.

- **Appears on:** `/account/restore` (full-page, under `AppShell`); reached when a soft-deleted user signs in (the sign-in call site routes here on `403 account_deleted`).
- **States:** offer ("scheduled for deletion — restore it?"), restoring, declining, refusal (mapped per code).
- **Props:**
  - `onRestore: () => Promise<void>` — mints an anonymous Bearer, calls `POST /me/restore { firebaseIdToken }`, adopts the returned user Bearer, routes home.
  - `onKeep: () => Promise<void>` — Firebase `signOut()`, routes home.
  - `refusal?: "account_not_deleted" | "account_unrecoverable" | "not_found" | "firebase_token" | null` — set when `onRestore` rejects, to render the mapped copy.
- **Behaviour:**
  - "Restore my account" → `onRestore`. The anonymous-Bearer + `firebaseIdToken` model is the one `/me/*` exception to user-scope ([USERS.md §3.3](../../roamgap-backend/specs/USERS.md)).
  - "Keep deletion" → `onKeep`.
  - Refusal copy: `account_not_deleted` (409 — already live; "Your account is active." + home link), `account_unrecoverable` (410 — outside the trip; `mailto:citizensdraft@gmail.com` fallback), `not_found` (404 — re-sign-in), `firebase_token` (401 — re-sign-in).
- **Accessibility:** the offer is a `role="region"`; the two actions are `<button>`s; `aria-busy` while restoring; refusal copy in a `role="alert"`.
- **Telemetry:** `account_restore_offered`, `account_restore_accepted`, `account_restore_declined` ([TELEMETRY.md §3.2](./TELEMETRY.md)).
- **Voice / copy:** per [VOICE.md](./VOICE.md). Offer "Your account is scheduled for deletion. Restore it?"; "Restore my account" / "Keep deletion"; success (on the destination) "Welcome back. Your account is restored."
- **Depends on:** `src/lib/me/client.ts` (`restoreMe`), `src/lib/auth/session.ts` (`adopt`/`signOut`), `src/lib/firebase.ts`, [PRIVACY.md §13.4](./PRIVACY.md), [USERS.md §3.3](../../roamgap-backend/specs/USERS.md).
- **Tests:** offer renders with the two actions; "Restore my account" calls `onRestore`; "Keep deletion" calls `onKeep`; each refusal code renders its mapped copy (incl. the `account_unrecoverable` mailto).

### `AccountDeletedRedirect` — Phase 2 (non-visual)
The global guard that makes the *reload-while-soft-deleted* path land on the interstitial rather than a broken half-signed-in state ([PRIVACY.md §13.4](./PRIVACY.md) "Reload while soft-deleted"). A render-null client component mounted once in `src/app/layout.tsx`, inside `SessionProvider`.

- **Behaviour:** watches `useSession().status`. When the session's silent Bearer re-mint hits `403 account_deleted` it flips `status` to `"deleted"`; this guard then `router.replace("/account/restore")` unless already there. No-op for every other status. It does **not** sign the user out of Firebase — `/account/restore` needs the live Firebase identity to mint the restore call.
- **No Figma surface** — renders nothing; Stage 2 is a recorded no-op.
- **Depends on:** `src/lib/auth/session.tsx` (the `"deleted"` status), `next/navigation` (`useRouter`, `usePathname`).
- **Tests:** routes to `/account/restore` when `status` becomes `"deleted"`; stays put when already there; no-op for `loading` / `anon` / `authed`.

### Itinerary components — Phase 2

The Itinerary feature lets a user pin `Trip`s from any search onto a draft, detect overlapping days, join overlapping/adjacent trips, delete selections, and save the result to their account as a **named itinerary** (one trip-theme, e.g. "Spain 2026"). A signed-in user **manages multiple saved itineraries** — list, open, rename, delete. Concept term: **Itinerary** ([VOICE.md §3](./VOICE.md)). Backend wire contract: [roamgap-backend ITINERARIES.md](../../roamgap-backend/specs/ITINERARIES.md). The draft is browser-local (localStorage) until saved — the free/saved split mirrors searches ([PROJECT.md §5](./PROJECT.md)). The **count** is tiered: a free account keeps up to **2** saved itineraries, roamgap Plus is unlimited ([PROJECT.md §5](./PROJECT.md), [PAYMENTS.md §20](./PAYMENTS.md)).

- **`TripCard` / `TripList` (selection props)** — additive, default off. `selectable`, `selected`, `onToggleSelect(trip)` on the card; `selectable`, `selectedKeys`, `onToggleSelect` on the list. When `selectable`, the card renders a **selection checkbox** (§4.2.1; no longer an "Add to itinerary"/"Added" header toggle nor a per-card `ExportCalendarButton`). Existing non-selectable call sites are unchanged.
- **`SelectableTripList`** (`src/components/itinerary/`) — client wrapper that holds the **transient selection** (a `useState` set of trip keys — *not* the draft) and the one `useItineraryDraft()`, renders a `TripList` with the selection props wired, and renders the **`TripSelectionBar`** (§ above) below the list. "Add to itinerary" in the bar is what commits the selection to the localStorage draft; "Download .ics" / "Add to Google Calendar" export it. Takes `trips`, `context` (`ItineraryContext` — country/subdivision/year/workweek, the provenance each selection carries), and `sourceLabel`. Used in **single-break mode** (`/year?view=trip`) in place of the bare `TripList`; whole-year (`ResultsClient`) wires the same selection + bar inline.
- **`ItinerarySelectionList`** — presentational list of pinned trips; renders each as an **`ItineraryTripCard`** (header + `CalendarStrip` + `ScoreBadge` + composition summary — see "Saved-itinerary detail" below); two-step delete per selection (`onDelete(id)`) and the Phase-B edit affordance. Empty state when none.
- **`OverlapWarningBanner`** — shown only when the analyze report has overlapping/adjacent pairs. Honesty copy (VOICE.md §5): overlapping days are counted once; offers a "Join" button per pair (`onMerge(aId, bId)`). Adjacent pairs are offered too but don't skew totals.
- **`ItineraryPanel`** — the per-itinerary **editor**: the localStorage draft composer (a new itinerary) or a saved itinerary loaded at `/solo/[id]`. Holds the working selections, calls `POST /api/itineraries/analyze` on change, renders the aggregate header (honest distinct totals; naive sum disclosed only when `skewedByOverlap`), the banner, the selection list, an editable **name** (→ `PATCH /itineraries/:id` when editing a saved itinerary), and `SaveItineraryButton`. Merge → `POST /api/itineraries/merge` then replaces the pair in the draft.
- **`SaveItineraryButton`** — anon → sign-in CTA with the VOICE.md §5.1 "lives only in this tab" note; authed → `POST /itineraries` with a name derived from the first selection's `context` (country + year), renamable after. At the free cap (PROJECT.md §5), `409 itinerary_limit_reached` → renders `UpgradePrompt` (the upsell), not just a flat message. (Itineraries are still a **deliberate** save — unlike searches, which now autosave via `AutosaveSearch`.)
- **`ItinerariesList`** — presentational list of the caller's **saved** itineraries (mirrors `SavedSearchesList`). Each row shows the name, trip count, and the honest distinct days/PTO summary; **inline rename** (edit the name → `PATCH /itineraries/:id`); two-step delete (`onDelete(id)` → `DELETE /itineraries/:id`); the row opens the per-itinerary editor (`/solo/[id]`). Loading / empty / error states mirror `SavedSearchesList`.
- **`ItinerariesPanel`** — the signed-in tool inside the `/solo` **list** view (mirrors `SavedSearchesPanel`). Fetches `GET /itineraries`, renders `ItinerariesList`, wires rename + delete, and surfaces the unsaved localStorage **draft** (the `ItineraryPanel` composer) plus a "New itinerary" affordance. Reads `useEntitlement()`: when a free account is at the cap (2 saved itineraries — PROJECT.md §5), the save / new affordance renders `UpgradePrompt` rather than issuing `POST /itineraries`. Anon visitors get the sign-in CTA.
  - **Single placeholder (item 10, [ERROR_STATES.md §2.20](./ERROR_STATES.md)).** The page shows **at most one** empty placeholder. It computes `hasDraft` (draft selections > 0) and `hasSaved` (saved list non-empty). When **neither**, it renders **one** page-level `EmptyState` ("No itineraries yet…") and **suppresses** both the draft composer's own "No trips in this itinerary yet." and the saved list's "No saved itineraries yet." When **either** has content, only the populated section(s) render — the empty sub-section renders nothing (never a placeholder beside real content, never two stacked). The draft→save→prepend ("upgrading saved to new") path is untouched: a save clears the draft and prepends to the saved list, which then becomes the shown content. The error state (load failure) is distinct and replaces the saved-list region only — never stacked with the empty placeholder.
- **Edit mode (Phase B):** each selection card has an **Edit** toggle; in edit mode, ± controls on each end add/subtract a day and call `POST /api/itineraries/recompute` (via the panel's `onAdjust`), which reclassifies the changed day (holiday/weekend/pto) and updates the draft. Guards keep the range within the holiday year and never invert it.

**Saved-itinerary detail — date visualisation + per-Trip AI slot (this change).**
The saved detail page (`/solo/[id]`) previously rendered each pinned
Trip as a bare ISO text row (`2026-03-19 → 2026-03-22 · 4 days · 1 PTO`) — the
"communicates little data" state. It is brought up to the composer's fidelity and
given a forward-compatible slot for the Phase-5 AI recommendation:

- **`ItineraryTripCard`** — the **shared per-Trip card** rendered by both the
  composer's `ItinerarySelectionList` and the saved read view, so per-Trip rendering
  is defined once. Header: the human range (`fmtRange` → "19 Mar 2026 – 22 Mar 2026",
  never a raw ISO pair), the `sourceLabel` (or `context` country + year) as a
  sub-label, and a `ScoreBadge`. Body: the **`CalendarStrip`** (the date
  visualisation — week-stacked `DayCell`s, W/H/P/B letters + kind colour,
  keyboard-navigable per [ACCESSIBILITY.md §4.2](./ACCESSIBILITY.md)) and a
  **composition summary** line from `trip.composition` — the honest day-kind
  breakdown in the §3 register, e.g. "2 weekend · 1 public holiday · 1 PTO", naming
  any holidays as a trailing clause via `DayComposition.holidayName` ("… · Includes
  Eidul Fitr"); zero-count kinds are omitted.
  Footer: the Total / PTO `<dl>` (mirrors `TripCard`). **No new token.** The composer
  mounts it with `onDelete`/`onAdjust` (delete + Phase-B edit); the read view mounts
  it without them (non-interactive — no edit affordances).
- **Per-Trip AI destination slot (Phase 5, forward-compatible).** Each **read-view**
  `ItineraryTripCard` reserves a "Where to go" region (the composer omits it; the card's
  `showWhereToGo`) for the [PROJECT.md §7](./PROJECT.md)
  recommendation, scoped to **that Trip** — keyed by the existing
  `(home_country, start_date, end_date, budget_band)` tuple, i.e. **per date range,
  not per itinerary**. **Today** it is a quiet, non-blocking placeholder ("AI
  destination suggestions are coming.", [VOICE.md §4.15](./VOICE.md)) that issues
  **no backend call** and sits behind the dark `PaidGate` until Phase 3/4 opts AI in
  ([PAYMENTS.md §20](./PAYMENTS.md)). **At Phase 5** it renders the model output
  (destination countries → 2–3 towns each → accommodation *style* → why this Trip
  suits each), every AI paragraph carrying the [VOICE.md §5.3](./VOICE.md) disclaimer;
  the prompt + Zod output schema are owned by [AI_PROMPTS.md](./AI_PROMPTS.md) and
  validated at the boundary ([AGENTS.md §6](../AGENTS.md)) — a refusal or unparseable
  output degrades to a "couldn't generate" empty state, never a broken card.
- **`ItineraryDetailPanel`** (read view) — the client half of
  `/solo/[id]`. Loads `GET /itineraries/:id`, then renders: the
  editable **name** (inline rename → `PATCH /itineraries/:id`) + **Delete**; an
  **aggregate header** — the itinerary's honest **distinct** totals
  (`distinctTotalDays` / `distinctPtoDays` / `distinctEfficiency` from
  `POST /api/itineraries/analyze`, the naive sum disclosed only when `skewedByOverlap`,
  mirroring `ItineraryPanel`; shown for 2+ Trips); and the selections as **read-only `ItineraryTripCard`s**.
  In-place **edit / merge of a saved** itinerary stays out of scope (the draft composer
  assembles trips; [ITINERARIES.md §5.8](../../backend/specs/ITINERARIES.md) is
  rename-only) — a tracked "active itinerary" follow-up.
- **Single-Trip itinerary (the common "future trip" shape).** An itinerary may hold a
  **single Trip** (e.g. a planned May 2026 trip). With one selection: the aggregate
  header is omitted (overlap / `skewedByOverlap` cannot arise from one Trip — the lone
  card's own totals are the itinerary's), the lone `ItineraryTripCard` is the hero, and its AI slot is the primary
  content — the canonical "I know *when*; tell me *where*" surface. Count copy stays
  honest ("1 trip", [VOICE.md §3.1](./VOICE.md)); the page never reads as empty with
  one Trip.

### Solo trips — `/solo` (route composition)

The personal itinerary builder, promoted out of the `noindex` account surface to a
top-level, **indexable** feature page — the single-author sibling of `/groups`
([URL_FORMAT.md §12](./URL_FORMAT.md), [VOICE.md §4.11](./VOICE.md)). It follows the
"**one route, two faces**" pattern of `/groups` + `/overlap`: a public explainer for
crawlers + anonymous visitors, and the signed-in tool below it. **`SoloPage`** is page
composition, not a reusable component (like `GroupsPage` / `OverlapPage`).

- **`/solo`** — the page header (an `eyebrow` "Solo trips · roamgap", an `h1`, a lead
  paragraph, a 4-step **HowTo** list, and a feature list — the `/groups` header
  register) over the **`ItinerariesPanel`** tool (the saved list `GET /itineraries` +
  inline rename/delete + the localStorage **draft** composer that works signed-out,
  analyzed via `POST /api/itineraries/analyze`). Saves via `POST /itineraries` when
  signed in; a free account at the cap (PROJECT.md §5) sees `UpgradePrompt`. Reached
  from the **primary nav** ("Solo trips"). **Indexable**: WebApplication + HowTo
  JSON-LD ([json-ld.ts](../src/lib/seo/json-ld.ts)), a `.md` twin (`/solo.md`), in
  [`sitemap.ts`](../src/app/sitemap.ts) + [`llms.txt`](../public/llms.txt) — the
  discoverability stack `/groups` carries (AGENTS.md §9). The draft composer makes the
  signed-out face genuinely usable, not just an explainer.
- **`/solo/[id]`** — full-page **`ItineraryDetailPanel`** (the read view — see §
  Itinerary components) bound to one saved itinerary (`GET /itineraries/:id`): the
  aggregate header + read-only `ItineraryTripCard`s (date visualisation + composition
  + per-Trip AI slot), inline **rename** (`PATCH /itineraries/:id`) and **delete**.
  **`noindex`**, account-only — one person's saved itinerary, no content value (the
  `/g/[id]` posture). Reached from a row in `/solo`. (In-place **edit / merge of a
  saved** itinerary — the former `ItineraryPanel` binding here — is a tracked "active
  itinerary" follow-up; [ITINERARIES.md §5.8](../../backend/specs/ITINERARIES.md) is
  rename-only today.)
- **Migration.** Replaces `/account/itineraries[/id]`; both old paths **308-redirect**
  to the `/solo` equivalents ([URL_FORMAT.md §12.1](./URL_FORMAT.md),
  `next.config.ts` `redirects()`).

> **SHIPPED 2026-06-22.** `/solo` (`app/solo/page.tsx`) renders the explainer header
> (eyebrow "Solo trips · roamgap" → H1 "Plan your own trips" → lead → the four HowTo
> steps) + WebApplication/HowTo JSON-LD over the `ItinerariesPanel` tool; `/solo.md`
> twin + `sitemap.ts` + `llms.txt` entries ship the discoverability stack; `/solo/[id]`
> (`app/solo/[id]/page.tsx`) stays `noindex`. Old `/account/itineraries[/id]` removed,
> 308-redirected. Drawn `Pattern · Solo trips (/solo)` Light `770:1580` / Dark `773:1580`
> ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). **`npm run check` green — 1055 web Vitest.**
>
> **Deviation (recorded):** the page owns a **"Your itineraries"** `h2` wrapping the
> tool (the `/groups` "Your groups" parallel), so `ItinerariesPanel`'s own
> "New itinerary" / "Saved itineraries" sub-headings dropped from `h2` to **`h3`** to
> keep a single clean outline (`h1` → `h2` → `h3`). The `ItinerariesPanel` single-empty-
> placeholder logic (item 10, [ERROR_STATES.md §2.20](./ERROR_STATES.md)) is unchanged.

### Account pages — Phase 2 (route composition)
Not components — the page routes that host the Phase 2 components under `AppShell`. Listed here so the inventory is complete. All `/account/*` pages are `noindex` with no Markdown twin (auth-gated, not content).

- **`/sign-in`** — full-page `SignInForm`; `onSuccess` adopts the exchanged session ([docs/ARCHITECTURE.md § Authenticated requests](../docs/ARCHITECTURE.md)) and routes to the `?redirect=` target (default `/account/saved` — the signed-in landing; see § SignInForm Props). Reached from `NavAuthSlot` (and, on mobile, the `NavMenu` Sign-in entry).
- **`/account/saved`** — full-page `SavedSearchesList`; fetches `GET /searches` with the session Bearer, wires `onDelete` → `DELETE /searches/:id`. Anon visitors see a "Sign in to see your saved searches" CTA (routes to `/sign-in?redirect=/account/saved`). Reached from `AccountMenu` → "Saved searches".
- **`/account/settings`** — the settings sections laid out as a **masonry of tiles** (`SettingsTiles`, below) — one column on mobile, **two columns on `lg`+** so variable-height cards interlock and the page scrolls far less. The tiles, in order: **Account settings** (`AccountSettingsForm`, the tall one — loads the profile, saves via `PATCH /me`; a failed load renders the shared `ErrorState` with "Try again", [ERROR_STATES.md §2.17](./ERROR_STATES.md)), **Theme** (the `ThemeSwitch` card — now its own page-level tile, no longer nested in `AccountSettingsPanel`), **Availability profile** (`AvailabilityProfileEditor`), and **Planner** (`DefaultStrategyPreference`). Anon visitors get the sign-in CTA. Reached from `AccountMenu` → "Settings".
- **`/account/billing`** — full-page, two sections under `AppShell`: **Your plan** (`AccountBillingPanel` → `BillingStatusCard`; reads `GET /billing`, wires Cancel / Resume — `POST /billing/cancel` | `POST /billing/resume`, §24; upgrade / renew CTAs → `/checkout`) and **What roamgap Plus adds** (`PlanComparison`, the free-vs-Plus perks; honest "coming soon" while dark, §20/§22). Anon visitors get the sign-in CTA on the panel plus the comparison. Reached from `AccountMenu` → "Subscription".
- **`/account/data`** — full-page `DataExportPanel`; `POST /me/export` → downloads the `ExportEnvelope` JSON. Anon visitors get the sign-in CTA. Reached from `AccountMenu` → "Download my data".
- **`/account/delete`** — full-page `DeleteAccountForm`; typed-email confirm → `POST /me/delete` + Firebase `signOut()`. Anon visitors get the sign-in CTA. Reached from `AccountMenu` → "Delete my account".
- **`/account/restore`** — full-page `RestoreAccountInterstitial`; the soft-delete undo path. Reached when a soft-deleted user signs in (`403 account_deleted`), or automatically via `AccountDeletedRedirect` when a soft-deleted user reloads with a live Firebase session (the silent re-mint hits `403 account_deleted`). "Restore my account" → `POST /me/restore`; "Keep deletion" → `signOut()`.

## Paid tier (Phase 3)

The billing + entitlement surfaces. Contract: [PAYMENTS.md](./PAYMENTS.md) (web) + [roamgap-backend PAYMENTS.md](../../roamgap-backend/specs/PAYMENTS.md). The gate predicate and the client gate are [PAYMENTS.md §20/§21](./PAYMENTS.md) (canonical for the UX gate); the `useEntitlement()` shape and the `PassStatusBanner` states are §21.1/§21.3. Money moves through **WiraPay** (the ike.work payment service; card + M-Pesa via Paystack behind it) — roamgap holds no Paystack credentials. The live status + manage surface is `BillingStatusCard` / `/account/billing` (specified below).

> **These six ship *dark* (PAYMENTS.md §20).** The whole gate layer below — `BillingProvider`/`useEntitlement`, `PaidGate`, `UpgradePrompt`, `RenewPrompt`, `PassStatusBanner`, `BillingStatusCard` — is built and wired but **gates nothing today**: everything roamgap implements now is free, and `PaidGate` wraps no surface until the first Phase-3/4 paid feature (shared itineraries, couple syncing, AI suggestions) opts in. They are exercised only by `/account/billing` and unit tests until then. All read the **current** `BillingStatusResponse` (`entitled` + `amount` + `pendingPriceChange`; `BillingStatus = active | past_due | non_renewing | expired`) — **not** the legacy `expiring`/`cancelled`/`EntitlementState` names some entries below still referenced before this change.

### `BillingProvider` + `useEntitlement()` — Phase 3 (non-visual)
The client entitlement context ([PAYMENTS.md §21.1](./PAYMENTS.md)). `BillingProvider` sits beside `SessionProvider`; on an authenticated session it fetches `GET /billing` **once**, caches it, and exposes the `useEntitlement()` hook every gate component reads. For an anonymous visitor it resolves to the un-entitled default (`entitled: false`, `status: 'none'`) **without a request**. Built dark (§20) — present but wrapping no paid feature today.

- **Hook shape (the contract):** `useEntitlement(): { entitled, status, currentPeriodEnd, amount, pendingPriceChange, cancelAtPeriodEnd, paymentIssue, refresh }`, all derived from `BillingStatusResponse` ([domain.ts](./domain.ts)). `entitled` is the **only** gate predicate; the rest are display inputs. `status ∈ 'none' | 'active' | 'past_due' | 'non_renewing' | 'expired'`.
- **Refresh triggers:** session load; return from `/checkout`; throttled window refocus; any `403 unpaid_tier` from a paid call. After a checkout return it may poll a few times (backing off) to absorb M-Pesa webhook lag (§21.1).
- **No Figma surface** — a context provider + hook; Stage 2 is a recorded no-op ([AGENTS.md §1](../AGENTS.md)).
- **Depends on:** `@/lib/domain` (`BillingStatusResponse`, `PendingPriceChange`), `src/lib/billing/client.ts` (`getBillingStatus`), `src/lib/auth/session.tsx`, [PAYMENTS.md §21.1](./PAYMENTS.md).
- **Tests:** anonymous → `entitled: false`, no fetch; authed → one `GET /billing`, hook reflects the response; `refresh()` re-fetches; a `403 unpaid_tier` triggers a reconcile refresh.

### `PaidGate` — Phase 3
The entitlement boundary every locked surface wraps its paid content in ([PAYMENTS.md §21.2](./PAYMENTS.md)). Renders children only when `entitled`; otherwise renders a renew/upgrade prompt and **issues no paid backend request**. **Built dark (§20): wraps no surface today** — it goes live the moment a Phase-3/4 feature (shared itineraries, couple syncing, AI suggestions) opts in.

- **Will wrap (once live):** the Phase-3/4 paid features — shared itineraries, couple syncing, AI-suggestions panel, and any later historical-year / multi-country / export-when-paid surface. **Today it wraps nothing** (everything implemented is free, §20).
- **States:** `entitled` (renders children), `expired` (renders `RenewPrompt` — `status: 'expired'`, or `non_renewing` past its period end), `free` (renders `UpgradePrompt` — never subscribed), `confirming` (post-checkout reconciliation poll — "confirming your payment…").
- **Props:** `feature: string` (the gated capability, used in telemetry + the server `unpaid_tier` detail); `children` (the paid content, mounted only when entitled — so its data fetch fires only then).
- **Behaviour:** reads `useEntitlement().entitled` synchronously from the cached `BillingProvider` state (§21.1) — the gate decision needs **no** backend round-trip. The server re-checks authoritatively (`@RequirePaidTier` → `403 unpaid_tier`); this is UX-only.
- **Accessibility:** locked state is a `role="region"` with a clear heading; focus lands on the renew/upgrade CTA.
- **Telemetry:** `paywall_viewed` `{ feature }` on locked render.
- **Depends on:** `BillingProvider`/`useEntitlement` (`src/lib/billing/`), `RenewPrompt`, `UpgradePrompt`, [PAYMENTS.md §20/§21](./PAYMENTS.md).
- **Tests:** entitled → renders children (and only then triggers their fetch); expired → `RenewPrompt`, no paid request issued; free → `UpgradePrompt`; a `403 unpaid_tier` from a child reconciles via `refresh()`.

### `UpgradePrompt` — Phase 3
Paywall for **never-subscribed** users at a paid trigger. States the plan + price and links to `/checkout` (or `/pricing` for the fuller story). Built dark (§20) — shown by `PaidGate`'s `free` state, which wraps nothing today.

- **Appears in:** `PaidGate` (free state) and inline at the Phase-3/4 upgrade triggers once they exist (shared itineraries, couple syncing, AI suggestions). **No mount today** — every current feature is free (§20).
- **States:** default; `loading` (price not yet hydrated — falls back to a configured static string).
- **Props:** `feature: string`; `priceLabel: string` (the **display** price from `NEXT_PUBLIC_RG_PLAN_PRICE_KES` via [`planPriceLabel`](../src/lib/billing/plan.ts) — display only; the charged amount is WiraPay's, [PAYMENTS.md §23](./PAYMENTS.md)).
- **Behaviour:** "Upgrade" → `/checkout?feature=…`. Never blocks the free capability that sits beneath it (local drafts still work).
- **Accessibility / Voice:** CTA is a `<button>`/link; copy per [VOICE.md](./VOICE.md). No dark patterns.
- **Telemetry:** `paywall_viewed`/`paywall_dismissed` `{ feature }`.
- **Depends on:** [PAYMENTS.md §21.2/§23/§26](./PAYMENTS.md), `src/lib/billing/plan.ts`, [VOICE.md](./VOICE.md).
- **Stage-2 Figma:** a frame (Light + Dark) — already an outstanding to-do ([PAYMENTS.md §27](./PAYMENTS.md)).
- **Tests:** renders the price; "Upgrade" routes to `/checkout`; dismiss fires telemetry.

### `RenewPrompt` — Phase 3
Locked-state surface for **lapsed** users — `status: 'expired'`, or `non_renewing` past its period end ([PAYMENTS.md §21.2](./PAYMENTS.md)). Reassures that saved data is retained. Built dark (§20).

- **Appears in:** `PaidGate` (expired state). No mount today (nothing is gated).
- **States:** default; `confirming` (renewal paid, awaiting webhook reconciliation).
- **Props:** `priceLabel: string` (display price, §23); `expiredAt?: number` (`currentPeriodEnd`); `method?: 'card' | 'mpesa'`.
- **Behaviour:** "Renew" → `/checkout?renew=1`. Copy: "Your roamgap Plus ended {date}. Your saved trips are safe — renew to view them across devices and unlock paid tools." After payment, shows `confirming` until `refresh()` flips `entitled` (§21.1).
- **Accessibility / Voice:** `role="region"`; per [VOICE.md](./VOICE.md); explicitly non-punitive about retained data.
- **Telemetry:** `pass_expired_viewed`, `renew_prompt_clicked`.
- **Depends on:** [PAYMENTS.md §21.1/§21.2/§26](./PAYMENTS.md), `useEntitlement`.
- **Stage-2 Figma:** a frame (Light + Dark) — outstanding to-do ([PAYMENTS.md §27](./PAYMENTS.md)).
- **Tests:** renders the expiry date + retained-data assurance; "Renew" routes to `/checkout?renew=1`; `confirming` clears on entitlement refresh.

### `PassStatusBanner` — Phase 3
App-shell banner driven by `useEntitlement()` ([PAYMENTS.md §21.3](./PAYMENTS.md)) — renders the **pass state**, never gates. Built dark (§20): wired to render from billing status, but with no paid feature live it only ever surfaces a price-change / cancel / payment-issue / welcome-back state to a Plus subscriber, never a gate.

- **Appears in:** `AppShell`, above page content, when the §21.3 table yields a visible state; absent when `entitled` and clean (`active`, no issues), and absent for `free`.
- **States (the §21.3 table):** **active-until** (informational, dismissible — "renews {date}"), **cancels-on** (`non_renewing`/`cancelAtPeriodEnd` — "ends {date}, resume before then"), **payment-issue** (`paymentIssue` card dunning — "update card"), **price-changing-on** (`pendingPriceChange != null` — "your price changes to {newAmount} on {date}", **new** §25), **expired** (`status: 'expired'`, welcome-back; dismissible, re-shown next session while expired). Precedence: payment-issue > price-changing-on > cancels-on > active-until; `expired` is exclusive.
- **Props:** derives from `useEntitlement()` (no required props); `onDismiss` persists dismissal for the session.
- **Behaviour:** Renew/Update/Resume → `/checkout` or `/account/billing`. Countdowns computed from `currentPeriodEnd` / `pendingPriceChange.effectiveAt` against the local clock; flips copy when a threshold is crossed without reload.
- **Accessibility:** `role="status"` (non-assertive); dismiss is a labelled `<button>`; colour is not the only signal (icon + text).
- **Telemetry:** `pass_banner_viewed` `{ state }`, `pass_banner_dismissed`; the price-change state additionally fires `price_change_notice_viewed` ([TELEMETRY.md](./TELEMETRY.md)).
- **Depends on:** `useEntitlement`, [PAYMENTS.md §21.3/§25](./PAYMENTS.md), [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (amber/status tokens).
- **Stage-2 Figma:** the §21.3 states, chiefly the **price-changing-on** state, Light + Dark ([PAYMENTS.md §27](./PAYMENTS.md)).
- **Tests:** renders per state and precedence; absent when `entitled`-and-clean / `free`; a `pendingPriceChange` renders the price-changing-on copy and fires `price_change_notice_viewed`; a crossed threshold flips copy; dismissal persists for the session.

### `BillingStatusCard` — Phase 3
The **live** `/account/billing` hub (it replaced the interim `BillingPanel`, now removed). Current status, method, period end + countdown, the **price line** (current `amount`, plus the §25 price-change line when `pendingPriceChange` is set), **Cancel / Resume**, and payment history.

- **Appears on:** `/account/billing` (full-page, under `AppShell`; `noindex`).
- **States:** `none` (upgrade CTA), `active` (method + renews date; **Cancel** control; card → also Update card; M-Pesa → also "Renew early"), `non_renewing` (cancels-on date + **Resume**), `past_due` (`paymentIssue` — Update card), `price_change_pending` (the §25 line, alongside whichever base state applies), `expired` (Renew), `loading`, `error`.
- **Props:** `billing: BillingStatusResponse`; `onCancel?`, `onResume?`, `onRenew?` (default to the billing client — `POST /billing/cancel` / `POST /billing/resume`, §24).
- **Behaviour:** **Cancel** → `POST /billing/cancel` → reflects `non_renewing` / `cancelAtPeriodEnd` (access holds to `currentPeriodEnd`, shown); **Resume** → `POST /billing/resume` → back to `active`; M-Pesa "Renew early" → `/checkout?renew=1`. After any mutation, `refresh()` re-reads `GET /billing` and re-renders from the reconciled state (no optimistic fake). Lists recent charges (no PANs). No dark patterns on Cancel ([PAYMENTS.md §24](./PAYMENTS.md)).
- **Accessibility / Voice:** single region with labelled actions; destructive Cancel is not dark-patterned; per [VOICE.md](./VOICE.md).
- **Telemetry:** `billing_viewed` `{ state }`, `billing_cancelled`, `billing_resumed`, `pass_renewed` ([TELEMETRY.md](./TELEMETRY.md)).
- **Depends on:** `@/lib/domain` (`BillingStatusResponse`, `PendingPriceChange`), `src/lib/billing/client.ts`, [PAYMENTS.md §21.4/§24/§25](./PAYMENTS.md).
- **Stage-2 Figma:** the §24 Cancel/Resume controls + the §25 price-change line, Light + Dark ([PAYMENTS.md §27](./PAYMENTS.md)).
- **Tests:** renders each state; Cancel calls `onCancel` and reflects `non_renewing`/`cancelAtPeriodEnd`; Resume calls `onResume` and reflects `active`; a `pendingPriceChange` renders the §25 line; M-Pesa Renew routes to `/checkout?renew=1`; error → `ErrorState` with retry.

### `PlanComparison` — Phase 3 · *the perks/comparison surface*
A free-vs-Plus perks comparison, driven entirely by the shared [`plan.ts`](../src/lib/billing/plan.ts) config so it can't drift from `/pricing` or the JSON-LD. The honest framing of the dark state ([PAYMENTS.md §20/§22](./PAYMENTS.md)) is built in: it never fabricates availability and injects **no** buy CTA — the host owns any upgrade/checkout action. (Distinct from the planner's `YearPlanComparison`; this is the billing/plan-tier comparison, in `components/billing/`.)

- **Appears on:** `/account/billing` (under the status card — "what roamgap Plus adds") and `/checkout` (above the Continue CTA — "what you're getting"). Reusable anywhere the free-vs-Plus value needs stating.
- **Two columns** (stacked on mobile, side-by-side from `sm`): **Free — no account needed** (`PLAN_FREE_TODAY`, • bullets, `text/muted`) and **roamgap Plus** (✓ accent). Mode-aware via `isSelling()`:
  - **coming-soon (today, dark):** the Plus column lists `PLAN_COMING_FEATURES` under a **"Coming soon"** tag, price shown as *planned* (`plannedPriceLabel`). Honest that nothing is buyable yet.
  - **selling (flip later):** the Plus column lists `PLAN_FEATURES` with the live price (`planPriceLabel`).
- **Props:** `heading?: string` (section title; mode default otherwise), `className?`. **No CTA inside** — `/account/billing` and `/checkout` supply their own.
- **Server component** — pure render from `plan.ts` (no client hooks); SSR-friendly.
- **Voice / honesty:** no superlatives; the dark-state Plus column is plainly "coming soon" ([VOICE.md §2/§3](./VOICE.md); [PAYMENTS.md §22](./PAYMENTS.md)). No fabricated availability.
- **Depends on:** [`plan.ts`](../src/lib/billing/plan.ts) (`PLAN`, `PLAN_FREE_TODAY`, `PLAN_COMING_FEATURES`, `PLAN_FEATURES`, `isSelling`, `plannedPriceLabel`/`planPriceLabel`), [PAYMENTS.md §20/§22/§23](./PAYMENTS.md).
- **Stage-2 Figma:** the two-column comparison card (coming-soon framing), Light + Dark.
- **Tests:** coming-soon mode lists the free-today items + the coming features under "Coming soon" with no buy CTA; selling mode lists the paid features + the live price; both column headings always render.
- **SHIPPED 2026-06-26.** `src/components/billing/PlanComparison.tsx`, mounted on `/account/billing` (under the status panel) and `/checkout` (as "What you're getting" above the Continue panel). Drawn `Pattern · Billing — plan comparison` Light `828:1580` / Dark `828:1746` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). `npm run check` green — 1137 web tests.

### `CheckoutConfirmPanel` — Phase 3
The client half of `/checkout/confirm` ([PAYMENTS.md §26.1](./PAYMENTS.md)) — the screen the payer lands on after WiraPay's hosted checkout. Reads WiraPay's advisory `wirapay_status` / `wirapay_reference` query hints, then **polls `GET /billing` for the authoritative entitlement** (the server is the gate, never the query param — the W2 rule). Replaces the old dead-end where `/checkout?return=1` read a non-existent `status` param and only showed a static "confirming…" line.

- **Appears on:** `/checkout/confirm` (full-page, under `AppShell`; `noindex`, account-only). Entered only via WiraPay's post-checkout redirect (or a deep-link); not linked from any nav.
- **States ([PAYMENTS.md §26.1](./PAYMENTS.md)):** **sign-in** (session lapsed → CTA back to `/checkout/confirm`); **verifying** (`aria-busy` "Confirming your payment…", the backoff poll absorbing webhook/M-Pesa lag); **confirmed** (`entitled` flipped true → "You're on roamgap Plus" + **Start planning** (`/year`) + billing link; fires `checkout_succeeded` once); **received** (`wirapay_status=success` but the poll window elapsed un-entitled → honest "Payment received — activating shortly" holding state + billing link; **no dead-end spinner** — this is the Paystack test-mode landing); **not-completed** (`wirapay_status` ∈ {`failed`,`abandoned`,`expired`} → "Your payment wasn't completed" + **Try again** → `/checkout`).
- **Props:** none — reads `useSearchParams()` (the WiraPay hints), `useSession()`, and `useEntitlement()`. Sits under a `Suspense` boundary (the page provides it) because of `useSearchParams`.
- **Behaviour:** on mount fires `checkout_returned` once with `outcome` mapped from `wirapay_status` (`success→succeeded`, `abandoned→cancelled`, else `unknown`); on the success/unknown path runs a few-attempt backing-off `refresh()` poll; transitions to **confirmed** the moment `entitled` is true, else settles on **received** when the poll exhausts.
- **Accessibility / Voice:** each state is a `role="region"` (verifying adds `aria-busy`) with a clear heading and a single forward CTA; copy in register ([VOICE.md](./VOICE.md)) — honest, no superlatives, no dark patterns; **not-completed** assigns no blame.
- **Telemetry:** `checkout_returned` `{ outcome }`, `checkout_succeeded` (once, on confirmed) ([TELEMETRY.md §3.2](./TELEMETRY.md)).
- **Depends on:** `useEntitlement` / `BillingProvider` (`src/lib/billing/`), `src/lib/auth/session.tsx`, `@/lib/telemetry`, [PAYMENTS.md §26.1](./PAYMENTS.md).
- **Stage-2 Figma:** the §26.1 states (verifying / confirmed / received / not-completed), Light + Dark ([PAYMENTS.md §27](./PAYMENTS.md)).
- **Tests:** anonymous → sign-in CTA; `wirapay_status=success` + entitled → confirmed + `checkout_succeeded`; success + not-yet-entitled poll → verifying then received; `abandoned`/`failed` → not-completed + Try again; `checkout_returned` outcome maps from `wirapay_status`.

### Future components — tracked in the ROADMAP

This inventory documents **built** components. Components for unbuilt phases (e.g. the Phase-5 `DestinationsList` for AI recommendations) are tracked per phase in [ROADMAP.md](../../../ROADMAP.md) and specced when picked up (spec → Figma → dev) — they earn a `### Name` entry here once built.

## Multi-country overlap (Phase 4)

The `/overlap` feature — *"when can we all be off together?"* (couples + small groups). Contract: [MULTI_COUNTRY.md](./MULTI_COUNTRY.md) (web) + [roamgap-backend OVERLAP.md](../../roamgap-backend/specs/OVERLAP.md) (engine). Concept terms: **Overlap** / **shared break** / **person** ([VOICE.md §3](./VOICE.md)). Schemas: `OverlapRequest`/`Overlap`/`OverlapOutput` ([domain.ts](./domain.ts)).

> **All four mount inside `PaidGate feature="multi_country_overlap"`** on the account-only `/overlap` page ([MULTI_COUNTRY.md §3](./MULTI_COUNTRY.md)) — overlap is a **roamgap Plus** feature. While the gate ships dark ([PAYMENTS.md §20](./PAYMENTS.md)) any signed-in user reaches them; anonymous visitors get the sign-in CTA / `UpgradePrompt`. They reuse existing `DESIGN_SYSTEM.md` colour tokens (success / warning / danger) — **no new tokens**.

### `OverlapPartyEditor` — Phase 4

The party form: 2–6 people, each a country + optional region + PTO budget + optional workweek ([MULTI_COUNTRY.md §4.1](./MULTI_COUNTRY.md)). Maps to `OverlapRequest.parties` + the shared `year`/`filters`.

- **Appears on:** `/overlap` (the free, open tool — no `PaidGate`, [MULTI_COUNTRY.md §3](./MULTI_COUNTRY.md)).
- **States:** default (2 parties — "You" + "Person 2"); `at-max` (6 parties — "Add person" disabled with a one-line reason, [VOICE.md §4.3](./VOICE.md)); per-party `invalid` (a region not matching its country → inline warn, [ERROR_STATES.md](./ERROR_STATES.md)); `min` (2 parties — remove disabled).
- **Props:** `parties: OverlapParty[]`, `year`, `filters?`, `onChange(req: OverlapRequest)`, `max = 6` (`OVERLAP_MAX_PARTIES`).
- **Behaviour:** add/remove a party (clamped 2–6); each party row composes `CountryPicker` + `SubdivisionPicker` + a PTO input + an optional workweek control; the first party is labelled "You" (seeds the `/overlap?p=…` order, [URL_FORMAT.md §3.5](./URL_FORMAT.md)). `observedCalendars` is **not** offered ([OVERLAP.md §5.5](../../roamgap-backend/specs/OVERLAP.md)). The party rows lay out as a **responsive wrapping grid** (1 → 2 → 3 columns) that **wraps after the third** like the `/year` strategy cards (item 11, [MULTI_COUNTRY.md §4.1](./MULTI_COUNTRY.md)); `OverlapTool` seats the editor at content width above the results (no 360 px sidebar), and the idle *"Add who's coming…"* helper line under the empty results column is **removed** (item 12).
  - **"Also off on" add control:** the `<select>` that adds an `alsoObserves` calendar is styled as the dashed **`+ country`** pill drawn in Figma (`512:48`) — visually a sibling of the **`+ Add person`** button. It carries `appearance-none` so the browser's native dropdown arrow is removed; without it the artefact chevron sits cramped against the pill's rounded right edge and breaks the match with the chevron-free add button. The same control + treatment is reused by [`MemberAvailabilityEditor`](#memberavailabilityeditor--phase-4½).
  - **Per-entry region (UX-2):** each added `alsoObserves` chip whose country has subdivisions carries an **inline region `<select>`** (default *National*) so an extra calendar can be refined to a subdivision — *Germany → Bavaria* — exactly as the main `CountryPicker` pairs Country + Region. Writes `HolidaySource.subdivision` (which already rides the `/overlap?p=…` grammar, [URL_FORMAT.md §3.5](./URL_FORMAT.md), and the backend's per-source holiday union). Reused by `MemberAvailabilityEditor`.
  - **Shared calendar customisations:** below the party grid the editor embeds the reused **[`FilterPanel`](#filterpanel--phase-1)** (`variant="panel"` — months / excluded weekdays / blocked dates, the same control as `/year`'s Advanced settings) under a "Shared window — applies to everyone" label, wired to `OverlapRequest.filters` and cleared to `undefined` when empty. This is `/overlap`'s parity with `/year`'s calendar customisations, applied to the whole group ([MULTI_COUNTRY.md §4.1](./MULTI_COUNTRY.md)). `observedCalendars` stays per-party (*Also off on*), never a shared filter.
- **Accessibility:** each party is a labelled `fieldset`/`legend`; add/remove are labelled `<button>`s; the disabled-at-cap control keeps an accessible reason.
- **Depends on:** `CountryPicker`, `SubdivisionPicker`, the PTO/workweek inputs, `@/lib/domain` (`OverlapParty`/`OverlapRequest`), [MULTI_COUNTRY.md §4.1](./MULTI_COUNTRY.md).
- **Stage-2 Figma:** the multi-party form, add/remove, the at-cap disabled state (Light + Dark) — [MULTI_COUNTRY.md §11](./MULTI_COUNTRY.md).
- **Tests:** add/remove clamps to 2–6; a region/country mismatch shows the inline warn and is excluded from the emitted request; the emitted `OverlapRequest` round-trips through the schema.

### `OverlapCalendar` — Phase 4

The 12-month heatmap over `OverlapOutput.calendar` (`OverlapDay[]`), three colour states ([MULTI_COUNTRY.md §4.2](./MULTI_COUNTRY.md)).

- **Appears on:** `/overlap` results (inside `PaidGate`).
- **States (per day cell):** `free-all` (green — everyone off, no leave), `pto-some` (amber — off for some), `pto-all` (red — a working day for all); plus `loading` and the `meta.noCommonWindow` empty treatment.
- **Props:** `calendar: OverlapDay[]`, `parties: OverlapPartySummary[]` (for the hover/legend).
- **Behaviour:** colours each day from `OverlapDay.state`; hover/focus reveals which parties are free vs would spend leave (`freeFor` / `ptoFor`). Reuses the `YearCalendar` / `MonthGrid` grid scaffolding; only the cell fill rule differs.
- **Accessibility:** colour is **not** the only signal — each state has a legend label (`OverlapLegend`) and the per-day breakdown is reachable on focus; meets the [ACCESSIBILITY.md](./ACCESSIBILITY.md) non-colour-only rule.
- **Depends on:** `MonthGrid`/`YearCalendar`, `OverlapLegend`, [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (success/warning/danger), `@/lib/domain` (`OverlapDay`).
- **Stage-2 Figma:** the 12-month grid in all three states + legend (Light + Dark).
- **Tests:** a weekday that is a holiday for one party and a workday for another renders `pto-some`; a shared holiday `free-all`; an ordinary weekday `pto-all`; the hover surfaces the correct `freeFor`/`ptoFor`.

### `OverlapResultCard` — Phase 4

One ranked `Overlap` — the per-party breakdown card (the ROADMAP's "WindowCardOverlap"), [MULTI_COUNTRY.md §4.2](./MULTI_COUNTRY.md).

- **Appears in:** the ranked-overlaps list on `/overlap` (best first — least combined leave).
- **States:** default; `over-budget` (a party's `overBudget` — surfaced, not in Phase 4 but contract-ready); the list's empty/`noCommonWindow` state is owned by the page, not the card.
- **Props:** `overlap: Overlap`, `parties: OverlapPartySummary[]`, `onAddToItinerary?: () => void` (UX-3 — when present, renders the **Add to itinerary** action), `addedToItinerary?: boolean` (UX-3 — when true the action reads **"Added"** and is disabled), `selectable?: boolean` / `selected?: boolean` / `onToggleSelect?: () => void` (item 8 — when `selectable`, the whole card is a click-to-select toggle that goes **transparent** when `selected`, the same affordance as `TripCard`; the group host wires the one-action bar over the selection — **Add to trip** / **Add to poll**, [GROUPS.md §4.2/§4.8](./GROUPS.md)), `hero?: boolean` (groups polish — full-width **hero** variant for the top-ranked shared break: a larger headline + the labelled `OverlapDayStrip` instead of the compact squares, [GROUPS.md §4.2](./GROUPS.md)).
- **Behaviour:** shows the span + `totalDays`, the **combined** headline (`combinedPtoDays` + `combinedEfficiency`), a per-party row (each party's `byParty[*].ptoDays`), and a day strip coloured from `Overlap.days` (the `hero` card uses the labelled `OverlapDayStrip`; the compact card the inline squares). "Share" emits the `/overlap?p=…` URL ([URL_FORMAT.md §3.5](./URL_FORMAT.md)).
  - **Which holidays the window covers (groups polish).** The card derives the **public holidays inside `[start, end]`** from each party's `byParty[*].composition` (`kind === 'holiday'` → `holidayName` + the observing party's country, deduped by date+name) and surfaces them as a compact **"Public holidays"** note under the strip + per-day-cell tooltips — the missing *why* behind the overlap colour ([GROUPS.md §4.2](./GROUPS.md)). Pure projection of data already on the `Overlap`; a window with no holiday shows no note.
  - **Add to itinerary (UX-3).** An optional action that pins this window onto the browser-local itinerary draft ([ITINERARIES.md §2](../../backend/specs/ITINERARIES.md)). An `Overlap` is **multi-party** (`byParty[…]`) but an `ItinerarySelection.trip` is **single-party**, so a perspective is chosen: the **signed-in user's own party** — `parties[0]` ("You") in the `/overlap` tool, the caller's `self` member in the group view ([GROUPS.md §4.2](./GROUPS.md)). The card itself is presentational — the **parent** owns the single `useItineraryDraft()` (one instance, so a per-card hook can't clobber the shared localStorage array) and passes `onAddToItinerary` + `addedToItinerary` per window. The conversion lives in `src/lib/itineraries/from-overlap.ts` (a pure `overlapToSelection`).
- **Depends on:** `CalendarStrip`, `ScoreBadge`, `ShareButton`, `@/lib/domain` (`Overlap`); the host wires `useItineraryDraft` + `overlapToSelection`.
- **Stage-2 Figma:** span + combined PTO + per-party breakdown + day strip + the **Add to itinerary** action (Light + Dark).
- **Tests:** renders combined + per-party PTO; the day strip matches `Overlap.days`; ranks/render order is the array order (already sorted by the backend).

### `OverlapDayStrip` — Phase 4½ (groups polish)

A presentational, **full-width labelled day-cell row** over an `OverlapDay[]` — the visual the bare *"3 Apr – 6 Apr"* poll option and the compact card squares lacked ([GROUPS.md §4.2/§4.6](./GROUPS.md)). One cell per day: the **date number** (top), the **three-state colour** box (`free-all` green / `pto-some` amber / `pto-all` red — the same states as `OverlapCalendar`), and the **weekday** abbreviation (bottom). Shared by the shared-break **hero** card and every **poll option**.

- **Appears in:** `OverlapResultCard` (`hero`), `GroupDatePoll` (per option).
- **States:** `compact` (small squares + number, no weekday — the in-card default) and `full` (the labelled, larger cells); `empty` (no days → renders nothing, so the poll falls back to the plain range when no overlap calendar is loaded).
- **Props:** `days: OverlapDay[]`, `parties: OverlapPartySummary[]` (for tooltip names), `size?: 'compact' | 'full'`, `holidaysByDate?: Map<string, string[]>` (optional date → holiday-and-who labels, threaded by the card so the tooltip names the holiday; the poll passes none), `locale?`.
- **Behaviour:** colours each cell from `OverlapDay.state`; the per-cell `title` names the state + which parties are free vs spend PTO (`freeFor`/`ptoFor`) + any holiday — colour is never the only signal ([ACCESSIBILITY.md](./ACCESSIBILITY.md)).
- **Depends on:** `@/lib/domain` (`OverlapDay`/`OverlapPartySummary`), [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (success/warn/danger), [GROUPS.md §4.2/§4.6](./GROUPS.md). **No new token.**
- **Stage-2 Figma:** the labelled `full` row (drawn inside the groups coordination-polish pattern), Light + Dark.
- **Tests:** renders one cell per day with the date number + weekday; the cell colour matches `state`; the `title` includes the holiday when `holidaysByDate` carries one; an empty `days` renders nothing.

### `OverlapLegend` — Phase 4

Names the three calendar states and lists the party set (the `OverlapCalendar` companion).

- **Appears with:** `OverlapCalendar` (on `/overlap`, above the calendar). In a **crew group** it is lifted into the **`GroupWorkspace` left column beneath `GroupRoster`** ("Who's in"), shown only while the shared-breaks list shows — the same `≥2 members && !noCommonWindow` guard (item 5, [GROUPS.md §4.2](./GROUPS.md)); `GroupOverlapResult` no longer renders it.
- **Props:** `parties: OverlapPartySummary[]`.
- **Behaviour:** static legend — "Free for all" (green) / "Some spend PTO" (amber) / "Everyone spends PTO" (red), per [VOICE.md §4.13](./VOICE.md); lists each person + their country.
- **Accessibility:** the colour-key text is the non-colour-only signal `OverlapCalendar` relies on.
- **Depends on:** [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) tokens, [VOICE.md §4.13](./VOICE.md).
- **Stage-2 Figma:** drawn with `OverlapCalendar` (one frame).
- **Tests:** renders the three labels + one entry per party.

---

## Groups (Phase 4½)

The social layer over the overlap engine — *"add your crew, everyone drops their availability, roamgap finds the window that burns the least leave for the whole group."* Contract: [GROUPS.md](./GROUPS.md) (web) + [roamgap-backend GROUPS.md](../../roamgap-backend/specs/GROUPS.md) (engine + endpoints). Concept terms: **group** / **organiser** / **member** / **invite link** / **comment** / **poll** ([VOICE.md §3/§4.14](./VOICE.md)). Schemas: the `Group*` family ([domain.ts](./domain.ts)).

> **One organiser pays, the group rides free (GD-3).** Only the organiser's **Create group** action sits behind `PaidGate feature="groups"`; **members never see a paywall** ([GROUPS.md §3](./GROUPS.md)). While the gate ships dark ([PAYMENTS.md §20](./PAYMENTS.md)) any signed-in user can create. The result surfaces **reuse** the Phase-4 `OverlapResultCard` / `OverlapCalendar` / `OverlapLegend` and the availability inputs reuse `CountryPicker` / `SubdivisionPicker` / the PTO + workweek controls — **no new tokens**.

### `GroupCover` — Phase 4½ (cover artwork)

The group's generative **cover artwork** ([GROUPS.md §4.9](./GROUPS.md)) — a themed SVG horizon (sky gradient + sun/moon + layered hills/waves) keyed by `GroupCoverArt`. Curated, **never an upload** (GD-7 deferred).

- **Appears in:** `GroupsPanel` rows (thumbnail), `GroupWorkspace` left column (banner atop the collapsed availability), `GroupCoverPicker` swatches.
- **States:** one per `GroupCoverArt` theme (*sunrise · ocean · forest · desert · aurora · blossom*); resolves the **id-derived default** when `coverArt` is `undefined` (so there is never a blank).
- **Props:** `coverArt?: GroupCoverArt`, `seed: string` (the group id — picks the deterministic default when `coverArt` is unset), `className?` (the host sizes it; the SVG fills via `preserveAspectRatio="xMidYMid slice"`).
- **Behaviour:** presentational + deterministic — same `(coverArt, seed)` → same art, so a group looks identical in the list and the workspace. The art uses a **self-contained illustration palette** (fixed hexes), **not** the `colour` UI tokens, so it reads on either surface ([BRANDING.md §4.4](./BRANDING.md)). Decorative: `role="img"` + an `aria-label` naming the theme.
- **Depends on:** `@/lib/groups/cover` (`resolveCoverArt` + the theme palettes), `@/lib/domain` (`GroupCoverArt`). **No new `colour` token** (illustration palette is separate).
- **Stage-2 Figma:** the six themes at banner + thumbnail size (Light + Dark surfaces behind).
- **Tests:** renders an `<svg>` per theme; an unset `coverArt` falls back to the **same** deterministic theme for a given seed (stable); the `aria-label` names the resolved theme.

### `GroupCoverPicker` — Phase 4½ (cover artwork)

The organiser's theme chooser ([GROUPS.md §4.9](./GROUPS.md)) — a swatch row in the workspace **Manage group** section.

- **Appears in:** `GroupWorkspace` (organiser, active group), the Manage section.
- **States:** default (the resolved theme's swatch ringed `selected`); each swatch a mini `GroupCover` button.
- **Props:** `value?: GroupCoverArt`, `seed: string`, `onPick(art: GroupCoverArt)`.
- **Behaviour:** one labelled `<button>` per theme (a mini `GroupCover` + the theme name for the a11y name); clicking emits `onPick` (the host `PATCH`es `{ coverArt }`). The currently-resolved theme is ringed.
- **Depends on:** `GroupCover`, `@/lib/groups/cover` (`GROUP_COVER_ARTS`), `@/lib/domain` (`GroupCoverArt`), [VOICE.md §4.14](./VOICE.md).
- **Stage-2 Figma:** the swatch row with one theme selected (Light + Dark).
- **Tests:** renders one swatch per theme; clicking a swatch emits `onPick(theme)`; the resolved theme is marked selected.

### `GroupsPanel` — Phase 4½

The signed-in list of my groups on `/groups` + the Create entry ([GROUPS.md §4.1/§4.9](./GROUPS.md)).

- **Appears on:** `/groups` (signed-in; the anonymous face is the page's indexable explainer).
- **States:** `loading`; `empty` (no groups — the shared dashed **`EmptyState`** card *"No groups yet — create one and invite your crew."*, item 13 reuse); `list` (rows of `GroupSummary` — a **`GroupCover` thumbnail** + name, year, member count, role badge, status); `creating` (the `PaidGate`-wrapped Create flow open).
- **Props:** none — it is the signed-in **owner** (mirrors `ItinerariesPanel`): it reads `useSession`, fetches `GET /groups` itself (the `loading` → `list`/`empty` states), and toggles the `creating` flow. (The Phase-4½-spec `groups`/`onCreate` props were folded into the owner so `/groups/page.tsx` mounts `<GroupsPanel />` with no props — a server page cannot pass user-scoped data.)
- **Behaviour:** anonymous → a sign-in CTA (the page's explainer is the indexable face); each row leads with a `GroupCover` thumbnail (`GroupSummary.coverArt`, §4.9) then links to `/g/[id]`; a status chip renders Active / Paused (lapsed) / Closed; **Create group** opens `CreateGroupForm` inside `PaidGate feature="groups"`. The Create button is a **compact action** (`text-sm`, secondary-button scale), not heading-scale — it no longer competes with the *"Your groups"* title (item 14).
- **Depends on:** `useSession`, `PaidGate`, `CreateGroupForm`, `GroupCover`, `@/lib/groups`, `@/lib/domain` (`GroupSummary`), [GROUPS.md §4.1/§4.9](./GROUPS.md).
- **Stage-2 Figma:** the explainer + the signed-in list (cover thumbnails) + the gated Create entry (Light + Dark).
- **Tests:** empty vs list render; each row renders a cover; status chip per `GroupStatus`; Create is gated (renders `UpgradePrompt` when not entitled).

### `CreateGroupForm` — Phase 4½ · *redesigned (§4.7, 2026-06-21)*

The **group's shape** — name + year + segmented type/who-can-join → `POST /groups`
([GROUPS.md §4.1/§4.7](./GROUPS.md)). The organiser's availability is **preloaded**, not
re-entered: there is **no `MemberAvailabilityEditor`** on this screen.

- **Appears on:** `/groups`, inside `PaidGate feature="groups"`.
- **States:** default; `invalid` (inline-warn per [ERROR_STATES.md](./ERROR_STATES.md)); `submitting`; `error`.
- **Props:** `onCreated(group: Group)`.
- **Layout (the "barebones → first-class" redesign).** A single card
  (`rounded-lg border bg-surface-elevated p-6 shadow-elevation-1`, the `OptimizerForm`
  shell) with an even vertical rhythm: **Group name** (full-width) + **Year**; then
  **What kind of group** and **Who can join** as **segmented controls** (the house
  two-button group — same treatment as `FilterPanel`'s "Any time / Pick months", not
  bare `<select>`s), each with its one-line helper; then the **preloaded-availability
  caption** (§4.7) and a **"You'll get"** summary line (*"You're creating a {crew|event}
  for {year}. Invite the others once it's live."*); then the **Create group** button.
  Event groups reveal the *Event starts / ends* date pair under the type control.
- **Availability seed.** `self` (`GroupMemberInput`) comes from `useGroupSelfDefaults()`
  (§ below) — saved profile, else home country + default leave — and is posted silently.
  The caption tells the user where it came from and that they fine-tune it in the group.
- **Depends on:** `useGroupSelfDefaults`, `@/lib/groups`, `@/lib/domain` (`GroupCreate`/`Group`), [GROUPS.md §4.1/§4.7](./GROUPS.md). **No** `MemberAvailabilityEditor`.
- **Stage-2 Figma:** the redesigned card (segmented controls, caption, summary, Create) + validation + submitting/error (Light + Dark) — drawn (FIGMA_REFERENCE § §4.7).
- **Tests:** emits a schema-valid `GroupCreate` with a profile-seeded `self`; renders **no** availability editor; a blank name shows inline warn and blocks submit; the segmented type/who-can-join choices flow into the body.
- **SHIPPED 2026-06-21.** Segmented controls are a local `radiogroup`/`radio` (the house pattern); the "You'll get" summary + Create reuse the shared **`FormActionBar`**.

### `GroupInvitePanel` — Phase 4½

The `/g/[id]` preview for non-members — the signed-out "see the plan" landing ([GROUPS.md §4.2/§5.2](./GROUPS.md)).

- **Appears on:** `/g/[id]` for anonymous or signed-in non-members.
- **States:** `joinable` (Sign in to join / Join this group CTA); `full`; `closed`/`paused`; `loading`.
- **Props:** `preview: GroupPreview`, `onJoin()`.
- **Behaviour:** shows name, organiser label, member count / max, year — **no** member identities/availability (§6); for a **joinable** group also a short value-prop (*what you get* + *"joining is free"*, VOICE §4.14) — value-only, rendered only when `canJoin`; CTA adapts to auth + `canJoin`.
- **Accessibility:** the CTA is a labelled `<button>`/link; status is text, not colour-only.
- **Depends on:** `@/lib/domain` (`GroupPreview`), [GROUPS.md §5.2](./GROUPS.md).
- **Stage-2 Figma:** joinable (with value-prop) / full / closed states (Light + Dark).
- **Tests:** renders only public-safe fields; the value-prop shows for a joinable group and is absent for full/closed/paused; CTA label by auth + `canJoin`; an `approval` group reads "Request to join" / "Sign in to request".

### `PendingRequestsPanel` — Phase 4½ (UX-5b)

The organiser's approval queue on `/g/[id]` for an `approval`-policy group ([GROUPS.md §4.2](./GROUPS.md); [backend §5.10/§5.11](../../backend/specs/GROUPS.md)).

- **Appears on:** the `/g/[id]` workspace — the organiser of an active `approval` group only.
- **States:** `empty` (renders nothing); `list` (one row per waiting applicant).
- **Props:** `groupId: string`, `onApproved(group: Group)` (lifts the updated roster to the workspace).
- **Behaviour:** fetches `GET /pending-requests` on mount; each row shows label + country + PTO (the organiser may see availability, §7) with **Approve** / **Reject** — approving `POST …/approve` and lifts the returned `Group`, rejecting `POST …/reject` and drops the row.
- **Accessibility:** a labelled `<section>` ("Requests to join (N)"); Approve/Reject are labelled `<button>`s.
- **Depends on:** `@/lib/groups/client` (`listPendingRequests` / `approvePendingRequest` / `rejectPendingRequest`), `@/lib/domain` (`GroupPendingRequest`).
- **Stage-2 Figma:** the queue (rows + Approve/Reject), Light + Dark.
- **Tests:** lists requests with identity + availability; Approve calls approve + lifts the group; Reject drops the row; an empty queue renders nothing.

### `MemberAvailabilityEditor` — Phase 4½

One person's own availability form ([GROUPS.md §4.4](./GROUPS.md)) — the `OverlapPartyEditor` per-party row extracted for a single self-entered party.

- **Appears on:** the `/g/[id]` **workspace** only ("Your availability", edit). It is
  **not** mounted on create or join — those preload `self` from the saved profile (§4.7).
- **States:** default; per-field `invalid` (region/country mismatch → inline warn); `at-cap` (4 also-observes).
- **Props:** `value: GroupMemberInput`, `onChange(v: GroupMemberInput)`.
- **Behaviour:** composes `CountryPicker` + `SubdivisionPicker` + a PTO input + an optional workweek control + the "Also off on" chips (`alsoObserves`, ≤4 — [OVERLAP.md §5.7](../../roamgap-backend/specs/OVERLAP.md)); `label` defaults to the user's display name.
- **Depends on:** `CountryPicker`, `SubdivisionPicker`, the PTO/workweek inputs, `@/lib/domain` (`GroupMemberInput`), [GROUPS.md §4.4](./GROUPS.md).
- **Stage-2 Figma:** the form incl. the also-observes chips + validation (Light + Dark).
- **Tests:** emits a schema-valid `GroupMemberInput`; a region/country mismatch warns; also-observes clamps at 4.

### `GroupWorkspace` — Phase 4½

The member-view page composition on `/g/[id]` ([GROUPS.md §4.2](./GROUPS.md)).

- **Appears on:** `/g/[id]` for members.
- **States:** `active`; `paused` (lapsed — role-aware banner, §3.1: organiser `RenewPrompt`, member info banner); `closed` (read-only).
- **Props:** `group: Group`.
- **Behaviour:** composes `MemberAvailabilityEditor` (own) + `GroupRoster` + (crew) the **`OverlapLegend`** beneath the roster in the **left column** (item 5 — `overlap.meta.parties`, shown only while the shared-breaks list shows) + `GroupOverlapResult` + `GroupTripDates` + `GroupCommentThread` + `GroupDatePoll`; surfaces organiser-only controls (rename, close, delete, set poll, remove members, **the `GroupCoverPicker`**) when `yourRole === 'organiser'`.
- **Cover banner (§4.9).** A `GroupCover` banner sits at the **top of the left column, atop "Your availability"** — but **only when the availability editor is collapsed** (`!(active && editingAvailability)`), so it shows for a member who has set their availability (item 7) and is hidden while one is still entering it (never too much in view). The organiser additionally gets the `GroupCoverPicker` in **Manage group**.
- **Availability nudge (§4.7).** When the signed-in member has **no saved availability
  profile** (`useGroupSelfDefaults().hasProfile === false`, once settled), a quiet
  `CalloutBox`-style prompt sits **above** the editor — *"Set your availability so the
  shared dates count you in."* — because they rode in on a default. It is non-blocking
  and **dismissed for the session** on the first successful **Save availability**. A
  member with a saved profile sees no nudge.
- **Depends on:** the components below, `useGroupSelfDefaults`, `RenewPrompt`, `@/lib/domain` (`Group`), [GROUPS.md §4.2/§4.7/§3.1](./GROUPS.md).
- **Stage-2 Figma:** the workspace, organiser-only controls, the paused/closed states (Light + Dark).
- **Tests:** organiser sees controls a member doesn't; the paused state shows the role-aware banner (no member paywall).

### `GroupRoster` — Phase 4½

The member list — identities only ([GROUPS.md §4.2/§7](./GROUPS.md)).

- **Appears in:** `GroupWorkspace` (left column). For a crew group the **`OverlapLegend`** ("Overlap key") now renders **directly beneath this roster** — keyed by the same `overlap.meta.parties`, shown only while the shared-breaks list shows (item 5, [GROUPS.md §4.2](./GROUPS.md)).
- **States:** default; organiser view (a **Remove** affordance per non-self member).
- **Props:** `members: GroupMember[]`, `yourRole: GroupRole`, `onRemove(memberId)`.
- **Behaviour:** lists each member's **label + country** (+ role badge); **never** another member's PTO budget (§6 — `availability` is absent for co-members on the wire); the caller's own row is marked.
- **Depends on:** `@/lib/domain` (`GroupMember`), [GROUPS.md §7](./GROUPS.md).
- **Stage-2 Figma:** member rows + the organiser Remove control (Light + Dark).
- **Tests:** renders identity without budget for co-members; Remove only for the organiser, never on self.

### `GroupOverlapResult` — Phase 4½

Thin composition of the **reused** `OverlapResultCard` + `OverlapCalendar` over `GET /groups/:id/overlap` ([GROUPS.md §4.2/§6](./GROUPS.md)). The **`OverlapLegend`** is no longer rendered here — it moved to the `GroupWorkspace` left column beneath the roster (item 5).

- **Appears in:** `GroupWorkspace`.
- **States:** `loading`; `result` (ranked breaks + heatmap); `too-few` (< 2 members → "invite at least one more person"); `no-window` (≥ 2, infeasible); `paused`/`closed` (compute gated → the role-aware state, not a member paywall). Within `result`: `clipped` (the **best 5** — a full-width hero + four standard cards) ↔ `expanded` ("Show all N", the long tail below the hero in a **height-capped scroll region** so the rest of the workspace stays reachable).
- **Props:** `overlap: OverlapOutput`, `members: GroupMember[]`, plus the trip/poll wiring it owns (`onAddWindows`, `onAddPollOptions`) for the selection action bar (§4.8).
- **Behaviour (item 1/2/4/8):** delegates rendering to the reused Phase-4 components (the **legend is hoisted to the workspace left column**, item 5 — `GroupWorkspace` reads `overlap.meta.parties`). **Leads with the best 5** — the **top-ranked** window (per the current sort) as a full-width **`OverlapResultCard hero`**, the next four as standard two-up cards — with a **"Show all N"** expander (the backend returns up to 24, [backend §6](../../backend/specs/GROUPS.md)); when expanded the windows below the hero sit in a `max-h` + `overflow-y-auto` region (~6 cards) so "Best shared breaks" + "The year at a glance" don't bury the poll/comments/trip-dates (item 4). Hosts the **reused `SortToggle`** (efficiency / length / date) and reorders the windows **client-side** per this viewer (default: backend order = least combined leave); the hero tracks the sort. Owns the **transient selection** set (whole-card click on `OverlapResultCard`); a one-action bar over the selection dispatches **Add to trip** (→ `POST /date-groups/windows`) and **Add to poll** (→ `POST /poll/options`); selected windows' days are passed to `OverlapCalendar` to paint as **accent outlines** in "The year at a glance".
- **Depends on:** `OverlapResultCard`, `OverlapCalendar`, `OverlapLegend`, `SortToggle`, `@/lib/domain` (`OverlapOutput`), [GROUPS.md §4.2/§4.8/§6](./GROUPS.md).
- **Stage-2 Figma:** reuses the overlap frames; the sort row, the clipped/expanded list, a selected (transparent) card + the selection action bar, the year-at-a-glance selected-day outline, and the too-few/no-window/paused empty states (Light + Dark).
- **Tests:** renders the reused surfaces from a fixture `OverlapOutput`; the top-ranked window is the full-width hero and the next four are standard (best 5) + expands to the rest in the height-capped region; `SortToggle` reorders (and re-picks the hero); a card click selects (transparent) and the action bar adds to the trip / poll; selected days reach `OverlapCalendar`; the too-few + no-window empty states.

### `GroupEventResult` — UX-9 · *multi-window (PR2, shipped 2026-06-23)*

The **event**-archetype counterpart to `GroupOverlapResult` ([GROUPS.md §4.2](./GROUPS.md), [backend §6.5](../../backend/specs/GROUPS.md)). Self-contained: fetches `GET /groups/:id/event-plan` and renders, **per release** (`eventWindows`, multi-window — UX-9), the CALLER's ranked breaks around that release. Each release is a **labelled section** that **leads with a hero** recommended plan and a **"Show more"** expander over the rest (item 2 — replaces the carousel *and* the tall 4-card stack, killing the vertical scroll), with the release's **event days** named (item 9) and a mobile-safe layout (item 10).

- **Appears in:** `GroupWorkspace` (only when `group.groupType === 'event'`; replaces `GroupOverlapResult`).
- **States:** `loading` ("Planning your time off around the event…"); `ready` (one section **per release** — a `DateWindowCard` naming the release's event days + a **hero** `EventPlanCard` (`options[0]`, accent-ringed) + a **"Show more"** expander revealing the rest, collapsed by default); `dateless` (no releases → an empty state: *"No dates yet — propose them in the date poll below, then promote one to set the event date."*, item 3); `error` (retry copy). No "too-few" state — an event plan is per-caller.
- **Props:** `groupId: string`, `eventWindows: EventRelease[]`, `locale?`.
- **Behaviour:** reads `useSession`; on mount fetches `getGroupEventPlan` → `{ releases: [{ release, options }] }`. Renders **one section per release** (`releases[*]`): the event days via a `DateWindowCard` (item 9), then `options[0]` as a full-width **hero** `EventPlanCard` (`hero`), then a **"Show {n} more"** toggle revealing `options[1..]` (item 2 — a stable height, no horizontal carousel). A release with no options still shows its event days. A **dateless** event (`releases: []`) shows the §6.5 empty state. Renders nothing about co-members (a co-member's plan/PTO is never fetched — §7 privacy); the roster is the separate `GroupRoster`. The day strip stays `flex-wrap` (never `overflow-x-auto`), so the window never scrolls horizontally on mobile (item 10).
- **Depends on:** `EventPlanCard`, `DateWindowCard`, `@/lib/groups/client` (`getGroupEventPlan`), `@/lib/domain` (`GroupEventPlan`, `EventRelease`), [GROUPS.md §6.5](../../backend/specs/GROUPS.md).
- **Stage-2 Figma:** one release section — event-days `DateWindowCard` + the hero `EventPlanCard` (recommended ringed) + the collapsed "Show more"; the dateless empty state; loading/error (Light + Dark).
- **Tests:** fetches + renders one section per release with its event-days card and a hero; the hero is `options[0]` (accent-ringed) and the rest are behind "Show more" (collapsed by default, revealed on click); a dateless event (`releases: []`) shows the empty state pointing at the poll; the error state on failure. `CreateGroupForm`'s **event** type allows a **dateless** create (no window inputs required) and posts `groupType: 'event'` with `eventWindows: []` (or the organiser's initial windows).
- **SHIPPED 2026-06-23 (multi-window).** [`GroupEventResult`](../src/components/groups/GroupEventResult.tsx) renders **one section per release** (`getGroupEventPlan` → `{ releases }`): the event days via `DateWindowCard`, a hero `EventPlanCard` (`recommended`), and a **"Show N more"** expander; the dateless path renders directly (no fetch). [`CreateGroupForm`](../src/components/groups/CreateGroupForm.tsx) makes the event dates **optional** (dateless posts no `eventWindows`). [`GroupDatePoll`](../src/components/groups/GroupDatePoll.tsx) gains an **`eventMode`** + **`onSetEventDate`** path: for an event group the poll hides the trip actions and the organiser sees **"Set as event date"** (→ `addEventWindow`), so the poll **drives the event releases** ([`GroupWorkspace`](../src/components/groups/GroupWorkspace.tsx) wiring). New client fns `addEventWindow` / `removeEventWindow`. **Deviations (recorded):** (1) the **hero** is `EventPlanCard recommended` (the accent ring) + the show-more lives in `GroupEventResult`, not a new `hero` prop on the card. (2) The per-member **persisted PTO selection + `/groups` list summary** (brief item 4) is **deferred to a follow-up** — a distinct cross-cutting persistence feature.

### `EventPlanCard` — UX-9 (event view, full-width)

One ranked option in the event plan ([GROUPS.md §4.2](./GROUPS.md)) — the full-width card behind `GroupEventResult`. Where `/year`'s `TripCard` is a 2-up card that stacks its `CalendarStrip` into a fixed 7-column week grid (clipped to a scrollbar when the card is narrow), `EventPlanCard` is **always full-width** and lays the window out as a single **wrapping** day strip.

- **Appears in:** `GroupEventResult` (one per `GroupEventPlan.options[*]`).
- **States:** default; `recommended` (rank 1 — an accent ring + a "Most efficient" tag, matching the section lead); `over-budget` (a `Trip.overBudget` marker, contract-ready).
- **Props:** `trip: Trip`, `rank: number`, `recommended?: boolean`, `locale?`.
- **Behaviour:** header is the `#N` eyebrow + the date range + the inline `Total · {Leave} · Efficiency` stats (`tFor` leave term, [I18N](./I18N.md)); below it a **wrapping** day strip — one cell per `composition` day (weekday label + a kind box: number + `W/H/P/B/O` letter, the day-kind colours, dashed = estimated, dotted = personal observance — the same non-colour signals as `DayCell`, [ACCESSIBILITY.md §3](./ACCESSIBILITY.md)). No `overflow-x-auto`: the strip uses `flex-wrap`, so a long window wraps to the next line instead of scrolling.
- **Depends on:** `@/lib/domain` (`Trip`/`DayComposition`), the `bg-day-*` tokens ([DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md)), `tFor` ([I18N.md](./I18N.md)). **No new token.**
- **Stage-2 Figma:** the full-width card (recommended ringed) + the wrapping day strip (Light + Dark).
- **Tests:** renders the range + `#rank` + the three stats; one day cell per composition day with its kind letter; the strip is `flex-wrap` and **not** inside an `overflow-x-auto` wrapper; rank 1 is `recommended`.
- **SHIPPED 2026-06-22.** [`EventPlanCard`](../src/components/groups/EventPlanCard.tsx) + [`GroupEventResult`](../src/components/groups/GroupEventResult.tsx) single-column layout are live. **Deviation (recorded):** the shipped "Most efficient" tag is the subtle tint `bg-accent/15 text-accent` (the Figma schematic draws a filled `accent` pill for thumbnail legibility); the kind cell reuses the `bg-day-*` tokens + the `DayCell` estimated/observance signals rather than mounting `DayCell` itself (which is an ARIA-grid cell needing the `CalendarStrip` roving-tabindex parent — wrong fit for a plain wrapping list).

### `GroupCommentThread` — Phase 4½

The async comment list + composer ([GROUPS.md §4.5/§8](./GROUPS.md)).

- **Appears in:** `GroupWorkspace`.
- **States:** `empty`; `list` (oldest first, each with author label + relative time); `composing`; a removed comment renders *"comment removed"*; composer `disabled` (closed/paused).
- **Props:** `comments: GroupComment[]`, `onAdd(body)`, `onDelete(id)`.
- **Behaviour:** posts `GroupCommentCreate` (1–2000 chars); the caller's own comments + (for the organiser) any carry a Delete (soft-delete); **not realtime** (no live updates).
- **Voice:** [VOICE.md §4.14](./VOICE.md) — it's a **comment**, not a chat; neutral removed-state copy.
- **Depends on:** `@/lib/domain` (`GroupComment`/`GroupCommentCreate`), [GROUPS.md §8](./GROUPS.md).
- **Stage-2 Figma:** list + composer + removed state + disabled (Light + Dark).
- **Tests:** add posts a valid body; delete shows on own + for organiser, not a third party; removed renders neutral.

### `DateWindowCard` — Phase 4½ (date finesse — items 6/8/9)

A presentational, **finessed date-window card** over a plain `{ start, end, label? }`
range — the visual the barebones *"3 Apr – 6 Apr"* rows of `GroupTripDates` and
`GroupDatePoll` lacked ([GROUPS.md §4.6/§4.8](./GROUPS.md)). Where `OverlapDayStrip`
draws the **overlap state** of each day (and so needs an `OverlapDay[]`), `DateWindowCard`
needs **only the dates** — so it works for a trip window, a poll option, and (PR2) an
event release / event days, none of which carry per-day overlap state on their own.

- **Anatomy:** a **calendar-leaf glyph** (the start day number over its month abbreviation,
  an accent-tinted tear-off), a **smart range** (`formatDateWindow` — collapses a shared
  month/year: *"3 – 6 Apr 2026"*, cross-month *"29 Apr – 2 May 2026"*), and a **duration**
  line (*"4 days · Fri – Mon"*). Optional `label` shows above the range.
- **Appears in:** `GroupTripDates` (each pinned window), `GroupDatePoll` (each option — it
  frames the existing `OverlapDayStrip` + tally + actions), and (PR2) the event surfaces.
- **States:** `default`; `accent` (`tone="accent"` — the poll's "Group favourite"); `selected`
  (the PR2 transparency toggle — prop reserved, unused in PR1).
- **Props:** `start`, `end`, `label?`, `locale?`, `tone?: 'default' | 'accent'`,
  `trailing?: ReactNode` (right-aligned slot — Remove, tally meter), `children?: ReactNode`
  (below the header — the day strip, the action row).
- **Behaviour:** pure presentational; the date maths is the pure `formatDateWindow(start,
  end, locale)` in `@/lib/dates/window.ts` (`{ range, duration, weekdaySpan, nights }`),
  the **first shared date-range formatter** (every component formatted ranges inline before
  — [GROUPS.md §4.8](./GROUPS.md)). UTC-anchored (`T00:00:00Z`), no off-by-one.
- **Depends on:** `@/lib/dates/window.ts`, [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (accent +
  surface tokens). **No new token.**
- **Stage-2 Figma:** the leaf + smart range + duration, `default` + `accent`, inside the
  trip-dates + date-poll polish pattern (Light + Dark).
- **Tests:** `formatDateWindow` collapses a same-month range and a cross-month range,
  counts the duration and nights correctly, and is UTC-stable; the card renders the leaf,
  range, duration, `label`, and the `trailing`/`children` slots.

### `GroupDatePoll` — Phase 4½

The approval poll over candidate windows ([GROUPS.md §4.6/§9](./GROUPS.md)).

- **Appears in:** `GroupWorkspace`.
- **States:** `none` (no poll); `open` (options + per-option tally + the caller's approvals; leading option = "Group favourite"; member-proposed options carry *"Suggested by {name}"*; each option shows its **`OverlapDayStrip`**; the organiser sees **"Make this the trip"** per option — item 5; **every** member sees **"Add to trip"** per option); `suggesting` (the inline "Suggest a date" mini-form open — any member); `closed` (reads *"The group is taking {dates}"* with the promoted option marked, **but options stay actionable** via "Add to trip"); organiser `editing` (set/replace options).
- **Props:** `poll: GroupPoll | null`, `yourRole: GroupRole`, `onVote(optionIds)`, `onSetPoll(input)`, `onAddOption(option: GroupPollOptionInput)`, `onRemoveOption(optionId)`, `onPromoteOption(optionId)` (item 5 — organiser only), `onAddOptionToTrip(option: {start; end; label?})` (any member — append to the active trip without closing the poll, §4.6), `calendar?: OverlapDay[]` (the overlap calendar for the per-option day strip — absent when not computed), `activeTripName?: string` (labels "Add to {name}" when multiple trips exist), `memberCount?`, `readOnly?`.
- **Behaviour:** members approve any subset (`GroupPollVote`); the organiser sets options wholesale (`GroupPollInput`), often from the top computed windows; **any member can "Suggest a date"** (`onAddOption` → `POST /poll/options`, creating the poll if none) and **remove an option they proposed** (`GroupPollOption.self`), while the **organiser can remove any** (`onRemoveOption`). Each option is framed by a **`DateWindowCard`** (calendar-leaf + smart range + duration, items 6/8) wrapping its **`OverlapDayStrip`** (days looked up in `calendar`; falls back to the plain range when absent), and the approval count renders as a small **meter** filled to `votes / memberCount` beside the *"N of M members"* label. The **organiser promotes** an option — **"Make this the trip"** (`onPromoteOption` → `POST /poll/options/:oid/promote`), which copies the window into the active date-group (§4.8) and **closes the poll**; separately, **any member** can **"Add to trip"** (`onAddOptionToTrip` → `POST /date-groups/windows`) to append the option to the active trip **without** closing the poll — available open *or* closed, so a poll can seed **subsequent** trips (item 1). `readOnly` (paused/closed) disables voting, suggesting, removing, promoting, **and** "Add to trip" (§3.1).
- **Voice:** [VOICE.md §4.14](./VOICE.md) — approval voting ("pick more than one"); "Group favourite", never "winner"; **"Suggest a date"** + *"Suggested by {name}"* + the honest "may use more leave" hint.
- **Depends on:** `@/lib/domain` (`GroupPoll`/`GroupPollInput`/`GroupPollVote`/`GroupPollOptionInput`), `@/lib/groups` (`addPollOption`/`removePollOption`), [GROUPS.md §9](./GROUPS.md).
- **Stage-2 Figma:** options + approval + tally + favourite; the **member "Suggest a date"** affordance + *"Suggested by"* attribution + a member-removable option; organiser edit (Light + Dark).
- **Tests:** member approves a subset; tally reflects approvals; only the organiser sets the whole poll, but **any member can suggest a window** (emits a schema-valid `GroupPollOptionInput`); a member sees Remove on an option they proposed (`self`) but not on others; organiser sees Remove on any; the organiser sees **"Make this the trip"** and a member does not; **every member sees "Add to trip"** and it fires `onAddOptionToTrip` with the option's window without closing the poll — including on a **closed** poll (item 1); an option renders its day strip from `calendar` and falls back to the plain range when `calendar` is absent.

### `GroupTripDates` — date-group overhaul (item 3/4/5)

The crew's **shared chosen dates** — the date-groups ("trips") panel ([GROUPS.md §4.8](./GROUPS.md), [backend §6.6](../../backend/specs/GROUPS.md)). Where `GroupOverlapResult` shows what's *possible*, this shows what the crew has *decided*.

- **Appears in:** `GroupWorkspace` (crew groups only; an event group has a fixed window and no panel).
- **States:** `single` (the default trip only — the common case); `multi` (expanded to several date-groups via "+ Add another trip", each a section/tab); `empty` (a trip with no windows → "No dates yet — add one from a shared break above, or from the date poll below"); `readOnly` (paused/closed).
- **Props:** `dateGroups: GroupDateGroup[]`, `onCreateTrip(name?)`, `onRenameTrip(id, name)`, `onDeleteTrip(id)`, `onRemoveWindow(dgid, wid)`, `activeTripId` / `onSelectTrip` (which date-group new windows land in), `readOnly?`. (Adding a window is driven from `GroupOverlapResult`'s selection bar — **Add to trip** — targeting `activeTripId`.)
- **Behaviour:** leads with the single default trip; **"+ Add another trip"** reveals multiple date-groups (item 4); each trip lists its windows as **`DateWindowCard`**s (calendar-leaf + smart range + duration, item 6) with a **Remove** (any member, collaborative); a trip can be renamed and an added trip deleted (the default persists). Backed by `GET/POST/PATCH/DELETE /groups/:id/date-groups` (`GroupDateGroupList`); when the list is empty the panel synthesises a single empty default trip so "Add to trip" always has a target (the server creates the real default lazily on the first window).
- **Voice:** [VOICE.md §4.14](./VOICE.md) — a **trip** the crew is taking, not an "itinerary"; the empty-state copy points back to the shared breaks / poll.
- **Depends on:** `@/lib/domain` (`GroupDateGroup`/`GroupDateGroupList`), `@/lib/groups/client` (the date-group fns), [GROUPS.md §4.8](./GROUPS.md).
- **Stage-2 Figma:** the single default trip, the expanded multi-trip layout, a window row with Remove, and the empty + read-only states (Light + Dark).
- **Tests:** renders the default trip + its windows; "+ Add another trip" reveals a second date-group; remove a window; rename/delete a trip; the empty default synthesises when the list is empty; read-only hides the mutators.

---

## Holiday league (engagement surface)

The cross-country comparison surface — the league hub (`/holiday-league/[y]`) and the two-country **head-to-head** (`/holiday-league/[y]/[a]-vs-[b]`). Contract: [HOLIDAY_LEAGUE.md](./HOLIDAY_LEAGUE.md) (§15 for the head-to-head). Concept terms: **holiday league** / **head-to-head** / **lost to a weekend** / **lands on a working day** / **PTO leverage** ([VOICE.md §3](./VOICE.md)). Schemas: the `HolidayLeague*` family ([domain.ts](./domain.ts)) — reused as-is, **no new schema**. The league hub renders its four boards inline; `VersusTable` is the first catalogued league component (the head-to-head's side-by-side view).

### `VersusTable` — Engagement surface · *spec*

The two-country side-by-side comparison on the head-to-head page ([HOLIDAY_LEAGUE.md §15.3](./HOLIDAY_LEAGUE.md)) — both countries across every league metric, the better value per row marked.

- **Appears on:** `/holiday-league/[y]/[a]-vs-[b]`.
- **States:** `default` (both countries present); `partial` (one country in the league's `missing` set — its column shows the calm "couldn't load" line, never a zero, [HOLIDAY_LEAGUE.md §15.2](./HOLIDAY_LEAGUE.md)); `estimated` (an "includes estimated dates" note when either country has a forward/estimated date, [HOLIDAY_LEAGUE.md §4.2](./HOLIDAY_LEAGUE.md)).
- **Props:** `year`, `a`, `b` (each the assembled per-country stat line from [§15.2](./HOLIDAY_LEAGUE.md) — a presentation type over `HolidayLeagueEntry`, **not** a new domain schema), `missing?: Iso3166Alpha2[]`.
- **Behaviour:** one row per metric (`total`, `onWorkday`, `onWeekend`, `substitutes`, `leverage`, `maxDaysOff`); marks the better value per row — **higher** wins except `onWeekend`, where **lower** wins; ties shown as ties (never broken). Country names link to `/public-holidays/{c}/{year}`.
- **Accessibility:** real `<table>` with `<caption>`, `<th scope="col">` per country and `<th scope="row">` per metric; the "better value" cue is a text marker / `<strong>`, **not colour alone** ([ACCESSIBILITY.md §3](./ACCESSIBILITY.md)).
- **Depends on:** `@/lib/domain` (`HolidayLeagueEntry`, `Iso3166Alpha2`), [HOLIDAY_LEAGUE.md §15](./HOLIDAY_LEAGUE.md), [VOICE.md §3](./VOICE.md).
- **Stage-2 Figma:** the side-by-side comparison table — `default` + `partial` + `estimated` (Light + Dark).
- **Tests:** renders one row per metric with the correct winner marked (incl. the `onWeekend` lower-is-better inversion); a tie marks neither; a `missing` opponent renders the calm line, not a zero.

---

## What this inventory implies for Figma and code

Each component above maps to one Figma component, with variants for each state. Where a state combines multiple axes (e.g. `TripCard` × `hover` × `expanded`), use Figma's nested variants — don't flatten.

### The three-phase order, applied to a single component

For any new or changed component, the order is **Spec → Figma → Development**, never reversed. See [`SPEC_FIRST.md`](./SPEC_FIRST.md) for the authoritative rule and [`docs/DECISIONS.md` ADR-006](../docs/DECISIONS.md) for the rationale.

| Stage | Output | What lands |
| ----- | ------ | ---------- |
| 1. Spec | Edit to this file + dependent specs ([DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md), [VOICE.md](./VOICE.md), [ACCESSIBILITY.md](./ACCESSIBILITY.md), [ERROR_STATES.md](./ERROR_STATES.md)) | Component entry with **States**, **Props**, **Depends on**. Commit prefix `spec:`. |
| 2. Figma | Component drawn (or extended) in the design library at `gzgZd6XTnFVwHHbPxKuac2`. Screenshot in `specs/figma/`. Entry in [FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md). | Visual contract with node IDs. Commit prefix `design(figma):`. |
| 3. Development | `src/components/<feature>/<Name>.tsx` + failing test → impl → `npm run check` green. Test titles cross-reference both this file and any cross-cutting spec section. | Executable contract. Commit prefix `impl(spec:#NNN):`. |

Component **specs are derived from this list**, not invented in Figma. If a Figma session needs a component not listed here, **stop and go to Stage 1** — add the entry here first, then design it, then build it. The reverse — a component drawn in Figma without a prior spec entry — is a workflow violation: see [`SPEC_FIRST.md` §"When the spec, Figma, and code disagree"](./SPEC_FIRST.md).


---

# Figma reference (visual snapshot)

_Source: `specs/FIGMA_REFERENCE.md`_

# Figma Reference — Visual State of the Design Library

> Snapshot of the roamgap design library in Figma, with screenshots of every realized page, section, and major component. Updated by hand after each Figma iteration — when this document drifts from the file, this document is wrong. Sync workflow at the bottom.

## At a glance

- **File:** [roamgap — design library](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2)
- **File key:** `gzgZd6XTnFVwHHbPxKuac2`
- **Team:** Portfolio (Pro tier, Full seat)
- **Maintainer:** Ike Mawira
- **Last snapshot:** 2026-06-27
- **Spec version:** v0.6 (Phase 3 — payments **gate layer** drawn: the `Payments (gate layer)` Section on the Patterns page realises `/pricing` coming-soon, `PassStatusBanner` (incl. the new **price-changing-on** state), `BillingStatusCard` (Cancel/Resume + price-change line), `UpgradePrompt`/`RenewPrompt`/`PaidGate`, and `/checkout` — all Light + Dark; see [Payments — gate layer](#payments--gate-layer-added-2026-06-15).) · v0.5 (Phase 3 — spec rewritten to the unified `observedCalendars` model: the `CalendarOverlayPicker` is now one observed-calendars list, no visibility checkboxes, Region in Advanced settings, `worldwide` hint on `full`-scope days. **The `CalendarOverlayPicker` node `308:504` has been redrawn (2026-06-09) to the unified list**; see [Phase 3 — Calendar overlays surface](#phase-3--calendar-overlays-surface-added-2026-06-09).) · v0.4 (the pre-redraw snapshot: calendar-overlays surface screenshots captured from the live file; the planner's Advanced settings present the `FilterPanel` + `CalendarOverlayPicker` as adjacent cards)

## Pages

| Page | Node | Purpose | Status |
| ---- | ---- | ------- | ------ |
| `Cover` | `1:66` | Presentation cover — accent bar, brand wordmark, headline, governance lead, metadata, and a numbered "What's inside" page index | Live |
| `Tokens` | `1:74` | Visual token system — colour, type, space, radius, elevation | Live |
| `Primitives` | `483:2` | Atomic interaction states — Button (Primary/Ghost: default/hover/pressed/focus/disabled) and Input (idle/focused/error/disabled), all token-bound | Live |
| `Components` | `3:2` (Light) · `228:491` (Dark) | Component inventory — `Inventory · Light` / `Inventory · Dark` / a regridded `Source components` shelf; the `Calendar` showcase sits below the Light inventory | Live (Light + Dark) |
| `Patterns` | `1:5` | Assembled screens in a vertical stack of **eight** labelled Sections, each Light-left / Dark-right (see [File organization](#file-organization-canvas-layout)) | Live (Light + Dark parity) |

The Pro plan removes the Starter-tier 3-page cap — see [docs/DECISIONS.md](../docs/DECISIONS.md) ADR-005.

### Itinerary reminders — Plus-gated toggle (added 2026-06-27)

Realises the Plus gate on the planned-itinerary reminders ([EMAIL_ALERTS.md §4.5](../../backend/specs/EMAIL_ALERTS.md), [PROJECT.md §5](./PROJECT.md), [COMPONENTS.md § ItineraryDetailPanel](./COMPONENTS.md)): the per-itinerary reminders toggle on `/solo/[id]` gains a **locked** state when the paid tier is enforced and the viewer is not entitled. A `Pattern · Itinerary reminders — Plus-gated toggle` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, token-bound to the `colour` collection — **no new token**.

- **`Pattern · Itinerary reminders — Plus-gated toggle · Light`** `832:1580` (`x = 80`) · **`· Dark`** `833:1718` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`; the whole frame cascades). Two states of the reminders row: **entitled — or while dark (gate inert)** = the accent checkbox + "Email me as a trip in this itinerary approaches" (unchanged); **free + enforced → locked** = a disabled checkbox + "Trip reminders are a roamgap Plus feature" + an accent **Upgrade** pill.
- **Screenshots (host reachable):** [`itinerary-reminders-gated.png`](./figma/itinerary-reminders-gated.png) (`832:1580`) / [`itinerary-reminders-gated-dark.png`](./figma/itinerary-reminders-gated-dark.png) (`833:1718`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Deviation (recorded):** drawn in **Inter** (the app uses Geist) as a schematic — the live `ItineraryDetailPanel` reminders row is the Stage-3 source of pixel detail. The locked state renders only when `NEXT_PUBLIC_RG_PAID_TIER_ENFORCED` is on **and** `useEntitlement().entitled === false`; while dark the unlocked checkbox shows (mirrors the inert server gate).

![Pattern · Itinerary reminders — Plus-gated toggle (Light)](./figma/itinerary-reminders-gated.png)

### Page gutter — px-6, owned by AppShell (added 2026-06-23)

Documents the page-padding contract ([COMPONENTS.md § AppShell](./COMPONENTS.md)): `AppShell`'s `<main>` is the single owner of the page gutter — **`px-6` (24 px)** — on every route. Pages must not add their own page-level padding. A `Pattern · Page gutter` annotation frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, token-bound — **no new token**.

- **`Pattern · Page gutter — px-6 owned by AppShell · Light`** `809:1580` (`x = 80`) · **`· Dark`** `809:1702` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`). Two "viewport" rows: the **correct** `px-6` (24 px) gutter (Plan year · Calendar · now all pages) and the **former** `px-6 + extra px-4` = 40 px over-inset (Plan a year for 2 · Solo · Group trips) — the content bar visibly sits further from the page edge in the buggy row.
- **Screenshots (host reachable):** [`page-gutter.png`](./figma/page-gutter.png) (`809:1580`) / [`page-gutter-dark.png`](./figma/page-gutter-dark.png) (`809:1702`), exported from the live file and committed.
- **Deviation (recorded):** an annotation/spec frame (not a screen) — the live `AppShell` is the source of the actual gutter; this documents the rule that `/overlap`, `/solo`, `/groups` now conform to (they previously double-padded).

![Pattern · Page gutter (Light)](./figma/page-gutter.png)

### /account/settings — tile masonry (reduce vertical scroll) (added 2026-06-23)

Lays the `/account/settings` sections out as a **masonry of tiles** instead of one long single column ([COMPONENTS.md § SettingsTiles](./COMPONENTS.md)): one column on mobile, **two columns on `lg`+** (CSS multi-column, column-major fill) so the variable-height cards interlock — the tall **Account settings** card on the left, **Theme** / **Availability profile** / **Planner** packing down the right — and the page scrolls far less. A `Pattern · /account/settings — tile masonry` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, token-bound to the `colour` collection — **no new token**.

- **`Pattern · /account/settings — tile masonry … · Light`** `806:1580` (`x = 80`) · **`· Dark`** `808:1580` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`; the whole frame cascades). Two columns: **col1** = the tall Account-settings card (display name · language · country · the "Your default calendars" sub-box · email alerts · Save changes); **col2** = the Theme segmented control, the Availability-profile card (country · region · leave · max trip · Save/Forget), and the Planner default-strategy card. The **Theme** card is now its own page-level tile (un-nested from `AccountSettingsPanel`).
- **Screenshots (host reachable):** [`settings-masonry.png`](./figma/settings-masonry.png) (`806:1580`) / [`settings-masonry-dark.png`](./figma/settings-masonry-dark.png) (`808:1580`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Deviation (recorded):** drawn in **Inter** (the app uses Geist) as a schematic — the field rows are placeholder bars; the live `SettingsTiles` / `AccountSettingsForm` / `AvailabilityProfileEditor` are the Stage-3 source of pixel detail.
- **Form-control consistency refresh (2026-06-25).** The frame pair was redrawn to the unified control treatment ([COMPONENTS.md § Settings — form-control consistency](./COMPONENTS.md), [DESIGN_SYSTEM.md §11–§12](./DESIGN_SYSTEM.md)): **every field micro-label is now the uppercase `text/faint` eyebrow** (DISPLAY NAME · LANGUAGE · COUNTRY · REGION · ANNUAL LEAVE · MAX TRIP LENGTH · DEFAULT STRATEGY) — previously the settings-local labels were drawn sentence-case `text/muted`, mismatching the shared pickers' eyebrow. The buttons were already one geometry (`radius-8`, h-33: **Save changes** / **Save** / **Forget**), confirming the target; the live code is what drifted (pill Save changes, `px-5 py-2.5` Save/Forget) and Stage 3 brings it back onto this geometry. The 8 label nodes re-bound their fill to `text/faint` (`VariableID:1:14`) with `tracking-wide` (2.5%); the Dark twin cascades (faint text legible on dark). Same node ids (`806:1580` / `808:1580`); screenshots refreshed.

![Pattern · /account/settings — tile masonry (Light)](./figma/settings-masonry.png)

### Empty-state standard — dashed card + CTA outside (added 2026-06-23)

Pins the **one** canonical empty/default state across the app ([ERROR_STATES.md §1.1](./ERROR_STATES.md), [COMPONENTS.md § EmptyState](./COMPONENTS.md), [DESIGN_SYSTEM.md §11.1](./DESIGN_SYSTEM.md)): a shared **dashed `EmptyState`** (a subtitle headline + a grey body line), with any CTA rendered **outside** the card as a sibling at the **text-sm primary-button** scale. A `Pattern · Empty-state standard (dashed card + CTA outside)` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, token-bound to the `colour` collection — **no new token**.

- **`Pattern · Empty-state standard … · Light`** `799:1580` (`x = 80`) · **`· Dark`** `801:1580` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`; the whole frame cascades). A 2×2 grid of the four states: top row **no-CTA** (`/solo` "No itineraries yet." · `/calendar` "See a country's year."), bottom row **CTA-outside** (`/groups` signed-out "Sign in to create a group…" + a **Sign in** button below the card · `/groups` empty "No groups yet." + a **Create group** button below the card). All four share the dashed card; the buttons are `text-sm`, accent-filled, left-aligned beneath the card.
- **Standardises** the `/groups` **signed-out** face, which was a **solid** card with the *Sign in* button **inside** it — the anti-pattern the rule forbids. It now matches the empty-list, `/solo`, and `/calendar` states. Splits the empty-list line into subtitle + grey body.
- **Screenshots (host reachable):** [`empty-state-standard.png`](./figma/empty-state-standard.png) (`799:1580`) / [`empty-state-standard-dark.png`](./figma/empty-state-standard-dark.png) (`801:1580`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Deviation (recorded):** drawn in **Inter** (the app uses Geist) as a schematic — the live `EmptyState` / `GroupsPanel` are the Stage-3 source of pixel detail.
- **Other depictions brought into line (2026-06-23):** the standalone `/groups` sign-in card in `Pattern · /overlap free + /groups sign-in (Q3)` (`793:1580` / re-cloned Dark `805:1580`) was restyled from its old solid-card-with-button-inside to this format. The `EmptyState` tile in the `UI finesse wave` frame (`780:1583`) was reviewed and **already conforms** (dashed card, "No itineraries yet." + grey body, no CTA) — left unchanged.

![Pattern · Empty-state standard (Light)](./figma/empty-state-standard.png)

### /overlap shared calendar customisations — reused FilterPanel (added 2026-06-23)

`/overlap` reaches parity with `/year`'s calendar customisations ([MULTI_COUNTRY.md §4.1](./MULTI_COUNTRY.md), [COMPONENTS.md § OverlapPartyEditor / FilterPanel](./COMPONENTS.md)): the editor embeds the **reused `/year` `FilterPanel`** as a **shared window for the whole group** — months / excluded weekdays / blocked dates, wired to `OverlapRequest.filters` (already honoured by the engine, `resolveFilters`). A `Pattern · /overlap shared filters (calendar customisations)` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, token-bound — **no new token**.

- **`Pattern · /overlap shared filters … · Light`** `795:1580` (`x = 80`) · **`· Dark`** `797:1580` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`; the whole frame cascades). Shows the "Shared window — applies to everyone" label above the expanded **Filters** disclosure: TRAVEL WINDOW (12 month chips, Mar/Apr active), EXCLUDE WEEKDAYS (Sat/Sun active), BLOCKED DATES (a "3 Apr – 6 Apr ✕" chip + the date-range add row) — the same control as `/year`'s Advanced settings.
- **Screenshots (host reachable):** [`overlap-shared-filters.png`](./figma/overlap-shared-filters.png) (`795:1580`) / [`overlap-shared-filters-dark.png`](./figma/overlap-shared-filters-dark.png) (`797:1580`), committed.
- **Deviation (recorded):** drawn in **Inter** as a schematic of the **reused** `FilterPanel` (already mastered for `/year`) composed onto the overlap editor — the live `FilterPanel` is the Stage-3 source of pixel detail.

![Pattern · /overlap shared filters (Light)](./figma/overlap-shared-filters.png)

### /overlap free & open + /groups sign-in card (Q3) (added 2026-06-23)

Reframes `/overlap` as a **free extension of `/year`** ([MULTI_COUNTRY.md §3](./MULTI_COUNTRY.md), [OVERLAP.md §3](../../backend/specs/OVERLAP.md)): the "· roamgap Plus" eyebrow, the sign-in card, and the `PaidGate` are gone — the party editor renders for **every** visitor. `/groups` stays Plus; its anonymous sign-in card (Q3) gets clearer copy. A `Pattern · /overlap free + /groups sign-in (Q3)` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, token-bound to the `colour` collection — **no new token**.

- **`Pattern · /overlap free + /groups sign-in (Q3) · Light`** `793:1580` (`x = 80`) · **`· Dark`** `805:1580` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`; the whole frame cascades). Two sections:
  - **/overlap — free & open:** the eyebrow reads **"MULTI-COUNTRY OVERLAP"** (no "· ROAMGAP PLUS"), above the "When can you both be off?" H and the "Who's coming?" party editor (two person cards + "+ Add person" + "Find shared breaks") — the tool itself, shown to everyone, with **no sign-in card**.
  - **/groups — sign-in card (Plus, now the standard empty-state format — updated 2026-06-23):** the eyebrow reads "GROUPS · ROAMGAP PLUS"; the sign-in card is now the **shared dashed `EmptyState`** (headline **"Sign in to create a group or join one you've been invited to."** + grey body **"One organiser pays — everyone you invite joins free."**) with the **Sign in** button rendered **OUTSIDE** the card, left-aligned, at the `text-sm` primary-button scale — matching the agreed [Empty-state standard](#empty-state-standard--dashed-card--cta-outside-added-2026-06-23) (`799:1580`). It previously showed a **solid** card with the button **centred inside** it (the anti-pattern); the inner card was restyled to the dashed surface card and the button re-parented below it. This depiction now matches the shipped `GroupsPanel` signed-out face (PR #87).
- **Screenshots (host reachable):** [`overlap-free-and-groups-signin.png`](./figma/overlap-free-and-groups-signin.png) (`793:1580`) / [`overlap-free-and-groups-signin-dark.png`](./figma/overlap-free-and-groups-signin-dark.png) (`805:1580`), refreshed from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Supersedes** the gated framing in the older `Multi-country overlap (Phase 4)` section (`518:1042`) — overlap no longer has a locked/`UpgradePrompt` state.
- **Deviation (recorded):** drawn in **Inter** (the app uses Geist) as a schematic composite — the live `OverlapTool` / `OverlapPartyEditor` / `GroupsPanel` are the Stage-3 source of pixel detail.

![Pattern · /overlap free + /groups sign-in (Q3) · Light](./figma/overlap-free-and-groups-signin.png)

### Mobile nav — Sign-in folds into the hamburger, panel edge-aligned (added 2026-06-23)

The mobile Sign-in overshadow fix ([COMPONENTS.md § NavMenu / NavAuthSlot](./COMPONENTS.md)): on a phone the standalone "Sign in" button sat in the top-right corner while the `NavMenu` popover — anchored `right-0` to the ☰, not the right-most control — dropped down **left-shifted and detached**, so the button overshadowed and displaced the menu. A `Pattern · TopNav mobile — auth in menu (fix)` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, every fill token-bound to the `colour` collection — **no new token**.

- **`Pattern · TopNav mobile — auth in menu (fix) · Light`** `788:1580` (`x = 80`) · **`· Dark`** `792:1580` (`x = 1720`, a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`; the whole frame cascades — dark surface, light text, accent recolour). Two states:
  - **Closed** — only the mark + ☰ remain; the standalone "Sign in" button is gone below `sm` (it moves into the menu), so the ☰ is the sole right-aligned trigger.
  - **Open** — the ☰ popover is **edge-aligned to the content right edge** and lists the five current destinations (Plan year · Plan a year for 2 · Solo trips · Group trips · Calendar) + a separator + the new **Sign in** entry at the foot.
- **Screenshots (host reachable):** [`topnav-mobile-auth-fix.png`](./figma/topnav-mobile-auth-fix.png) (`788:1580`) / [`topnav-mobile-auth-fix-dark.png`](./figma/topnav-mobile-auth-fix-dark.png) (`792:1580`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Supersedes** the `topnav-mobile.png` showcase (`633:1296`, §9 below), which depicts the **old** buggy layout (popover under the ☰ with "Sign in" floating to its right) and stale labels ("Find a trip" / "Shared time off").
- **Deviation (recorded):** drawn in **Inter** (the app uses Geist / `font-mono` for the eyebrow) as a schematic pattern, and the ☰ is a text glyph rather than the SVG icon — the live `TopNav` / `NavMenu` components are the Stage-3 source of pixel detail.

![Pattern · TopNav mobile — auth in menu (fix) · Light](./figma/topnav-mobile-auth-fix.png)

### Anon nav — guest menu (added 2026-06-26)

A signed-out user's right slot was a bare "Sign in" pill — the account options (`AccountMenu`'s Saved searches / Settings / Subscription…) only appeared after signing in, so the nav read as "sign in to see anything." `NavGuestMenu` ([COMPONENTS.md §NavGuestMenu](./COMPONENTS.md), [VOICE.md §4.24](./VOICE.md)) is the anon counterpart to `AccountMenu`: it keeps Sign in one obvious click **and** tells a guest what an account adds.

- **`Pattern · Anon nav — guest menu`** — Light `826:1580` / Dark `827:1580` (Patterns page; Dark is a `clone()` with the `colour` collection mode set to **Dark** via `setExplicitVariableModeForCollection`). Two contexts:
  - **Desktop (≥ sm)** — a **split pill**: "Sign in" (a link → `/sign-in`, one click) joined to a "▾" disclosure that opens the guest panel — eyebrow "Browsing as a guest", "Every planner here is free — no account needed.", "Sign in to save your searches across devices and plan group trips.", and a "Sign in →" accent link.
  - **Mobile (< sm)** — the same guest pitch renders at the foot of the `NavMenu` hamburger (below the five destinations + a separator); no nested popover.
- **Screenshots (host reachable):** [`anon-nav-guest-menu.png`](./figma/anon-nav-guest-menu.png) (`826:1580`) / [`anon-nav-guest-menu-dark.png`](./figma/anon-nav-guest-menu-dark.png) (`827:1580`).
- **Deviation (recorded):** drawn in **Inter** as a schematic pattern (the app uses Geist / `font-mono` for the eyebrow), and the affordances are simplified token-bound shapes — the live `NavGuestMenu` / `NavAuthSlot` / `NavMenu` components are the Stage-3 source of pixel detail.

![Pattern · Anon nav — guest menu · Light](./figma/anon-nav-guest-menu.png)

### Groups page overhaul — date-groups, select-to-add, sort, collapse (added 2026-06-22)

The groups-page overhaul ([GROUPS.md §4.2/§4.8](./GROUPS.md), [PROJECT.md §4.2.1/§4.2.2](./PROJECT.md), [ERROR_STATES.md §2.20](./ERROR_STATES.md), [COMPONENTS.md § GroupTripDates / GroupOverlapResult / TripCard](./COMPONENTS.md)): a `Pattern · Groups page overhaul (1–10)` frame pair drawn directly in the live library via the remote Figma MCP, **Light-left / Dark-right** per the house convention, every fill **token-bound** to the `colour` collection (Geist Regular/Medium/SemiBold/Bold).

- **`Pattern · Groups page overhaul (1–10) · Light`** `743:1540` (`x = 80`) · **`· Dark`** `745:1570` (`x = 1720`), 1200 × 1440, on the **Patterns** page (`1:5`). The Dark mirror is a `clone()` with the `colour` collection mode set to **Dark** (`1:1`) via `setExplicitVariableModeForCollection`; the whole frame cascades (dark surface, light text, accent recolours) — verified by the committed screenshots. Six labelled schematic sections, top to bottom:
  - **1/2/8 · Best shared breaks** — the reused `SortToggle` pill row (Most efficient active), two `OverlapResultCard`s (the right one **selected → transparent** with an `accent` ring, item 8), the selection action bar (**Add to trip** / **Add to poll**), and the **"Show all 14 →"** clip/expand affordance (item 1).
  - **8 · The year at a glance** — a month grid with the selected days drawn as **`accent` outlines** (item 8).
  - **7 · Your availability — collapsed** — the one-line summary (label · country · leave) + **Edit** (item 7).
  - **3/4 · Trip dates** — the `GroupTripDates` panel: the default **"Trip"** date-group with two window rows (each with **Remove**) and **"+ Add another trip"** (items 3/4).
  - **5 · Date poll — promote** — an option row with **"Make this the trip"** and the closed state *"The group is taking 3 Apr – 6 Apr."* (item 5).
  - **10 · Itineraries** — the single standardised empty placeholder *"No itineraries yet."* + **Find trips** (item 10).
- **Screenshots (host reachable):** [`patterns-groups-overhaul.png`](./figma/patterns-groups-overhaul.png) (`743:1540`) / [`patterns-groups-overhaul-dark.png`](./figma/patterns-groups-overhaul-dark.png) (`745:1570`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Deviations (recorded):** (1) **One composite pattern-frame pair, schematic** — the overhaul touches many surfaces, so it is drawn as a single labelled overview frame (the established `Pattern · …` bar, as TF-4 / ADR-008) rather than a per-state redraw of every component; the live components are the Stage-3 source of truth for pixel detail. (2) The `SortToggle`, clip/expand, and the reused `OverlapResultCard`/`OverlapCalendar` are **composed/represented** here, not re-mastered as new library components (they are reuse + an additive `selectable` mode). (3) The year-at-a-glance grid is a schematic 5×7 swatch, not the full 12-month `OverlapCalendar`.

![Pattern · Groups page overhaul (Light)](./figma/patterns-groups-overhaul.png)

### Event multi-window — per-release time-off, hero + show-more, dateless empty (added 2026-06-23)

First-class multi-window event groups ([GROUPS.md §2/§4.2](./GROUPS.md), [backend GROUPS.md §6.5](../../backend/specs/GROUPS.md), [COMPONENTS.md § GroupEventResult / EventPlanCard](./COMPONENTS.md)): an event group holds **0..N releases** (`eventWindows`), and `GroupEventResult` renders **one "Your time off" section per release**. A `Pattern · Event multi-window` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, 1200 × 1012, token-bound to the `colour` collection (Geist) — **no new token**.

- **`Pattern · Event multi-window · Light`** `784:1580` (`x = 80`) · **`· Dark`** `786:1580` (`x = 1720`, a `clone()` with the `colour` collection set to **Dark** `1:1` via `setExplicitVariableModeForCollection`; the whole frame cascades). Three labelled sections, top to bottom:
  - **Release 1 / Edition 2** — each: an **event-days** `DateWindowCard` (calendar-leaf + the release window, item 9) + a full-width **hero** `EventPlanCard` (`options[0]`, `accent`-ringed, with the `Total · Leave · Efficiency` stats + the wrapping `day/*` kind strip) + a **"Show N more"** toggle (item 2 — no carousel, a stable height). Two releases are drawn to make the multi-window model legible.
  - **Dateless empty (item 3)** — the dashed `EmptyState`: *"No event dates yet. Propose them in the date poll below, then promote one to set the event date."*
- **Screenshots (host reachable):** [`patterns-event-multiwindow.png`](./figma/patterns-event-multiwindow.png) (`784:1580`) / [`patterns-event-multiwindow-dark.png`](./figma/patterns-event-multiwindow-dark.png) (`786:1580`), committed.
- **Deviations (recorded):** (1) One composite schematic pattern-frame pair; the live components are the Stage-3 source of pixel detail. (2) `EventPlanCard` / `DateWindowCard` are composed here, not Figma-mastered library components. (3) Mobile (item 10) is the **same** hero + wrapping-strip layout at a narrow width — the strip `flex-wrap`s so there is no horizontal scroll, so no separate mobile frame is drawn.

![Pattern · Event multi-window (Light)](./figma/patterns-event-multiwindow.png)

### UI finesse wave — DateWindowCard, who's-coming wrap, legend move, solo empty, create button (added 2026-06-23)

The PR1 polish ([GROUPS.md §4.1/§4.2/§4.6/§4.8](./GROUPS.md), [MULTI_COUNTRY.md §4.1/§4.2](./MULTI_COUNTRY.md), [COMPONENTS.md § DateWindowCard / OverlapPartyEditor / OverlapLegend / GroupTripDates / GroupDatePoll](./COMPONENTS.md), [ERROR_STATES.md §2.20](./ERROR_STATES.md)): a `Pattern · UI finesse wave` frame pair drawn in the live library via the remote Figma MCP, **Light-left / Dark-right** per the house convention, 1200 × 1709, on the **Patterns** page (`1:5`), below the Solo-trips pair. Every fill is **token-bound** to the `colour` collection (Geist Regular/Medium/SemiBold); the overlap/day states stay `status/success` · `status/warn` · `status/danger` and the accents reuse `accent` — **no new token**.

- **`Pattern · UI finesse wave · Light`** `775:1580` (`x = 80`) · **`· Dark`** `781:1580` (`x = 1720`). The Dark mirror is a `clone()` with the `colour` collection mode set to **Dark** (`1:1`) via `setExplicitVariableModeForCollection`; the whole frame cascades (dark surface, light text, accent + state recolours) — verified by the committed screenshots. Six labelled schematic sections, top to bottom:
  - **A · `DateWindowCard` (new — items 6/8/9).** The shared finessed date row: a **calendar-leaf** glyph (the start day over its month on `accent`) + a **smart range** (*"3 – 6 Apr 2026"*, collapsing the shared month/year) + a **duration** (*"4 days · Fri – Mon"*); `default` + `accent` (favourite) tones.
  - **B · Trip dates (item 6).** A trip card whose windows are `DateWindowCard`s with a **Remove**, above the dashed **"+ Add another trip"** — the barebones list, finessed.
  - **C · Date poll option (item 8).** An option framed by a `DateWindowCard` (favourite, **Approve**, the **"Group favourite"** pill), its `OverlapDayStrip`, the new **tally meter** (a bar filled to `votes/memberCount` beside *"2 of 3 members"*), and the **Add to trip** / **Make this the trip** actions.
  - **D · /overlap "Who's coming" wrap (items 11/12).** Compact person cards in a **3-across wrapping grid** (Person 4 wraps to row 2) + **"+ Add person"**; the idle *"Add who's coming…"* helper line is gone.
  - **E · Legend under "Who's in" (item 5).** The crew left column — the `GroupRoster` rows then the **Overlap key** legend lifted in directly beneath them.
  - **F · Empty reuse + create button (items 13/14).** The shared dashed **`EmptyState`** (*"No itineraries yet."*, one line, no CTA) used by `/solo` + `/groups`, and the **compact `text-sm` "Create group"** button beside the `text-lg` *"Your groups"* title (with the old base-size button shown for contrast).
- **Screenshots (host reachable):** [`patterns-ui-finesse.png`](./figma/patterns-ui-finesse.png) (`775:1580`) / [`patterns-ui-finesse-dark.png`](./figma/patterns-ui-finesse-dark.png) (`781:1580`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Deviations (recorded):** (1) **One composite schematic pattern-frame pair** spanning several surfaces (the established `Pattern · …` convention), not a per-state redraw; the live components are the Stage-3 source of truth for pixel detail. (2) `DateWindowCard` / `OverlapDayStrip` / the tally meter are **composed** here, not mastered as separate library components — small presentational reuse over existing tokens. (3) The person cards, roster, and legend are schematic stand-ins, not full library instances.

![Pattern · UI finesse wave (Light)](./figma/patterns-ui-finesse.png)

### Groups coordination polish — poll→trip, best-5 hero, poll day strips, scroll cap, holiday notes (added 2026-06-22)

The §4.2/§4.6/§4.8 coordination polish ([GROUPS.md §4.2/§4.6/§4.8](./GROUPS.md), [COMPONENTS.md § OverlapResultCard / OverlapDayStrip / GroupOverlapResult / GroupDatePoll](./COMPONENTS.md), [VOICE.md §4.14](./VOICE.md)): a `Pattern · Groups coordination polish` frame pair drawn directly in the live library via the remote Figma MCP, **Light-left / Dark-right** per the house convention, 1200 × 1363, on the **Patterns** page (`1:5`), below the groups-page-overhaul pair. Every fill is **token-bound** to the `colour` collection (Geist Regular/Medium/SemiBold/Bold); the three overlap-day states stay `status/success` · `status/warn` · `status/danger` and the hero/favourite accents reuse `accent` — **no new token**.

- **`Pattern · Groups coordination polish · Light`** `756:1580` (`x = 80`) · **`· Dark`** `760:1691` (`x = 1720`). The Dark mirror is a `clone()` with the `colour` collection mode set to **Dark** (`1:1`) via `setExplicitVariableModeForCollection`; the whole frame cascades (dark surface, light text, accent + day-state recolours) — verified by the committed screenshots. Three labelled schematic sections, top to bottom:
  - **A · Best shared breaks — best 5 (item 2/5).** The sort row + a **full-width hero** card (the top-ranked window, `accent`-ringed) carrying the labelled **`OverlapDayStrip`** (date · coloured box · weekday) and the **"Public holidays: Good Friday · Easter Monday (KE)"** note (item 5), above a **2×2 grid** of the four standard `OverlapResultCard`s.
  - **B · Expanded → height-capped scroll (item 4).** The "Show all N" long tail in a `surface/sunken` **scroll region** that **clips** its overflowing rows (the last rows cut off, a `text/faint` scrollbar thumb at the right) — the `max-h ≈ 6 cards` cap that keeps the poll/comments/trip-dates reachable.
  - **C · Date poll — option rows + Add to trip (item 1/3).** Two full-width poll options: the favourite (checked, **"Group favourite"** pill, *"2 of 2 members"*) with its `OverlapDayStrip`, *"Suggested by …"*, and **both** actions — **"Add to trip"** (outline, any member, leaves the poll open) + **"Make this the trip"** (`accent`, organiser, terminal); and a second option whose lone **"Add to Trip 2"** shows a member seeding a *subsequent* trip from the poll (item 1).
- **Screenshots (host reachable):** [`patterns-groups-coordination-polish.png`](./figma/patterns-groups-coordination-polish.png) (`756:1580`) / [`patterns-groups-coordination-polish-dark.png`](./figma/patterns-groups-coordination-polish-dark.png) (`760:1691`), exported from the live file and committed. The node ids are the durable pointers; refresh from them when these frames change.
- **Deviations (recorded):** (1) **One composite pattern-frame pair, schematic** — like the groups-page overhaul above, the change spans several surfaces, so it is drawn as a single labelled overview frame (the established `Pattern · …` convention) rather than a per-state redraw; the live components are the Stage-3 source of truth for pixel detail. (2) `OverlapDayStrip` is **composed** here (date/box/weekday cells) rather than mastered as a separate library component — it is a small presentational reuse over the existing day-state tokens. (3) The scroll cap is shown by **real clipping** of the region's overflowing rows, schematic stand-ins for full `OverlapResultCard`s.

![Pattern · Groups coordination polish (Light)](./figma/patterns-groups-coordination-polish.png)

### Event result — full-width cards + wrapping day strip (added 2026-06-22)

The event-archetype result redesign ([GROUPS.md §4.2](./GROUPS.md), [COMPONENTS.md § GroupEventResult / EventPlanCard](./COMPONENTS.md)): the prior 2-up grid of narrow `TripCard`s clipped the fixed 7-column `CalendarStrip` into a horizontal scrollbar. A `Pattern · Event result — full-width cards` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, 1200 × 482, every fill token-bound to the `colour` collection (Geist; the day cells bind `day/pto` · `day/weekend` · `day/holiday`) — **no new token**.

- **`Pattern · Event result — full-width cards · Light`** `762:1580` (`x = 80`) · **`· Dark`** `763:1691` (`x = 1720`, a `clone()` with the `colour` collection set to **Dark** `1:1` via `setExplicitVariableModeForCollection`; the whole frame cascades). Two stacked **full-width** `EventPlanCard`s:
  - **#1 (recommended)** — `accent`-ringed, the `#1` eyebrow + date range + a **"Most efficient"** tag, the inline `Total · Leave · Efficiency` stats, and the **wrapping day strip** (weekday label + a `day/*` kind box with the date number + `W/H/P` letter).
  - **#2 (standard)** — a longer (16-day) window whose strip **wraps** across the full width instead of scrolling — the whole point of the redesign.
- **Screenshots (host reachable):** [`patterns-event-fullwidth.png`](./figma/patterns-event-fullwidth.png) (`762:1580`) / [`patterns-event-fullwidth-dark.png`](./figma/patterns-event-fullwidth-dark.png) (`763:1691`), committed.
- **Deviations (recorded):** (1) The schematic **"Most efficient"** tag is drawn as a filled `accent` pill (white text) for legibility at thumbnail scale; the shipped tag is a subtle tint (`bg-accent/15 text-accent`). (2) `EventPlanCard` is **composed** here, not mastered as a library component.

![Pattern · Event result — full-width cards (Light)](./figma/patterns-event-fullwidth.png)

### Group cover artwork — six themes + placements (added 2026-06-22)

The per-group cover artwork ([GROUPS.md §4.9](./GROUPS.md), [COMPONENTS.md § GroupCover / GroupCoverPicker](./COMPONENTS.md), [BRANDING.md §4.4](./BRANDING.md)): a `Pattern · Group cover artwork` frame pair on the **Patterns** page (`1:5`), **Light-left / Dark-right**, 1200 × 874. The **chrome** (cards, text, page) is token-bound to the `colour` collection; the **cover art itself uses a self-contained fixed-hex illustration palette** (sky / sun / hills per theme), NOT the UI tokens — so it is **full-bleed** and reads identically on either surface (the Dark twin's covers are pixel-identical to the Light twin's; only the chrome flips). **No new `colour` token.**

- **`Pattern · Group cover artwork · Light`** `765:1580` (`x = 80`) · **`· Dark`** `768:1580` (`x = 1720`, a `clone()` with the `colour` collection set to **Dark** `1:1`). Sections:
  - **The six themes** — `Sunrise · Ocean · Forest · Desert · Aurora · Blossom`, each a flat-illustration horizon (a two-tone sky, a sun/moon ellipse, two layered hill ellipses, clipped to a `radius-10` rounded rect).
  - **On the `/groups` list** — a `GroupsPanel` row with a **cover thumbnail** (72×52) leading the name + meta + status pill.
  - **In the workspace** — a full-column (360-wide) **cover banner** sitting **atop the collapsed "Your availability"** card (the item-7 collapsed state) — hidden while the editor is expanded.
  - **Manage group · Cover** — the `GroupCoverPicker` swatch row, the resolved theme (`Sunrise`) **`accent`-ringed**.
- **Screenshots (host reachable):** [`patterns-group-cover.png`](./figma/patterns-group-cover.png) (`765:1580`) / [`patterns-group-cover-dark.png`](./figma/patterns-group-cover-dark.png) (`768:1580`), committed.
- **Deviations (recorded):** (1) The Figma covers are **composed from rounded rects + ellipses** (a two-tone sky band, not a true gradient) for robustness; the shipped `GroupCover` SVG may use real gradients — both are the same flat-illustration language. (2) `GroupCover` is **drawn as art**, not mastered as a swappable library component (the source of truth is the `GroupCover` SVG component, [GROUPS.md §4.9](./GROUPS.md)).

![Pattern · Group cover artwork (Light)](./figma/patterns-group-cover.png)

### `CalendarLoader` (added 2026-06-20)

The optimizer's first-class loading indicator ([COMPONENTS.md § State surfaces](./COMPONENTS.md), [VOICE.md §4.2](./VOICE.md)) — an animated year-calendar that reads as "scanning the year for trips," drawn as a representative **mid-sweep still** (Jan–Apr lit, the rest neutral day-tiles) under the "Calculating trips…" caption. Built directly in the library; every fill is **token-bound** to the `colour` collection so the Light/Dark cascade applies.

- **Source component:** node `679:75` — a `surface/sunken` card (`radius-12`, 24×18 padding) holding a 12-cell month `strip` (each cell 22×26, `radius-5`) and the caption. The four leading cells fill `accent` with `accent/contrast` letters (the sweep through Q1); the rest are `surface/elevated` tiles with `text/faint` letters; caption `text/muted` (Geist Regular 13). In code this still becomes a forward shimmer (cells brighten Jan→Dec, looping) that collapses back to this static strip under `prefers-reduced-motion`.
- **Inventory:** a `CalendarLoader section` in **Inventory · Light** (`680:49`, instance `680:52`, under root `3:2`) and **Inventory · Dark** (`680:79`, instance `680:82`, under root `228:491`).
- **Dark parity confirmed:** the dark instance renders distinctly (dark card, light caption, `accent` cells holding orange with light letters) — the `colour`-collection cascade reaches every paint. Screenshots [`calendar-loader.png`](./figma/calendar-loader.png) (`679:75`) and [`calendar-loader-dark.png`](./figma/calendar-loader-dark.png) (`680:82`) were exported from the live file via the remote Figma MCP and committed.
- **Code realisation (Stage 3 — follows this draw):** the drawn source for `CalendarLoader.tsx`, wired into `AnnualPlannerForm` (`/year`) while the optimizer request is pending and doubling as the scroll target ([COMPONENTS.md § Annual planner — Finding state](./COMPONENTS.md)).

### `DefaultStrategyPreference` (added 2026-06-20)

The per-device planner default-strategy setting on `/account/settings` ([COMPONENTS.md § DefaultStrategyPreference](./COMPONENTS.md)) — a "Planner" card with a labelled **Default strategy** select (defaulting to **Longest breaks**) + help. Token-bound; Light/Dark cascade verified.

- **Source component:** node `681:101` — a `surface/elevated` card (`border` hairline, `radius-12`) holding a `text` heading "Planner", a `text/muted` "Default strategy" label, a `surface/sunken` select pill (`border`, `radius-8`, "Longest breaks" + ▾), and the `text/muted` help line.
- **Inventory:** a `DefaultStrategyPreference section` in **Inventory · Light** (`682:101`, instance `682:104`, under root `3:2`) and **Inventory · Dark** (`682:111`, instance `682:114`, under root `228:491`).
- **Dark parity confirmed:** dark card, light heading, dark select with light text — the `colour` cascade reaches every paint. Screenshots [`default-strategy-pref.png`](./figma/default-strategy-pref.png) (`681:101`) / [`default-strategy-pref-dark.png`](./figma/default-strategy-pref-dark.png) (`682:114`) committed.
- **Code realisation:** `DefaultStrategyPreference.tsx`, mounted below `AccountSettingsForm` on `/account/settings`; `ResultsClient` reads the same `localStorage` preference via `pickDefaultStrategy` to choose the initially-selected plan.

### TF-3 — per-market "leave" terminology (added 2026-06-20)

The tester-feedback terminology change ([VOICE.md §3.2](./VOICE.md), [I18N.md §14](./I18N.md)): "PTO" tested as US jargon, so the planner/trip-finder display layer now reads the **en-KE default term — "leave" / "annual leave"** (en-US keeps "PTO" in the catalog). The library's in-scope frames were updated to the en-KE term in **both Light and Dark**; a text-only swap (no layout, no token, no new node), so the dark cascade is unaffected. The day-kind letter **"P"** on `DayCell` is deliberately **left** (the W/H/P/B letters are a separate localisation concern; `DayCell`/`ScoreBadge`/`OverBudgetMarker` hover/SR strings stay "PTO" pending their own pass, per VOICE §3.2). Out-of-scope surfaces (Overlap, `CalendarOverlayPicker` Free/PTO toggle, `OffDayLedger`, `SavedSearchesList`, holiday-league "PTO leverage") keep "PTO" and are unchanged.

- **Component masters / showcases (Components page `1:4`)** — edited the source so every instance inherits: `TripCard` stat `PTO`→`LEAVE` (Rank-1 `3:135` / Rank-2 `3:179`, Dark `228:615` / `228:657`); `YearPlanCard` `PTO spent`→`Leave spent` (Selected/Default/Spread `5:54` / `5:76` / `5:98`, Dark `228:716` / `228:737` / `228:758`); `AnnualPtoInput` label `ANNUAL PTO BUDGET`→`ANNUAL LEAVE` (`5:125`, Dark `228:783`); `BudgetUtilisationBar` meta `… PTO`→`… leave` (`5:29` / `5:36`, Dark `228:693` / `228:699`); `FilterPanel` weekday helper "We won't spend **leave** on these days." (`266:529`); `CalloutBox / info` budget-note master "fewer **leave** days … each **leave** day" (`411:27`, which propagates to its instances on both pages).
- **Pattern frames (Patterns page `1:5`)** — `Pattern · Home` Light `8:183` / Dark `108:179` (eyebrow "… · 30 LEAVE BUDGET", "Your year: … 6 **leave** spent.", hero/sub copy); `Pattern · Find a trip (results)` Light `413:513` / Dark `414:2` (eyebrow "… · 10 LEAVE", the lost-to-weekend callout override "your **leave** needs"; the budget-note callout inherits the `411:27` master); `Pattern · Find a trip — hero` `417:693` ("how many **leave** days you have", PtoInput label override "LEAVE BUDGET"); `Pattern · Itinerary detail` Light `611:2` / Dark `611:101` (`AggregateHeader` `LEAVE`, the composition line "… · 1 **leave** · …", `ItineraryTripCard` `LEAVE` stat).
- **Screenshots refreshed (host reachable):** `patterns-home.png` / `-dark`, `patterns-trip-results.png` / `-dark`, `trip-card.png`, `inputs-section.png`, `year-plan-card.png`, `filter-panel.png`, `callout-box.png`, and — clearing the earlier egress-block gap — `itinerary-detail.png` / `-dark` (`611:2` / `611:101`) and `patterns-trip.png` (`417:693`). The node ids above are the durable pointers; refresh from them when these frames change.

![Pattern · Home — en-KE "leave" terminology (Light)](./figma/patterns-home.png)

### TF-4 — first-run onboarding (added 2026-06-21)

The tester-feedback onboarding change ([PROJECT.md §4.1.1](./PROJECT.md), [VOICE.md §4.19](./VOICE.md), [COMPONENTS.md § PlannerHero / HowItWorksSteps / PlannerDemo](./COMPONENTS.md)): the home first-run state read as a cold form ("too complicated", low conversion, TF-4), so it now leads with *why → how → what you get*. Drawn as a dedicated **`Pattern · Home — first run (TF-4)`** frame pair on the **Patterns** page (`1:5`), Light-left / Dark-right per the house convention. Built directly in the live library via the remote Figma MCP write path; every new fill/stroke **token-bound** to the `colour` collection so the Light/Dark cascade applies (Geist Regular/Medium/SemiBold/Bold).

- **`Pattern · Home — first run (TF-4) · Light`** `691:1306` (`x = 80`) · **`· Dark`** `695:1423` (`x = 1720`) — a `surface`-filled vertical auto-layout column, 1440 × 1697. The Dark mirror is a `clone()` with the `colour` collection mode set to **Dark** (`1:1`) via `setExplicitVariableModeForCollection`; the whole frame cascades (dark surface, light text, accent badges/button, the reused instances and sunken demo block all recolour) — verified by the export below. Top to bottom:
  - **Reframed hero (`Hero · Copy` `691:1307`)** — eyebrow "ANNUAL PLANNER" (`text/faint`, `691:1308`), the benefit-led H1 **"Turn public holidays into more time off."** (`text`, Geist Bold 48/52, `691:1309`), and the now-visible lead (`text/muted`, `691:1310`) — replacing the mechanism-first "Plan your year off." The lead states a result is a Trip, that plans are ranked, and that it's free / no sign-up ([VOICE.md §4.19](./VOICE.md)).
  - **Inline "How it works" (`692:1306`)** — a "HOW IT WORKS" label (`text/faint`) over a 3-column row (`692:1308`) of token-bound step cards (`692:1309` / `692:1314` / `692:1319`), each a `surface/elevated` card (`border` hairline, `radius-12`, 20px padding) with an `accent` number badge (`accent/contrast` numeral), a SemiBold `text` title, and a `text/muted` body; a "See how it works →" `accent` link (`692:1324`) closes the section.
  - **Form (`693:1306`)** — the real planner form, **reused** by cloning the Home frame's Inputs row (`8:199`), Presets row (`21:2`), and CTA row (`8:233`) instances; no redraw.
  - **Demo · Example results (`694:1341`)** — a `surface/sunken` block (`radius-16`, 40px padding): the "EXAMPLE · …" eyebrow (`text/faint`, `694:1343`), the fact headline (`text`, SemiBold, `694:1344`), the "build your own" lead (`text/muted`), then **two read-only `TripCard` instances** (`694:1347` / `694:1389`, cloned from the Home selected-plan `TripCard`s `8:346` / `8:389`) — the worked example beneath the form.

- **Demo content reuse (recorded divergence).** The Figma demo **reuses the already-drawn German `TripCard` instances** (Apr/Easter, 11 days off / 3 leave), so its eyebrow + headline read "EXAMPLE · GERMANY 2026" / "Two trips. 11 days off. 3 days of leave." to match the cards. The **live `PlannerDemo` fixture** is **Kenya 2026** — two bridge trips (Mashujaa Day + Christmas), **18 days off / 8 leave** ([`src/lib/planner/demo.ts`](../src/lib/planner/demo.ts), [PROJECT.md §4.1.1](./PROJECT.md)). The visual contract — Example-labelled, read-only `TripCard`s in a sunken block — is identical; only the illustrative country/numbers differ, to avoid re-drawing two 9-day calendar strips (the same reuse the trip-finder pattern made). Re-drawing the demo cards to the Kenya fixture is a tracked, optional follow-up.

- **Legacy Home frames (`8:183` / `108:179`) — hero-copy sync deferred.** The reframed hero copy persists on the with-input results state too (the live `PlannerHero` renders on both `/year` branches). Updating the legacy Home frames' hero text to match requires re-flowing their dense **absolute-positioned** layout (the collapsed lead, inputs row, presets, CTA, plans-comparison all shift), so it is a tracked follow-up — in keeping with the existing "drop · Phase 1 from `patterns-home`" follow-up. The dedicated `Pattern · Home — first run (TF-4)` frame is the authoritative drawn realisation of the new hero.

> **Screenshots captured (2026-06-21).** `specs/figma/patterns-home-first-run.png` (`691:1306`) and `patterns-home-first-run-dark.png` (`695:1423`) were exported at native resolution (1440 × 1697) from the live file via the remote Figma MCP (`get_screenshot` → `curl`) and committed — the `figma.com` asset host is reachable from this environment. The node ids are the durable pointers; refresh from them when the frames change.

![Pattern · Home — first run (TF-4) · Light](./figma/patterns-home-first-run.png)

### UX-11 — planner advanced revamp (added 2026-06-21)

The planner advanced-settings revamp ([PROJECT.md §4.1.2](./PROJECT.md); [COMPONENTS.md](./COMPONENTS.md) `PlannerPresets` / `TripStyleSlider` / `TravelWindowPicker` / `PlanSummary` / `AvailabilityProfileEditor`; [VOICE.md §4.16/§4.20](./VOICE.md)): the option-heavy planner is reworked around **presets + progressive disclosure + smart defaults**, and the per-page availability save/load cluster is replaced by autoload + a single settings editor. Drawn as a dedicated **`Planner advanced revamp (UX-11)`** Section at the bottom of the **Patterns** page (`698:1540`), Light-left / Dark-right per house convention; every fill/stroke token-bound to the `colour` collection (Geist Regular/Medium/SemiBold).

- **`Pattern · Planner revamp (UX-11) · Light`** `698:1541` (`x = 80`) · **`· Dark`** `705:1540` (`x = 1720`) — a `surface`-filled vertical auto-layout column, 1040 wide. The Dark mirror is a `clone()` with the `colour` mode pinned to **Dark** (`1:1`) via `setExplicitVariableModeForCollection`; the Light frame is pinned to **Light** (`1:0`) explicitly. **Gotcha recorded:** the file's default collection mode resolved **Dark** for any subtree without an explicit mode, blacking out the `surface/sunken` `PlanSummary`/Advanced fills until the Light frame was pinned to Light — pin the mode on both mirrors, don't rely on the default. Top to bottom:
  - **Hero inputs (`698:1543`)** — Country / Annual leave / Max trip length as token-bound `surface/elevated` cards. *(The autoload hint caption was **removed** in the §4.2.1 declutter — the inputs still autoload from the saved profile silently; see [AVAILABILITY_PROFILE.md §5.2](./AVAILABILITY_PROFILE.md).)*
  - **Quick start — `PlannerPresets` (`698:1544`)** — the three outcome chips "Long weekends" / "A balanced year" (active `accent`) / "One big trip" + the "one tap … never need Advanced" helper.
  - **`PlanSummary` + Find trips (`698:1545`)** — the "YOU'LL GET" live-preview line ("Turning 30 days of leave into trips up to 2 weeks long, any time this year.") beside the `accent` Find-trips button, in a `surface/sunken` bar.
  - **Advanced settings (`698:1546`)** — the now-light drawer: **`TripStyleSlider`** (`700:1541`, an `accent`-filled track + thumb with the "Fewer, longer trips ↔ More, shorter trips" captions), **`TravelWindowPicker`** (`701:1540`, the "When can you travel?" segmented "Any time"/"Pick months" + the quarter quick-picks Jan–Mar … Oct–Dec it expands to), and the remaining-knobs line (Year · Region · Exclude weekdays · Blocked dates · Calendars).
  - **`AvailabilityProfileEditor` (`698:1547`)** — the settings card: "Availability profile" + lead, the field row (Country/Region/Annual leave/Max trip), the workweek weekday toggles (Mon–Fri active), the "also off on" chips (Gregorian/Islamic (Kenya) + Add calendar), and the **Save profile** / **Forget profile** footer with the "✓ Saved to your profile." status.

- **Dark parity confirmed:** the Dark clone renders distinctly — dark surfaces, light text, `accent` on the active preset / slider / "Any time" segment / Mon–Fri / Find trips / Save, `status/success` green status — the `colour` cascade reaches every paint.

> **Screenshots captured (2026-06-21).** `specs/figma/patterns-planner-revamp.png` (`698:1541`) and `patterns-planner-revamp-dark.png` (`705:1540`) exported at native 1040 × 1230 via the remote Figma MCP and committed.

![Pattern · Planner revamp (UX-11) · Light](./figma/patterns-planner-revamp.png)

![Pattern · Home — first run (TF-4) · Dark](./figma/patterns-home-first-run-dark.png)

### §4.7 — Group availability preload + /trip form rhythm (added 2026-06-21)

The group create/join availability redesign + the `/trip` form-rhythm parity ([GROUPS.md §4.7](./GROUPS.md), [COMPONENTS.md § CreateGroupForm / OptimizerForm — form layout / AdvancedCalendarFilters / FormActionBar](./COMPONENTS.md), [VOICE.md §4.14](./VOICE.md)). Drawn as a dedicated **`Groups availability preload + /trip rhythm (§4.7)`** Section at the bottom of the **Patterns** page (`707:1540`), Light-left (`x≈80`) / Dark-right (`x≈1720`); every fill/stroke token-bound to the `colour` collection (Geist Regular/Medium/SemiBold). Each Light frame is pinned to **Light** (`1:0`) and its Dark twin is a `clone()` pinned to **Dark** (`1:1`) via `setExplicitVariableModeForCollection` — the whole frame cascades (dark surfaces, light text, accent segments/bar/button), verified by the exports below (zero unbound fills).

- **`Pattern · Group create (redesign §4.7)`** — **Light** `708:1540` / **Dark** `713:1540`. The redesigned `CreateGroupForm` card (no `MemberAvailabilityEditor`): **Group name** + **Year**, the **segmented** *What kind of group* (A crew / An event) and *Who can join* (Anyone with the link / I approve each request) controls — the house two-button group, replacing the old bare `<select>`s — each with its helper; then the preloaded-availability caption *"✓ Your availability is set from your saved profile — fine-tune it inside the group."*, the **"YOU'LL GET"** summary bar (*"You're creating a crew for 2026…"*), and the **Create group** button. The "barebones → first-class" redesign ([COMPONENTS.md § CreateGroupForm](./COMPONENTS.md)).
- **`Pattern · Group workspace availability nudge (§4.7)`** — **Light** `709:1540` / **Dark** `713:1573`. The `accent`-barred `surface/sunken` callout *"Set your availability — …so the shared dates count you in…"* sitting **above** the workspace "Your availability" editor — shown to a member who joined without a saved profile, dismissed on first save ([GROUPS.md §4.2/§4.7](./GROUPS.md)).
- **`Pattern · Find a trip — form rhythm (§ OptimizerForm)`** — **Light** `710:1548` / **Dark** `713:1589`. The `/trip` form re-spaced to the `/year` rhythm: a tight `gap-3` input cluster (Country + leave + the "✓ Country & leave filled in…" caption), the **`surface/sunken` action bar** (`FormActionBar`) holding the "YOU'LL GET" `PlanSummary` + **Find trips**, then **Advanced settings** with the shared `AdvancedCalendarFilters` — the **equal-height** (`lg:items-stretch`) Filters + Calendars cards (the "different card sizes" bug fixed: both render at the same height; Calendars `FILL`s to match the taller Filters).

> **Screenshots captured (2026-06-21).** Exported at native resolution via the remote Figma MCP (`get_screenshot` → `curl`) and committed: `groups-create-redesign.png` / `-dark` (`708:1540` / `713:1540`), `group-availability-nudge.png` / `-dark` (`709:1540` / `713:1573`), `trip-form-rhythm.png` / `-dark` (`710:1548` / `713:1589`). The node ids are the durable pointers; refresh from them when the frames change.

![Pattern · Group create (redesign §4.7) · Light](./figma/groups-create-redesign.png)

![Pattern · Find a trip — form rhythm (§ OptimizerForm) · Light](./figma/trip-form-rhythm.png)

### §4.1.3 — /year results declutter + revamped TripCard header (added 2026-06-22)

The post-UX-11 results declutter ([PROJECT.md §4.1.3](./PROJECT.md), [COMPONENTS.md § TripCard / YearPlanComparison](./COMPONENTS.md), [VOICE.md §4.21](./VOICE.md)). Drawn as a dedicated **`Results declutter (§4.1.3)`** Section at the bottom of the **Patterns** page (`715:1540`), Light-left / Dark-right; every fill token-bound to the `colour` collection (Geist Regular/Medium/SemiBold/Bold). Each Light frame is pinned to **Light** (`1:0`), its Dark twin a `clone()` pinned to **Dark** (`1:1`) — the cascade reaches every paint (dark cards, light text, accent day-cells), verified by the exports.

- **`TripCard — revamped header (§4.1.3)`** — **Light** `715:1542` / **Dark** `717:1540`. The decluttered card: the header is now just the **date** + the **Add to itinerary** action — the prominent coloured efficiency `ScoreBadge` is **gone from the header**; efficiency moves into the **footer stat row** (Total · Leave · **Efficiency** `1.50×`). The `#N` rank stays a faint eyebrow. Shared by `/trip` + `/year`.
- **`/year results — decluttered (§4.1.3)`** — **Light** `716:1540` / **Dark** `717:1579`. The result surface: the **selected plan shows immediately** (eyebrow "SELECTED · LONGEST BREAKS" + the year-summary headline + "Strategy: Longest breaks."), the five-strategy comparison is a **collapsed "▸ Compare strategies"** disclosure (the Advanced-settings idiom), and there is **no "Lost to weekend" card** and no big "Pick a strategy" block. Embeds the revamped `TripCard`.

> **Superseded.** The `Pattern · Planner revamp (UX-11)` **Quick start** node `698:1544` (the three preset chips) is retired by §4.1.3 — `PlannerPresets` was removed (the chips no longer render on `/year`). The UX-11 frame is otherwise unchanged; refresh it if the form is re-drawn.

> **Screenshots captured (2026-06-22).** Exported at native resolution via the remote Figma MCP and committed: `tripcard-revamp.png` / `-dark` (`715:1542` / `717:1540`), `year-results-declutter.png` / `-dark` (`716:1540` / `717:1579`). Node ids are the durable pointers.

![TripCard — revamped header (§4.1.3) · Light](./figma/tripcard-revamp.png)

![/year results — decluttered (§4.1.3) · Light](./figma/year-results-declutter.png)

### §4.2 — Fold /trip into /year (mode toggle + selectable export) (added 2026-06-22) — *superseded by [ADR-008](../docs/DECISIONS.md)*

> **Superseded.** The single-break `view=trip` UI these frames drew was **removed** ([ADR-008](../docs/DECISIONS.md)) the same day. The `ModeToggle` and single-break results are gone; the sort + selectable-export affordances moved onto the whole-year results — see the next section. These frames are kept as the historical record of the fold.

The `/trip`-into-`/year` fold ([PROJECT.md §4.2 / §4.2.1](./PROJECT.md), [COMPONENTS.md § ModeToggle / TripSelectionBar / TripCard](./COMPONENTS.md), [VOICE.md §4.22](./VOICE.md), [ADR-007](../docs/DECISIONS.md)). Drawn as standalone **`Pattern · /year single-break + selection (§4.2)`** frames at the bottom of the **Patterns** page — **Light** `722:1540` (x=80) / **Dark** `723:1733` (x=1720, a `clone()` with the `colour` collection pinned to **Dark** `1:1`, so every token cascades). Every fill is bound to the `colour` collection. The pattern composes all the genuinely-new affordances of the fold:

- **`ModeToggle`** (`722:1542`) — the segmented control **"Plan my year" | "Find one trip"** (single-break active), modelled on `BudgetModeToggle` (`surface/sunken` track, active segment on `surface/elevated` + border). Sits above the results.
- **`ResultsHeader` — three sorts** — the sort pill group now reads **"Most efficient"** (active) **· "Longest first" · "By date"** (the new `sort=date`), beside the "Germany 2026 · 10 leave / 3 trips" echo.
- **`TripCard` — selection checkbox (§4.2.1)** — each card carries a leading **checkbox** (card #2 **selected**: accent-filled check + accent card border) instead of the old per-card Add-to-itinerary/Export buttons. The header is date-only; efficiency stays a footer stat (`5.00×`). Strip + footer (Total · Leave · Efficiency) retained from §4.1.3.
- **`TripSelectionBar`** (`723:1722`) — the one selection-scoped action bar: **"2 selected"** + **[Add to itinerary]** (accent) **· [Download .ics]** (outline) **· Clear**, with the quiet note *"Google Calendar adds one trip at a time — use Download .ics for several."* (the Google deep-link shows only at exactly one selection).

> **Screenshots captured (2026-06-22).** Exported at native resolution (760×737) via the remote Figma MCP (`get_screenshot` → `curl`; the `figma.com` asset host is reachable) and committed: `fold-trip-into-year.png` (`722:1540`) and `fold-trip-into-year-dark.png` (`723:1733`). Dark parity confirmed by the export — dark surface, light text, accent toggle/checkbox/button intact. The node ids are the durable pointers; refresh from them when these frames change.

![Fold /trip into /year — single-break + selection (§4.2) · Light](./figma/fold-trip-into-year.png)

![Fold /trip into /year — single-break + selection (§4.2) · Dark](./figma/fold-trip-into-year-dark.png)

### ADR-008 — Whole-year results: sort + 2-up grid (added 2026-06-22)

The single-break UI above was **removed** ([ADR-008](../docs/DECISIONS.md), [PROJECT.md §4.2](./PROJECT.md)); its sort control and selectable-export model moved onto the **whole-year** results, which were re-laid-out as a **two-up grid**. Drawn as standalone **`Pattern · /year results — sort + 2-up grid (ADR-008)`** frames at the bottom of the **Patterns** page — **Light** `728:1540` (x=80, y=42000) / **Dark** `732:1540` (x=1720, a `clone()` with the `colour` collection pinned to **Dark** `1:1`, so every token cascades). Every fill is bound to the `colour` collection.

- **`SortToggle`** — the sort pill group **Sort: "Most efficient" · "Longest first" · "By date"** now sits on the **whole-year** results, beside the "Your year: 4 trips, 52 days off, 30 leave spent." summary. **"By date"** is the active (default) pill ([VOICE.md §4.22](./VOICE.md), [COMPONENTS.md § SortToggle](./COMPONENTS.md)).
- **Two-up grid** — `TripCard`s flow **two per row** (cards #1 and #2), halving the wasted gutter beside the fixed-width calendar.
- **Wide odd card (`TripCard` `wide`)** — card #3, the odd final card, **spans the full width** and switches to a **side-by-side** body: the calendar strip on the left, the Total · Leave · Efficiency stats spread across the right — filling the row instead of re-opening the gutter ([COMPONENTS.md § TripCard](./COMPONENTS.md)).
- **`TripSelectionBar`** — retained unchanged: **"1 selected"** + [Add to itinerary] (accent) · [Download .ics] · [Add to Google Calendar] · Clear.

> **Screenshots captured (2026-06-22).** Exported at native resolution (820×534) via the remote Figma MCP (`get_screenshot` → `curl`) and committed: `year-results-grid.png` (`728:1540`) and `year-results-grid-dark.png` (`732:1540`). Dark parity confirmed by the export. **Deviations (recorded):** (1) card eyebrows (`#N`, `SELECTED · LONGEST BREAKS`) and stat labels are drawn in **Inter**, not the code's `font-mono` — a representative substitution to avoid an unverified mono-font load; the code uses `font-mono`. (2) The calendar is schematic — one colour-coded week row per narrow card, two on the wide card — rather than the full week-stacked `CalendarStrip`; the live component is unchanged.

![/year results — sort + 2-up grid (ADR-008) · Light](./figma/year-results-grid.png)

![/year results — sort + 2-up grid (ADR-008) · Dark](./figma/year-results-grid-dark.png)

### Nav restructure — reorder + rename + promote itineraries (added 2026-06-22)

The primary nav was reordered and relabelled ([VOICE.md §4.11](./VOICE.md), [COMPONENTS.md § TopNav](./COMPONENTS.md)): **Plan year · Plan a year for 2 · Solo trips · Group trips · Calendar**, then the identity slot. "Shared time off" → **"Plan a year for 2"** (`/overlap`); the `AccountMenu` "Itineraries" item is promoted to the nav as **"Solo trips"** (`/solo` — the route moved out of `/account/` to a top-level indexable page, [URL_FORMAT.md §12](./URL_FORMAT.md)); Calendar moves last. Drawn as **`Pattern · TopNav — nav restructure`** on the Patterns page — **Light** `733:1540` / **Dark** `734:1540` (a `clone()` with the `colour` collection pinned to Dark `1:1`). Every fill is bound to the `colour` collection; the active "Plan year" pill sits on `surface/sunken`, inactive items are `text/muted`.

> **Supersedes** the earlier nav label set across the file (the standalone TopNav-mobile frame + assembled per-route navs read "Plan year / Find a trip / Calendar / Shared time off / Group trips"). The new order + labels live in [nav-items.ts](../src/components/layout/nav-items.ts) (the single source `TopNav` + the `NavMenu` mobile popover both render); re-syncing the assembled per-route frames is the standing nav-consistency follow-up. **Deviation (recorded):** the wordmark + nav labels are drawn in Inter (the code uses `font-mono` for the wordmark), and the logomark + avatar are schematic (a square + a circle), not the `BrandMark`/photo art.

> **Screenshots captured (2026-06-22).** Exported (1080×56) via the remote Figma MCP (`get_screenshot` → `curl`) and committed: `nav-restructure.png` (`733:1540`), `nav-restructure-dark.png` (`734:1540`).

![TopNav — nav restructure · Light](./figma/nav-restructure.png)

![TopNav — nav restructure · Dark](./figma/nav-restructure-dark.png)

### §8 — `/g/[id]` per-group OG share card (added 2026-06-22)

The shareable group invite link unfurls with a **per-group** social card, not the
generic site card ([GROUPS.md §8](./GROUPS.md), [BRANDING.md §4.3](./BRANDING.md)) — the
GD-3 viral loop needs the image itself to name the group + organiser. Drawn as
**`opengraph-image · /g/[id] · group invite`** (`739:2`) in the Patterns →
**Brand & assets** section (`471:832`), beside the site-wide `opengraph-image · 1200×630
· TEMPLATE` (`292:482`).

1200×630, brand `surface` `#FAFAF7`, every fill bound to the `colour` collection: the
`roamgap` wordmark in `accent` (top), a `text/faint` "GROUP INVITE" eyebrow, the group
**name** as the `text` headline (Inter Semi Bold 92 — example "Mitaboni 2026"), an
`accent` rule, the §4.14 invite lead "{Organiser} invited you to plan a trip together."
in `text/muted`, and a `text/faint` footer ("roamgap.ike.work · Joining is free — only
the organiser pays"). Single surface — OG cards aren't theme-adaptive, so there is **no
Dark twin** (a forwarded link shows one image regardless of the viewer's theme). The
code realisation is the dynamic `next/og` route `src/app/g/[id]/opengraph-image.tsx`
(+ `twitter-image.tsx`), which renders the **live** group name/organiser from the
anonymous `/preview` and falls back to a generic brand card on a failed fetch.

![/g/[id] group invite OG card](./figma/og-group-invite.png)

### Phase 3 — `EstimatedBadge` (added 2026-06-08)

A muted "Estimated" pill for a holiday whose date is a forward-looking projection (`Holiday.estimated`); see [COMPONENTS.md § EstimatedBadge](./COMPONENTS.md). Built directly in the library:

- **Source component:** node `297:493` — auto-layout pill, full `cornerRadius`, **token-bound** fills (`surface/sunken` background, `border` stroke, `text/muted` label in Geist Medium 11). Token-bound so the dark cascade applies.
- **Inventory:** an `EstimatedBadge section` (header + instance) in **Inventory · Light** (`298:478`, under root `3:2`) and **Inventory · Dark** (`298:482`, under root `228:491`). Both inventory roots were switched to hug-height (`primaryAxisSizingMode = AUTO`, now 2659) so the new section isn't clipped and Light/Dark stay equal-height.
- **Dark parity confirmed:** the dark section renders distinctly from the light one (export byte-diff), i.e. the `colour`-collection cascade reaches it.

> **Screenshot pending.** The PNG under `specs/figma/estimated-badge.png` was **not** captured from the build environment — its network policy blocks the `figma.com` asset host. Refresh it from the live node (`297:493`) on the next manual sync.

### Phase 3 — `DayCell / Estimated` (added 2026-06-09)

The CalendarStrip **estimated-day** state — a holiday projected from last year, not yet granted ([COMPONENTS.md § CalendarStrip](./COMPONENTS.md), `roamgap-backend/specs/HOLIDAY_PROJECTION.md`): holiday colour as a **dashed outline** instead of a solid fill.

- **Source component:** node `305:495` — 40×48 cell, `fills = []`, a 2px **dashed** stroke (`dashPattern [4,4]`) **token-bound** to `day/holiday`, date number + kind letter bound to `text`. Token-bound so the dark cascade applies.
- **Inventory:** a `DayCell · Estimated section` in **Inventory · Light** (`305:498`) and **Inventory · Dark** (`305:503`); dark parity confirmed by export byte-diff.

> **Screenshot pending.** `specs/figma/daycell-estimated.png` not captured here (same `figma.com` host block). Refresh from node `305:495`.

### Phase 3 — Calendar estimated days · `MonthGrid` (added 2026-06-11)

The standalone `/calendar` (`MonthGrid` / `YearCalendar`) gains the **estimated-day** treatment already drawn for the optimizer's `DayCell / Estimated` (node `305:495`): an estimated holiday cell renders the holiday colour as a **dashed outline** (`dashPattern [4,4]`, stroke token-bound to `day/holiday`) instead of a solid fill, keeps its "H" code, and the `YearCalendar` legend gains an **Estimated** key (a dashed-holiday swatch). The token treatment is identical to the existing `DayCell / Estimated`, so no new variable is introduced — only the `Calendar section` frame (`291:493`) needs the estimated cell + legend swatch added.

> **Screenshot pending.** `year-calendar.svg` was **not** refreshed from the build environment (the `figma.com` asset/write host is restricted here, same as `daycell-estimated.png` / `estimated-badge.png`). Add the estimated cell + legend swatch to node `291:493` and re-export on the next manual sync.

### Phase 3 — Calendar overlays surface (added 2026-06-09)

> **Redrawn 2026-06-09 to the unified `observedCalendars` list.** [CALENDAR_OVERLAYS.md](../../backend/specs/CALENDAR_OVERLAYS.md) and [COMPONENTS.md § CalendarOverlayPicker](./COMPONENTS.md) were rewritten to the unified **`observedCalendars`** model, and the node `308:504` frame has now been **rebuilt in Figma to match** — **one list** (no visibility checkboxes), titled *Calendars you observe* with the lead "Your country's are added for you.", a vertical stack of rows separated by `border` hairlines, and an **Add calendar** control in the footer. The Kenya example state shows three rows: **Gregorian (Kenya)** (toggle on "Free day"), **Islamic (Kenya)** (toggle on "Free day"), and **Islamic (worldwide)** (toggle on "Spend PTO", with the sub-line hint "Worldwide date — your country may observe it ±1 day"). Each row = a label + a Free/PTO segmented pill toggle + a remove ✕; the footer is a select-style pill ("Hindu (worldwide)") + an `accent` "Add" button. The screenshot below is the redrawn frame.

The personal-calendar UI from [COMPONENTS.md § CalendarOverlayPicker](./COMPONENTS.md) + the observance / over-budget treatments ([roamgap-backend CALENDAR_OVERLAYS.md §6/§5.1](../../backend/specs/CALENDAR_OVERLAYS.md)). Drawn directly in the live library (Components page `1:4`, the **Phase-3 column** at `x = -700` beside `EstimatedBadge`/`DayCell · Estimated`), every fill/stroke **token-bound** to the `colour` collection so the Light/Dark cascade applies. Geist (Regular/Medium/SemiBold).

- **`DayCell / Observance`** — node `308:499`. 40×48, `fills = []`, a 2px **dotted** stroke (`dashPattern [2,3]`) **bound to `accent`**, with the kind letter `O` in `accent` and the date number in `text`. Deliberately distinct from `DayCell / Holiday` (solid `day/holiday` fill) and `DayCell / Estimated` (**dashed** `day/holiday`): dotted-vs-dashed is the non-colour signal, and the accessible name appends "(personal observance)". This is the `free`-overlay observance day (`DayComposition.overlay === true`).
- **`OverBudgetMarker`** — node `308:502`. An auto-layout pill, full `cornerRadius`, `status/warn` fill + `accent/contrast` "Over budget" label (Geist Medium 11). The honest over-budget marker for `TripCard` / `YearPlanCard` ([CALENDAR_OVERLAYS.md §5.1](../../backend/specs/CALENDAR_OVERLAYS.md)) — surfaced beside the rank eyebrow, never a rejection.
- **`CalendarOverlayPicker`** — node `308:504`. 360-wide `surface/elevated` card, `border` hairline, `radius-12`, redrawn 2026-06-09 to the unified `observedCalendars` list (**no checkboxes**). A padded **header** — *Calendars you observe* (Geist SemiBold 15, `text`) + the lead "Your country's are added for you." (`text/muted`) — then a vertical **rows** stack separated by `border` hairlines, then a footer (divider + **Add calendar** control). Kenya example state, three rows, each = a label (`text`) + a **Free / PTO** segmented pill toggle + a remove ✕ (`text/muted`): **Gregorian (Kenya)** and **Islamic (Kenya)** with the toggle on *Free day*; **Islamic (worldwide)** with the toggle on *Spend PTO* and a full-width sub-line hint (`text/faint`) "Worldwide date — your country may observe it ±1 day". The segmented toggle is a `surface/sunken` + `border` track, fully rounded; the active segment fills with `accent`, carries the `✓` glyph and `accent/contrast` text (non-colour signal, [ACCESSIBILITY.md §3](./ACCESSIBILITY.md)), the inactive segment is `text/muted` only. The footer **Add calendar** control is a `surface/sunken` select-style pill ("Hindu (worldwide)" + a `▾` chevron, FILL-width) beside an `accent` "Add" button (`accent/contrast` label). Copy per [VOICE.md §4.7](./VOICE.md). Long-label wrap was avoided by tightening the toggle padding; the worldwide hint is a full-width `FIXED`-width text line (the FILL→text-thread collapse gotcha — set explicit width, not FILL).

**Dark parity confirmed:** a recursive audit of all three nodes found **zero** unbound solid paints (`boundVariables.color` present on every `SOLID` fill/stroke), so the `colour`-collection cascade reaches every paint — per the "Rule of thumb for new dark frames" above. For the redrawn `308:504` this was verified by cloning the card, setting the clone's `colour` collection mode to **Dark**, and screenshotting — the whole card cascaded correctly (dark surface, light text, accent pills with `accent/contrast` text, visible dividers, no white wrapper blocks); the layout-only wrapper frames had their default white fills cleared to transparent so no block survives into dark (the "spurious white wrapper fills" fix). The clone was deleted afterwards.

> **Screenshots captured (2026-06-09).** `specs/figma/calendar-overlay-picker.png` (`308:504`), `daycell-observance.png` (`308:499`), and `over-budget-marker.png` (`308:502`) were exported from the live file via the remote Figma MCP (`get_screenshot` → `curl`) and committed — the `figma.com` asset host **is** reachable from the current environment, so the earlier block no longer applies. The node ids remain the durable pointers; refresh from them when these frames change.

> **Code realisation (Stage 3 — follows this redraw).** The redrawn `308:504` is now the drawn source for `CalendarOverlayPicker.tsx`: one `observedCalendars` list (card + `border` hairline rows + Free/PTO segmented toggle + remove ✕ + footer Add-calendar control), **no checkboxes**. Any prior `CalendarOverlayPicker.tsx` built against the pre-redraw show-calendars-checkboxes UI must be rebuilt to match this frame. In the planner's **Advanced settings** the picker sits as a card **adjacent to** the `FilterPanel` (`266:478`); this is realised (2026-06-10) as the composed **`Pattern · Advanced settings (Planner)`** frame — Light `386:534` · Dark `388:534` on the Patterns page — with both cards side-by-side under an "Advanced settings" header ([patterns-advanced-settings.png](./figma/patterns-advanced-settings.png) / [dark](./figma/patterns-advanced-settings-dark.png)). Folding it into `Pattern · Home` itself remains an optional follow-up.

> **Calendar overlays propagated to account settings + planner composition (2026-06-10).** The unified `observedCalendars` picker was carried from the source component (`308:504`) into every surface that hosts it: the source `AccountSettingsForm` (`199:444`, replacing the old checkbox clone `314:499` with a unified-picker clone `368:23`), both pattern account-settings forms (Light `211:441` · Dark `219:525` — a Default-calendars section added between Region and Theme, with the DSAR-section frames below shifted down to keep spacing), and a new composed **`Pattern · Advanced settings (Planner)`** frame (Light `386:534` · Dark `388:534`) showing the picker beside the `FilterPanel` (`266:478`). Screenshots refreshed: `account-settings.png`, `patterns-account-settings.png` / `-dark`, `patterns-advanced-settings.png` / `-dark`.
>
> **Token-binding gotcha (read before cloning this picker).** `308:504` was built with **baked dark literal colours** under its variable bindings, so in this file a bound paint renders its *literal*, not the resolved token — a plain clone shows **dark even inside a Light frame**, and a node-level "boundVariables present" audit (the earlier "zero unbound paints" claim) misses it. Fix used here: after cloning, set the clone's `colour` mode to the target (Light/Dark) and **sync each bound paint's literal to `variable.resolveForConsumer(node).value`**, re-applying `setBoundVariableForPaint` where the paint-level binding is stale. That is what makes the Light account-settings clone and the composed frames render correctly.
>
> **Commit-behaviour update.** Contrary to the older index-0 note ([[figma-mcp-commit-behavior]]), writes to **non-index-0** pages committed fine this session — verified by a write-probe rendered via server-side `get_screenshot`, and re-confirmed after restoring page order. No page-reorder was needed; page order is the conventional Cover / Tokens / Primitives / Components / Patterns.
>
> **Node-id note (confirmed 2026-06-04).** The `Node` column lists a representative top-level frame, **not** the page GUID. The page GUIDs are `1:4` (Components) and `1:5` (Patterns); `3:2` is the *Light inventory* root frame on `1:4`, now mirrored by a *Dark inventory* (`228:491`) and a *Source components* column. See [Frames vs components — the Phase-2 account/auth surface](#frames-vs-components--the-phase-2-accountauth-surface) at the foot of this doc.

### CalloutBox + /trip results pattern (added 2026-06-11)

The `CalloutBox` component ([COMPONENTS.md § CalloutBox](./COMPONENTS.md)) — previously code-only — is now drawn, and a `/trip` **results** pattern was assembled to host it, realising the efficiency-sort budget note ([ERROR_STATES.md §2.19](./ERROR_STATES.md) / [VOICE.md §4.10](./VOICE.md)).

- **`CalloutBox` ComponentSet** — node `411:38` on the **Components** page, three `kind=` variants (`info` / `warn` / `success`). Each is a horizontal auto-layout card: `surface/elevated` fill, 1px `border` stroke with `strokeLeftWeight = 0`, `cornerRadius 8`, `clipsContent` — fronted by a 4px **left accent bar** (`STRETCH` height, square corners clipped to the radius) bound to `accent` (info) / `status/warn` (warn) / `status/success` (success), then a 16px-padded content column: title (Geist Medium 14, `text`) over body (Geist Regular 14, `text/muted`). Every fill/stroke is **token-bound** to the `colour` collection so the Dark cascade applies. The `info` variant carries the §2.19 budget-note copy ("Most efficient trips") as its example.
- **Inventory** — a `CalloutBox section` (header + the three instances) in **Inventory · Light** (`412:2`, under root `3:2`) and **Inventory · Dark** (`412:22`, under root `228:491`). Dark parity confirmed by server-side screenshot: dark surface, light text, the coloured bars intact.
- **`Pattern · Find a trip (results)`** — Light `413:513` (x = 0) · Dark `414:2` (x = 1640) on the **Patterns** page. The `/trip` results region: an eyebrow ("FIND A TRIP · GERMANY 2026 · 10 LEAVE"), a results header (`3 trips` at the start, a `Sort: Efficiency | Length` toggle at the end with Efficiency active), the **budget-note** `CalloutBox` (info, "Most efficient trips"), the **lost-to-weekend** `CalloutBox` (info, text overridden per [ERROR_STATES.md §2.15](./ERROR_STATES.md)), and two `TripCard` instances (`3:140` at 5.00 / `3:184` at 3.00 — 1 and 2 leave against the 10-day budget, exactly the headroom case the note explains). The Dark frame sets the `colour` collection mode to Dark; the cascade reaches the instanced TripCards and callouts.

> **Screenshots captured (2026-06-11).** `specs/figma/callout-box.png` (`411:38`), `patterns-trip-results.png` (`413:513`), and `patterns-trip-results-dark.png` (`414:2`) were exported at native resolution (608×404, 704×896, 704×896) from the live file via the remote Figma MCP (`get_screenshot` → `curl`) and committed — the `figma.com` asset host **is** reachable from the current environment, so the earlier block no longer applies. Dark parity confirmed by the dark frame's export: dark surface, light text, the day cells / `ScoreBadge`s / coloured callout bars intact. The node ids remain the durable pointers; refresh from them when these frames change.

### Holiday league — `Pattern · Holiday league` (added 2026-06-12; **drawn + efficiency board 2026-06-17**)

Realises [HOLIDAY_LEAGUE.md](./HOLIDAY_LEAGUE.md) §5/§7/§9 — the cross-country public-holiday comparison page, now the **four**-board layout (the three count-based boards + the optimizer **efficiency** board, §4.4). Drawn directly in the live library via the Figma MCP write path (`use_figma`); every fill/stroke token-bound to the `colour` collection so the Light/Dark cascade applies. Geist (Regular/Medium/SemiBold). Lives in a Figma **Section** — `Holiday league — Light = left · Dark = right` (`589:1262`) on the **Patterns** page, keeping the house Light-left (`x = 80`) / Dark-right (`x = 1640`) convention.

- **`Pattern · Holiday league (Light)`** `584:1228` · **`· Dark`** `588:1245` — a `surface` page frame: an eyebrow ("HOLIDAY LEAGUE"), H1 "Public holiday league 2026", the answer-first lead sentence (naming the most-holidays / best-on-a-workday / highest-leverage leaders), the four board cards, the "How we count" note, and a `Footer` instance.
- **Board cards** (the `LeagueBoard` layout, realized inline as token-bound table frames) — a `surface/elevated` card, `border` hairline, `radius-12`, 24px padding: a SemiBold-15 `text` title, a `text/faint` Medium-12 header row over a `border` divider, then data rows (Geist Regular 14) — rank in `text/muted`, country link-styled in `accent`, numeric columns right-aligned in `text`. **No metric is colour-coded** — rank is the only ordering signal ([ACCESSIBILITY.md §3](./ACCESSIBILITY.md)), so the boards read correctly in both themes without relying on hue.
  - The three **counting** boards (Most public holidays / Best — lands on a working day / Unluckiest — lost to a weekend) share six columns: `# · Country · Total · On a workday · Lost to weekend · Substitutes`.
  - The **efficiency** board ("Best PTO leverage — most days off per leave day") has its own four columns — `# · Country · Leverage · Days off` — and a `text/faint` caption stating the fixed 15-day, Mon–Fri yardstick (§4.4), so the methodology reads on the card itself.
- **Dark parity confirmed:** the Dark mirror is a clone with the `colour` collection mode set to **Dark** (`1:1`) and the `Footer / Light` instance swapped to `Footer / Dark` (`112:54`); the whole frame cascaded (dark surface, light text, the cards on the elevated dark tone, accent country links intact) — verified by the export below.

> **Screenshots captured (2026-06-17).** `specs/figma/holiday-league.png` (`584:1228`) and `holiday-league-dark.png` (`588:1245`) were exported from the live file via the remote Figma MCP (`get_screenshot` → `curl`) and committed. The node ids are the durable pointers; refresh from them when the frames change. *(This clears the 2026-06-12 "draw pending" gap — the `/figma-use` skill loads cleanly now and the official remote MCP write path is reliable, per the memory note.)* A standalone reusable `LeagueBoard` **component** (vs. the inline board frames) can be extracted if the layout is reused elsewhere — a minor, recorded follow-up. The non-visual schema additions (`HolidayLeague*` in `domain.ts`) carry **no** Figma surface.

![Pattern · Holiday league — Light](./figma/holiday-league.png)

![Pattern · Holiday league — Dark](./figma/holiday-league-dark.png)

### Holiday head-to-head — `Pattern · Holiday head-to-head` (added 2026-06-17)

Realises [HOLIDAY_LEAGUE.md §15](./HOLIDAY_LEAGUE.md) — the two-country comparison page (`/holiday-league/[y]/[a]-vs-[b]`). Built by cloning the `Pattern · Holiday league` frame (so the `surface` page shell, eyebrow/H1/lead, "How we count" note, and `Footer` instance carry over token-bound) and replacing the four boards with a single **`VersusTable`** card. Lives in its own Figma **Section** — `Holiday head-to-head — Light = left · Dark = right` (`599:1296`) on the **Patterns** page, Light-left (`x = 80`) / Dark-right (`x = 1640`).

- **`Pattern · Holiday head-to-head (Light)`** `591:1262` · **`· Dark`** `597:1279` — the eyebrow ("HEAD-TO-HEAD"), H1 ("United Kingdom vs Kenya — public holidays 2026"), the answer-first **verdict** sentence (per-metric winners), the `VersusTable` card, the shared "How we count" note, and a `Footer` instance.
- **`VersusTable`** (the card, realized inline like the league boards) — a `surface/elevated` card, `border` hairline, `radius-12`, 24px padding: a SemiBold-15 `text` title, a header row (`text/faint` metric column + the two countries as `accent` link-styled, right-aligned headers) over a `border` divider, then one row per metric — `Public holidays · Lands on a working day · Lost to a weekend · Substitutes · PTO leverage · Days off unlocked`. The **better value per row is bolded** (Geist SemiBold + `text`) while the other stays `text/muted` — a **non-colour** winner cue ([ACCESSIBILITY.md §3](./ACCESSIBILITY.md)); `Lost to a weekend` inverts (lower wins) and a tie bolds neither. A `text/faint` caption states the fixed-15-day leverage yardstick.
- **Dark parity confirmed:** the Dark mirror is a clone with the `colour` collection mode set to **Dark** (`1:1`) and the `Footer / Light` instance swapped to `Footer / Dark`. *(Build gotchas, recorded for next time: the new card's paints had to be **rebound** to the `colour` variables via the full `VariableID:1:N` id — the short `1:N` form silently resolves to `null` in `getVariableByIdAsync`, leaving fallback literals that never cascade — and the auto-layout `Row`/`Header` wrapper frames' **default white fills were cleared** (the §"Dark-theme contrast fixes" #1 bug) so the dark card shows no white bars.)*

> **Screenshots captured (2026-06-17).** `specs/figma/holiday-head-to-head.png` (`591:1262`) and `holiday-head-to-head-dark.png` (`597:1279`), exported via the remote Figma MCP. A standalone reusable `VersusTable` **component** (vs. the inline card) can be extracted if reused — a minor follow-up, mirroring the `LeagueBoard` note above. The `partial` (one country missing) and `estimated` states ([COMPONENTS.md § VersusTable](./COMPONENTS.md)) are a recorded follow-up; the default state is realized.

![Pattern · Holiday head-to-head — Light](./figma/holiday-head-to-head.png)

![Pattern · Holiday head-to-head — Dark](./figma/holiday-head-to-head-dark.png)

### Pricing — `Pattern · Pricing` (added 2026-06-14)

Realises the public `/pricing` page ([PAYMENTS.md §13](./PAYMENTS.md)) — the single paid plan, **roamgap Plus** ([VOICE.md §3](./VOICE.md)). Drawn directly in the live library via the Figma MCP write path (`use_figma`); every fill token-bound to the `colour` collection so the Light/Dark cascade applies. Geist (Regular/Medium/SemiBold).

- **`Pattern · Pricing`** — Light `434:708` (`x = 0`) · Dark `437:726` (`x = 1640`) on the **Patterns** page. A `surface`-filled page frame: an eyebrow ("PRICING"), H1 "One plan. Everything unlocked.", a lead sentence stating the optimizer/planner/calendar stay free and what Plus adds, then the **pricing card** and a `Footer` instance.
- **Pricing card** (`435:708`) — a 540-wide `surface/elevated` card, `border` hairline, `radius-16`, 32px padding. Plan name **roamgap Plus** (Geist SemiBold 22, `text`) over the **price line** — rendered as the neutral placeholder **"Pricing announced soon"** (`text/muted`) because the amount is a still-open business decision, config-driven by `NEXT_PUBLIC_RG_PLAN_PRICE_KES` ([PAYMENTS.md §13](./PAYMENTS.md)); a `border` divider; then the **feature list** — eight rows, each an `accent` `✓` glyph + a `text` label (historical data, multi-country overlap, AI destinations, calendar export, email alerts, cross-device saved searches, cross-device saved itineraries, last-year holiday notes); then the **`Button / Primary`** instance relabelled **"Upgrade"** ([VOICE.md §4.1](./VOICE.md)).
- **Dark parity confirmed:** the Dark mirror is a clone with the `colour` collection mode set to **Dark** (`1:1`) and the `Footer / Light` instance swapped to `Footer / Dark` (`112:54`); the whole frame cascaded correctly (dark surface, light text, the card on the elevated dark tone, the accent Upgrade button intact) — verified by the server-side screenshot below.

> **Screenshots captured (2026-06-14).** `specs/figma/pricing.png` (`434:708`) and `pricing-dark.png` (`437:726`) were exported from the live file via the remote Figma MCP (`get_screenshot` → `curl`) and committed — the `figma.com` asset host is reachable from the current environment. The node ids are the durable pointers; refresh from them when the frames change. When the real price ships, update the card's price line and the page's `Product`/`Offer` JSON-LD gains `offers.price`.

![Pattern · Pricing — Light](./figma/pricing.png)

![Pattern · Pricing — Dark](./figma/pricing-dark.png)

### Calendar export — `ExportCalendarButton` (added 2026-06-15; **second action added 2026-06-16**)

Realises the calendar-export affordance ([CALENDAR_EXPORT.md §5/§7.1](./CALENDAR_EXPORT.md), [COMPONENTS.md § ExportCalendarButton](./COMPONENTS.md)) — now **two** ghost actions over the same `Trip`, both a **reuse of `Button / Ghost`** (the secondary-action treatment, [VOICE.md §4.1](./VOICE.md)), not new variants:

- **"Export calendar"** — the `.ics` download (the original action).
- **"Add to Google Calendar"** — a link to Google's prefilled event composer (CALENDAR_EXPORT.md §7.1; no OAuth), sitting beside the first with the same ghost treatment.

Recorded as an inventory section on the **Components** page: `ExportCalendarButton section` `438:137` (a vertical stack), containing two `Button / Ghost` instances — `438:139` "Export calendar" and `548:48` "Add to Google Calendar". Token-bound, so the Dark cascade applies like every other ghost button.

> **Screenshot refreshed (2026-06-16, both actions live).** `specs/figma/export-calendar-button.png` (`438:137`) re-exported via the remote Figma MCP (`get_screenshot` → `curl`) after drawing the second ghost instance (`548:48`) — the live frame now shows both "Export calendar" and "Add to Google Calendar" stacked. The `438:137` node id is the durable pointer. The actions mount on `TripCard` / the trip-results surface in code.

![ExportCalendarButton — two stacked ghost buttons: "Export calendar" and "Add to Google Calendar"](./figma/export-calendar-button.png)

### Email alerts — `AccountSettingsForm` opt-out toggle (added 2026-06-15; **drawn 2026-06-17**)

Realises the email-alerts opt-out ([EMAIL_ALERTS.md §3](../../backend/specs/EMAIL_ALERTS.md), [COMPONENTS.md § AccountSettingsForm](./COMPONENTS.md)): a single **"Email alerts"** checkbox in the `AccountSettingsForm` — "on" = opted in — governing all roamgap email (new-holiday alerts + renewal reminders). It writes the `emailOptOut` field on the `UserPatch`.

> **Drawn (2026-06-17).** The **"Email alerts"** checkbox row is realised in the source `AccountSettingsForm` (`199:444`, new row node `622:49`) and **both** pattern frames (Light `211:441` — form `211:475`, row `623:1296`; Dark `219:525` — form `219:536`, row `624:1296`), inserted between the **Default calendars** section and the Theme/Save action — matching the live `AccountSettingsForm.tsx`. The row is an `accent`-filled checkbox carrying an `accent/contrast` ✓ (drawn in the default *opted-in* state) + a Geist-Medium **"Email alerts"** label (`text`), over the muted helper "Email me when a country you saved declares a new public holiday, and before your roamgap Plus pass expires." (`text/muted`). Every fill binds to the `colour` collection via the full `VariableID` form and the wrapper frames are explicitly transparent, so the Dark cascade reaches the new row — verified on `219:525` (accent box, light label, muted helper, no white blocks). No new token. Screenshots refreshed: `account-settings.png` (`199:444`), `patterns-account-settings.png` (`211:441`), `patterns-account-settings-dark.png` (`219:525`).

![AccountSettingsForm — the Email alerts checkbox row between Default calendars and Theme/Save (source, Light)](./figma/account-settings.png)

### Payments — gate layer (added 2026-06-15)

Realises the **payments stream Stage-2** surfaces ([PAYMENTS.md §20–§27](./PAYMENTS.md), [COMPONENTS.md § Paid tier (Phase 3)](./COMPONENTS.md)) — the gate layer that **ships dark and gates nothing today**. All drawn directly in the live library via the Figma MCP write path (`use_figma`), every fill token-bound to the `colour` collection so the Light/Dark cascade applies. Geist (Regular/Medium/SemiBold). They live in a new Figma **Section** — `Payments (gate layer) — Light = left · Dark = right` (`444:744`) on the **Patterns** page — keeping the house Light-left (`x = 0`) / Dark-right (`x = 1640`) convention; each Dark mirror is a clone with the `colour` collection mode set to **Dark** (`1:1`) and any `Footer / Light` instance swapped to `Footer / Dark` (`112:54`).

The reused design-system parts: `Button / Primary` (`3:192`), `Button / Ghost` (`3:195`), `Footer / Light` (`112:36`) / `Footer / Dark` (`112:54`), and the `colour` tokens (`surface`, `surface/elevated`, `surface/sunken`, `border`, `text`, `text/muted`, `text/faint`, `accent`, `status/success`, `status/warn`, `status/danger`).

- **`Pattern · Pricing — coming soon`** — Light `447:744` · Dark `448:762`. The §22 **coming-soon mode** — the default today while not selling. A clone of the existing selling `Pattern · Pricing` (`434:708`) re-cast to the honest register: H1 "Everything roamgap does today is free.", a lead naming the free tools and that "roamgap Plus is coming — it will unlock shared itineraries, couple syncing, and AI suggestions", a planned-price line "Planned: KES 500 / month · coming soon", the three Plus-will-unlock features (not the full paid matrix), and — crucially — **no Buy/Upgrade CTA**: the primary button is replaced by a `Button / Ghost` "Notify me when it launches" (§22 — "never a Buy/Upgrade button; there is nothing to buy yet"). The existing selling frame (`434:708`/`437:726`) is unchanged and remains the `NEXT_PUBLIC_RG_PLAN_SELLING=true` layout.

  ![Pattern · Pricing — coming soon — Light](./figma/pricing-coming-soon.png)
  ![Pattern · Pricing — coming soon — Dark](./figma/pricing-coming-soon-dark.png)

- **`PassStatusBanner — states`** — Light `449:780` · Dark `450:780`. The §21.3 app-shell banner (pass state only, never gates), shown as a labelled column of its five visible states, each a token-bound `surface/elevated` row (`border` hairline, `radius-10`) with a leading **status dot** (the non-colour signal is the dot **plus** the copy), the message, an accent action link, and a Dismiss where dismissible: **active-until** (`status/success` dot, "renews {date}"), **cancels-on** (`status/warn`, "ends {date}, resume before then"), **payment-issue** (`status/danger`, "update card"), **price-changing-on** (`status/warn`, the **new** §25 state — "your price changes to {newAmount} on {date}, renew to keep it or cancel before then"), and **expired** (`text/muted`, welcome-back). Precedence and exclusivity per §21.3.

  ![PassStatusBanner — five states](./figma/pass-status-banner.png)

- **`BillingStatusCard — states`** — Light `451:805` · Dark `452:783`. The `/account/billing` hub (§21.4/§24/§25), shown as two representative cards: an **active + price-change-pending** card (status pill, "Card · renews {date} · in N days", the current price line, the amber §25 price-change line, and the **Cancel** + **Update card** `Button / Ghost` controls) and a **non_renewing** card (the cancels-on copy "you keep access until then" and the **Resume** `Button / Primary`). The §24 Cancel/Resume controls and §25 price-change line are the two items §27 flags for this card.

  ![BillingStatusCard — active+price-change and non_renewing](./figma/billing-status-card.png)

- **`UpgradePrompt · RenewPrompt · PaidGate`** — Light `453:803` · Dark `454:789`. The three locked-state surfaces as a card row: **`UpgradePrompt`** (never-subscribed — plan + price + `Button / Primary` "Upgrade"; "your free tools keep working"), **`RenewPrompt`** (lapsed — the retained-data assurance "your saved trips are safe", price, `Button / Primary` "Renew"), and **`PaidGate / locked`** (the entitlement boundary, drawn with a dashed border to read as a gate; "renders the paid content when entitled, otherwise an Upgrade/Renew prompt — and issues no paid request while locked. Built dark: wraps nothing today.").

  ![UpgradePrompt · RenewPrompt · PaidGate](./figma/upgrade-renew-paidgate.png)

- **`Pattern · Checkout`** — Light `455:792` · Dark `456:811`. The §26 transactional page pattern (account-only, `noindex`, no twin/JSON-LD/sitemap/llms — stated in the hero lead), with its three states as cards: **sign-in-gated** (`Button / Primary` "Sign in"), **starting** ("Starting checkout… taking you to our secure checkout, pick card or M-Pesa there", with a partial-arc spinner), and **error** (`status/danger` border, "Couldn't start checkout", `Button / Ghost` "Try again"). A `Footer` instance closes the frame.

  ![Pattern · Checkout — three states](./figma/checkout.png)

- **`Pattern · Checkout · Confirm`** (added 2026-06-22) — Light `748:1540` · Dark `751:1560`. The §26.1 **payment-confirmation flow** the payer lands on after WiraPay's hosted checkout (the `returnUrl` is now `/checkout/confirm`, replacing the old dead-end `/checkout?return=1` that read a non-existent `status` param). A clone of `Pattern · Checkout` re-cast as a **2×2 grid of the four states** (the states row set to `layoutWrap: WRAP`, 1064-wide, so it never collides with the Dark mirror at `x = 1720`): **verifying** ("Confirming your payment…", partial-arc spinner), **confirmed** (`Button / Primary` "Start planning" — "You're on roamgap Plus."), **received** (the honest holding state for a server not-yet-entitled — "Payment received… activating shortly", neutral `border`, `Button / Ghost` "View billing"), and **not-completed** (the only **`status/danger`**-bordered card — "Payment wasn't completed", `Button / Ghost` "Try again"). The hero note states the W2 rule: it reads WiraPay's advisory `wirapay_status` hint but confirms entitlement from the server, never the query param. Dark is the clone + `colour` mode `1:1` + `Footer / Dark` (`112:54`) swap; the section (`444:744`) was grown to `h≈4228` to contain the new pair.

  ![Pattern · Checkout · Confirm — four states (Light)](./figma/checkout-confirm.png)
  ![Pattern · Checkout · Confirm — four states (Dark)](./figma/checkout-confirm-dark.png)

- **`Pattern · Billing — plan comparison`** (added 2026-06-26) — Light `828:1580` · Dark `828:1746`. The `PlanComparison` perks card ([COMPONENTS.md §PlanComparison](./COMPONENTS.md)), reused **under the status card on `/account/billing`** and **above Continue on `/checkout`**. Two columns: **Free — no account needed** (`PLAN_FREE_TODAY`, • bullets) and **roamgap Plus** (✓ accent). Honest while dark (PAYMENTS §20/§22): the Plus header carries a **"Coming soon"** pill, the price reads **"Planned: KES 500 / month"**, and the column lists `PLAN_COMING_FEATURES` — no buy CTA inside. Dark is the `clone()` + `colour` mode `1:1`. Screenshots: [`billing-plan-comparison.png`](./figma/billing-plan-comparison.png) / [`-dark`](./figma/billing-plan-comparison-dark.png).

  ![Pattern · Billing — plan comparison (Light)](./figma/billing-plan-comparison.png)

> **Screenshots captured (2026-06-15; `Checkout · Confirm` 2026-06-22)** via the remote Figma MCP (`get_screenshot` → `curl`); the node ids above are the durable pointers — refresh from them when the frames change. When the real price ships and `NEXT_PUBLIC_RG_PLAN_SELLING` flips to `true`, the coming-soon frame is superseded by the existing selling `Pattern · Pricing`; the banner/card price lines and the `/pricing` `Product`/`Offer` JSON-LD pick up the configured amount with no Figma change.

### Multi-itinerary — `ItinerariesList` + `ItinerariesPanel` (added 2026-06-15; **inline rename drawn 2026-06-17**)

Realises the multi-itinerary management surface ([COMPONENTS.md § Itinerary components](./COMPONENTS.md), [ITINERARIES.md §2/§4/§5.8](../../backend/specs/ITINERARIES.md)) — the saved-itineraries list (the `/solo` tool, formerly `/account/itineraries`), the draft composer, the per-itinerary editor, and the free-cap upsell. The list now sits inside the indexable `/solo` page (below); these row/state nodes are unchanged. It is almost entirely a **reuse** of already-drawn nodes; the one genuinely-new affordance (inline rename) is flagged for a focused pass below. No new token is introduced.

The reused, already-drawn parts (the visual contract is these nodes — no redraw):

- **`ItinerariesList` rows** clone **`SavedSearchesList`** (`148:2`) one-for-one — the same `surface/elevated` card row (`border` hairline, `radius-10`), a truncating **name** in `text` (Geist Medium), and the same two-step **delete** (trash icon → inline "Delete this itinerary?" confirm → deleting spinner). The only row-content change is the meta line: a trip count + the honest distinct days/PTO summary, in place of the saved-search mode badge + query line. Empty / loading / error states reuse the same frame's states (the skeleton rows, the `ErrorState`, and an `EmptyState` reading "No saved itineraries yet.").
- **`ItinerariesPanel` page** clones the **`Pattern · Saved searches`** composition (`186:424` Light / `219:451` Dark): `TopNav` + the `AccountMenu` avatar slot + heading + the list — with the existing `ItineraryPanel` draft composer (aggregate header + `OverlapWarningBanner` + selection list + `SaveItineraryButton`) mounted above the list as the "current draft / New itinerary" affordance.
- **At the free cap (2 saved itineraries)**, the save / "New itinerary" affordance renders the already-drawn **`UpgradePrompt`** (`453:803` Light / `454:789` Dark) — the [PAYMENTS.md §20.1](./PAYMENTS.md) first-real-quota trigger — never blocking reads or deletes of existing rows.

> **Drawn (2026-06-17).** The net-new **inline rename** affordance is realised in a dedicated **`ItinerariesList`** showcase frame (`625:49`, Components page Source column) — cloned from `SavedSearchesList` (`148:2`) so the `surface/elevated` card rows, `border` hairlines, and two-step delete carry over token-bound (the prior "concurrent edit" risk cleared once that session was paused). It shows the full state set: **default** (name + the "N trips · X days off · Y PTO" meta line — no mode badge, per [COMPONENTS.md §Itinerary components](./COMPONENTS.md) — with a `text/muted` **pencil** rename button mirroring the trash button's 36×36 geometry, clustered beside it at a 4px gap), **renaming** (the row swaps to an `Input / idle`-style field + an `accent` **Save** + a bordered **Cancel**, matching `ItinerariesList.tsx`), **inline-confirm delete** ("Delete this itinerary?"), **deleting** (spinner), **empty** ("No saved itineraries yet."), and **loading** skeleton. The pencil glyph is the code's `PencilIcon` path as a vector, stroke-bound to `text/muted`. Reuses `text-muted`, the idle `Input`, and the ghost/accent button treatments — no new token. Screenshot: `itineraries-list.png` (`625:49`).

![ItinerariesList — default row (pencil + delete), the inline-rename editing state (Input + Save + Cancel), inline-confirm delete, deleting, empty, and loading](./figma/itineraries-list.png)

### Itinerary detail — read view + per-Trip "Where to go" slot (added 2026-06-17)

Realises the enriched saved-itinerary detail page ([COMPONENTS.md § Itinerary components](./COMPONENTS.md) "Saved-itinerary detail", [PROJECT.md §7](./PROJECT.md), [ITINERARIES.md §5.6](../../backend/specs/ITINERARIES.md)) — the `/solo/[id]` page (formerly `/account/itineraries/[id]`) that previously rendered each pinned Trip as a bare ISO text row.

**Drawn (2026-06-17) — Light + Dark, every fill bound to the `colour` collection.** Two frames on the **Patterns** page: **`Pattern · Itinerary detail (Light)`** (`611:2`) and its Dark twin **`Pattern · Itinerary detail (Dark)`** (`611:101` — a `clone()` with the `colour` collection mode set to Dark via `setExplicitVariableModeForCollection`, so every token cascades; verified in-session). Open: [Light](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2?node-id=611-2) · [Dark](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2?node-id=611-101). No new token.

The frame composes, top to bottom:

- **Page chrome** — "← Back to itineraries" (`accent`), the itinerary name "Kenya 2026" with **Rename** + **Delete** (`status-danger`) ghost buttons, and the "2 trips · saved …" meta (`text/muted`). Same row register as `ItinerariesList`.
- **`AggregateHeader`** — a `surface/elevated` card (`border` hairline, `radius-12`): the 3-col **Days off / Leave / Efficiency** `<dl>` (the "Leave" stat is the §3.2 per-market term — "PTO" at en-US). Shown for 2+ Trips; a single-Trip itinerary omits it (the lone card's own totals are the itinerary's). The "shared days counted once" disclosure appears only when `skewedByOverlap`.
- **Two `ItineraryTripCard`s** — each a `surface/elevated` card: a header (human date range + a `score-*` `ScoreBadge`), the **`CalendarStrip`** (W/H/P `DayCell`s bound to `day-weekend` / `day-holiday` / `day-pto`, date number + kind letter + weekday foot-label — the non-colour signal per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md)), the **composition summary** line ("2 weekend · 1 public holiday · 1 leave · Includes Eidul Fitr", `text/muted`), the Total / Leave stats, then the AI slot.
- **AI "Where to go" slot** — a `surface/sunken` block (`border` hairline, `radius-8`): a `text` Medium "Where to go" heading over a `text/muted` "AI destination suggestions are coming." line, **no CTA** (the honest register — nothing to generate or buy yet). It hosts the Phase-5 recommendation ([PROJECT.md §7](./PROJECT.md), [AI_PROMPTS.md](./AI_PROMPTS.md)) — destination countries → towns → accommodation style → why — each carrying the [VOICE.md §5.3](./VOICE.md) disclaimer, keyed **per Trip**.

> **PNG snapshot not committed — blocked by network egress, not deferred drawing.** The drawing is complete and live at the node ids above. The `specs/figma/itinerary-detail-*.png` files are **not** committed because this container's egress allowlist excludes the Figma asset host (`curl www.figma.com/api/mcp/asset/… → "Host not in allowlist"`), so the rendered PNG cannot be downloaded here. This is an infra limit on exporting a *copy*, not on the drawing. To add the repo PNGs, allowlist `www.figma.com` for the session (or export from the desktop app) and re-run the screenshot step.

### Solo trips — `/solo` page (added 2026-06-22)

Realises the **`/solo`** page ([COMPONENTS.md § Solo trips](./COMPONENTS.md),
[URL_FORMAT.md §12](./URL_FORMAT.md)) — the itinerary builder promoted out of the
`noindex` account surface to a top-level, **indexable** feature page, the single-author
sibling of `/groups`. The genuinely-new affordance is the **explainer header** that
fronts the tool (the "one route, two faces" pattern); the saved-itineraries list it sits
above is the already-drawn surface (above; `625:49`). No new token.

**Drawn (2026-06-22) — Light + Dark, every fill bound to the `colour` collection.** Two
frames on the **Patterns** page: **`Pattern · Solo trips (/solo) · Light`** (`770:1580`)
and its Dark twin **`Pattern · Solo trips (/solo) · Dark`** (`773:1580` — a `clone()`
with the `colour` collection mode set to Dark via `setExplicitVariableModeForCollection`,
so every token cascades). Open: [Light](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2?node-id=770-1580)
· [Dark](https://www.figma.com/design/gzgZd6XTnFVwHHbPxKuac2?node-id=773-1580). Cloned from
**`Pattern · Groups (Light)`** (`567:1058`) so the header register (eyebrow → H1 → lead →
numbered HowTo steps → "Your …" list → footer) and every token binding carry over
one-for-one — the deliberate visual parallelism with `/groups`.

The frame composes, top to bottom:

- **Explainer header** — an `text/faint` eyebrow **"SOLO TRIPS · ROAMGAP"**, the `text`
  H1 **"Plan your own trips"**, and a `text/muted` lead (pin breaks → honest cost →
  save → reminders; "Free to start — your draft lives in this browser until you sign
  in"). Same type ramp as the `/groups` header.
- **Four numbered HowTo steps** — `surface/sunken` number badge + a `text` Medium title
  over a `text/muted` line: **Find trips you like · See the true cost · Save it to your
  account · Get a nudge before you go**. Mirrors the JSON-LD `HowTo` emitted by the page.
- **"Your itineraries"** section heading (`text`, Geist Medium) over the saved list:
  two `surface/elevated` rows (`border` hairline) — a truncating **name** + the honest
  **"N trips · X days off · Y PTO"** meta line, and on the right the **pencil + trash**
  rename/delete icons (the `PencilIcon`/`TrashIcon` paths as vectors, **stroke-bound to
  `text/muted`** so they carry Dark) — **no status pill** (the one row-content departure
  from the `/groups` rows, which carry an Active/Paused pill).
- **Footer** — an `accent` **"Find trips"** button (the on-ramp into `/year`, since
  itineraries are built from a search) over the `text/faint` honesty note "Free accounts
  keep two itineraries — roamgap Plus is unlimited. Drafts stay in this browser."

![Pattern · Solo trips (/solo) — explainer header + saved itineraries list (Light)](./figma/patterns-solo.png)
![Pattern · Solo trips (/solo) — explainer header + saved itineraries list (Dark)](./figma/patterns-solo-dark.png)

### Multi-country overlap — components + `/overlap` screen (added 2026-06-16)

Phase 4 Stage 2 ([MULTI_COUNTRY.md §11](./MULTI_COUNTRY.md), [COMPONENTS.md § Multi-country overlap](./COMPONENTS.md), engine [OVERLAP.md](../../backend/specs/OVERLAP.md)). Four token-bound components in the Components page **Source** column, composed into a `/overlap` route screen in a new **Multi-country overlap (Phase 4)** Patterns section (`518:1042`). No new token — the three heatmap states reuse `status/success` (free-all) / `status/warn` (pto-some) / `status/danger` (pto-all), plus `surface*` / `border` / `text*`.

- **`OverlapLegend`** (`507:48`) — the 3-state colour key + party list. `specs/figma/overlap-legend.png`.
- **`OverlapResultCard`** (`509:48`) — span + "days off" + combined PTO + a coloured day strip (the Madaraka example: green·green·amber) + per-party PTO rows. `specs/figma/overlap-result-card.png`.
  - **UX-3 (2026-06-19) — Add to itinerary.** A left-aligned **`+ Add to itinerary`** accent action (new text node `663:49`, bound to `accent`) sits at the bottom of the card below the per-party rows. Drawn on the **component master** (`509:48`), so it propagates to both Light + Dark `/overlap` instances and to the group view (`GroupOverlapResult` reuses the same component); the `accent` binding carries Dark. The card stays presentational — the action only renders when the host passes `onAddToItinerary`, and reads **"Added"** (muted, disabled) once the window is in the draft (`addedToItinerary`). PNG export stays egress-blocked (`www.figma.com` not allowlisted, HTTP 403) — `663:49` is the durable pointer; `overlap-result-card.png` re-export pending the allowlist.
- **`OverlapCalendar`** (`510:48`) — a representative June-2026 month heatmap (wrapped 7-col grid; the app renders all 12). `specs/figma/overlap-calendar.png`.
- **`OverlapPartyEditor`** (`512:48`) — party rows (name + country/PTO chips + remove); the **"You" row carries a per-party "Also off on" sub-row** — a removable extra-calendar chip + a dashed `+ country` pill — the dual-calendar / "best of both" affordance ([MULTI_COUNTRY.md §4.1](./MULTI_COUNTRY.md), [OVERLAP.md §5.7](../../backend/specs/OVERLAP.md)); dashed "Add person" (1–6 people). `specs/figma/overlap-party-editor.png`.
  - **UX-2 (2026-06-19) — per-entry region.** The `Germany` alsoObserves chip (`524:5`) now reads **`Germany  Bavaria ▾ ×`**: an **inline muted region affordance** (new text node `660:49`, bound to `text/muted`) sits between the country and the remove ×, so an extra calendar can be refined to a subdivision exactly as the main `CountryPicker` pairs Country + Region. Drawn on the **component master** (`512:48`), so it propagates to both the Light and Dark `/overlap` instances and to `MemberAvailabilityEditor` (which reuses this party-row); the `text/muted` binding carries the Dark mode. PNG export stays egress-blocked (`www.figma.com` not allowlisted, HTTP 403) — `660:49` is the durable pointer; `overlap-party-editor.png` re-export pending that allowlist.
- **`Pattern · Overlap (Light)`** (`514:830`) — top bar + "When can you both be off?" + editor/legend (left) + ranked cards/heatmap (right). The editor instance shows the dual-calendar sub-row (subtitle "1–6 people — add a 2nd country, best of both"). `specs/figma/patterns-overlap.png`.
- **`Pattern · Overlap · Dark`** (`536:2`) — the Dark twin in the same section's right slot (the standard Light-left / Dark-right pairing). Dark surface, light text, status colours (`success`/`warn`/`danger`) hold across the mode flip. `specs/figma/patterns-overlap-dark.png`.

> **Dual-calendar pass (2026-06-16).** `OverlapPartyEditor` (`512:48`) — and the Light `Pattern · Overlap` instance that embeds it — were updated to show the per-party `alsoObserves` affordance: the **"Also off on" sub-row** (removable extra-calendar chip + dashed `+ country`), with the subtitle relaxed to "1–6 people — add a 2nd country, best of both". This is the "best of both" / remote-worker direction ([MULTI_COUNTRY.md §1/§4.1](./MULTI_COUNTRY.md), [OVERLAP.md §5.7](../../backend/specs/OVERLAP.md)). Screenshots `overlap-party-editor.png` + `patterns-overlap.png` refreshed. The dark `/overlap` frame is now built too (below).

> **Dark page — built (2026-06-16, resolving the earlier deferral).** The prior dark-clone failure was root-caused: **26 layout-wrapper frames** (the pattern's `Content`/`Heading`/`Body`/`Left`/`Right` and the components' internal `Header`/row/cell/`Day grid` frames) carried Figma's **default unbound white fill** — invisible white-on-white in Light, but **white boxes** once the colour mode flips to Dark (the "white-boxed text" symptom). Fix: **clear those stray fills** (`node.fills = []`) on the four components + the Light pattern's own wrappers — a Light-mode no-op (the bound surfaces show through; it actually improves Light, since cards now read as elevated on the base surface) that makes the components genuinely dark-ready. Then a plain `light.clone()` + `setExplicitVariableModeForCollection(colour, "1:1")` produced a correct **`Pattern · Overlap · Dark`** (`536:2`) — **no** per-node `resolveForConsumer` re-bake needed. The lesson: the cascade quirk was unbound default fills, not a binding-resolution problem. `specs/figma/patterns-overlap-dark.png`.

### Groups (Phase 4½) — components + `/groups` & `/g/[id]` screens (added 2026-06-16)

Phase 4½ Stage 2 ([GROUPS.md §5/§11](./GROUPS.md), [COMPONENTS.md § Groups (Phase 4½)](./COMPONENTS.md)). Groups is the social layer over the overlap engine, and **visually it is mostly reuse** — the computed result reuses the Phase-4 overlap surfaces, the create gate reuses the payments gate layer, the availability form reuses the overlap party-row, and the group list reuses the saved-searches/itineraries row. No new token (the three heatmap states stay `status/success` / `status/warn` / `status/danger`; everything else is `surface*` / `border` / `text*` / `accent`).

**Reuse map (already-drawn nodes — no redraw):**

- **`GroupOverlapResult`** = the Phase-4 `OverlapResultCard` (`509:48`) + `OverlapCalendar` (`510:48`) + `OverlapLegend` (`507:48`), rendered over the group's `GET /groups/:id/overlap` output. Identical visual contract.
- **`MemberAvailabilityEditor`** = one party-row of `OverlapPartyEditor` (`512:48`) — the country/PTO chips + the **"Also off on"** dual-calendar sub-row — extracted for a single self-entered party. `CountryPicker` / `SubdivisionPicker` / the PTO + workweek inputs are the existing input components.
- **The Create gate** = the existing `PaidGate` / `UpgradePrompt` / `RenewPrompt` row (`453:803` Light / `454:789` Dark). Only the organiser's **Create group** action is wrapped; members never see it. The lapsed-organiser state reuses `RenewPrompt` (organiser) + a plain info banner (member).
- **`GroupsPanel` rows** clone the `SavedSearchesList` (`148:2`) / `ItinerariesList` row treatment (`surface/elevated` card row, `border` hairline, `radius-10`, name + meta line + role badge + status chip).

> **Drawn (2026-06-16) — Light + Dark, every fill token-bound to the `colour` collection.** The genuinely-new, self-contained Groups frames are realised in the design file (file key `gzgZd6XTnFVwHHbPxKuac2`). Each Dark frame is a clone of its Light twin with the `colour` collection mode set to Dark (`setExplicitVariableModeForCollection`), so every token cascades — **including the reused overlap instances + Group-card clones nested inside the workspace pattern** (the §"Multi-country overlap — dark page" lesson, verified: zero unbound fills). The four component frames sit on the **Components** page (Source-components column, below the overlap set); the two pattern frames in the **Patterns → "Groups (Phase 4½)"** section (`551:1058`), Light-left (`x≈80`) / Dark-right (`x≈1640`).
>
> **Components page reorganised (2026-06-19).** The Source-components column had grown hard to trace, and the UX-5 additions (the grown `GroupInvitePanel` + the new approval-mode frames) had collided into overlaps. It is now laid out as six labelled sections — **Layout & chrome · Search & itineraries · Account & data (DSAR) · Calendar & optimizer · Overlap · Groups** — each a row-wrapped grid with every Light/Dark twin kept adjacent, uniform gutters, and **zero overlaps** (verified programmatically). Node ids are durable, so the references below are unaffected; the **Patterns** page was left as-is. Screenshots in `specs/figma/`:

- **`GroupInvitePanel`** (`group-invite-panel.png`; `559:49` Light / `559:58` Dark) — the `/g/[id]` preview card for non-members: group name (`text/xl`), `organiserLabel` + "invited you", a member-count line (`N of 6`), the `year`, then — for a **joinable** group (UX-5a, 2026-06-19) — a value-prop: the lead *"See when everyone can be off."* (`text`), a muted explanation paragraph *"roamgap finds the days you can all take time off together — counting each person's public holidays and leave, even across different countries."* (`text/muted`, new nodes `666:49` Light / `666:51` Dark), and *"Joining is free."* (`text`, new nodes `666:50` Light / `666:52` Dark) — value-only, no urgency/upsell (VOICE §4.14); then the primary CTA (`Button / Primary` "Sign in to join" / "Join this group"); a footnote names the `full` ("This group is full (6 people).") and `closed` status lines (those states drop the value-prop + CTA). Public-safe only — **no** member identities/budgets ([GROUPS.md §5.2/§7](./GROUPS.md)).
- **`GroupCommentThread`** (`group-comment-thread.png`; `561:49` Light / `561:69` Dark) — a vertical list of comment rows (author label in `text` Medium, body in `text`, relative time in `text/faint`, a `text/muted` Delete for own/organiser), a removed row reading *"comment removed"* (`text/faint` — the `Geist` library has no Italic cut, so it is not italicised), and a composer (`Input`-style field + `Button / Primary` "Comment"). Not realtime — no live/unread affordances ([GROUPS.md §8](./GROUPS.md), [VOICE.md §4.14](./VOICE.md)).
- **`GroupDatePoll`** (`group-date-poll.png`; `575:49` Light / `575:77` Dark) — candidate-window rows, each a date-range label + an approval checkbox + a tally bar (`accent` fill on a `surface/sunken` track) + "N of M members", the leading option tagged **"Group favourite"** (`status/success` pill on its own line). **Member-proposable** (GROUPS.md §9): a member-proposed window carries a `text/faint` *"Suggested by {name}"* line (organiser-set windows are unattributed), and a dashed-accent **"Suggest a date"** affordance sits below the options (any member). A `text/faint` footnote names the honest "may use more leave" hint. Approval voting ("pick more than one") ([GROUPS.md §9](./GROUPS.md)).
- **`GroupRoster`** (`group-roster.png`; `560:49` Light / `560:79` Dark) — member rows: `label` (+ a "You" pill on self) + `country` + a role badge (Organiser/Member); the organiser-only **Remove** (`text/faint`). **Never** another member's PTO budget ([GROUPS.md §7](./GROUPS.md)).
- **`PendingRequestsPanel`** (`pending-requests-panel.png`; `668:49` Light / `668:65` Dark) — **UX-5b (2026-06-19)**. The organiser's approval queue for an `approval` group: a "Requests to join (N)" heading + one row per applicant (`label · country · N days PTO`, `text`) with an accent **Approve** (`Button / Primary`) + a secondary bordered **Reject**. Composed from the Groups card + button tokens; the Dark twin is the Light clone with the `colour` collection's Dark mode ([GROUPS.md §4.2](./GROUPS.md); [backend §5.10/§5.11](../../backend/specs/GROUPS.md)).
- **`RequestSent`** (`669:49` Light / `669:58` Dark) + **`JoinPolicyPicker`** (`669:52` Light / `669:61` Dark) — **UX-5b**. The applicant's "Request sent" acknowledgement card (heading + muted body) and the `CreateGroupForm` **"Who can join"** picker (label + `open`/`approval` select + hint). The `GroupInvitePanel` CTA reads **"Request to join" / "Sign in to request"** for an `approval` preview — a data-driven label variant of the drawn panel (`559:49`/`559:58`). (Node ids recorded; the canonical Groups screenshot is `pending-requests-panel.png`.)
- **`Pattern · Groups`** (`patterns-groups.png` / `-dark`; `567:1058` Light / `568:1076` Dark) — the `/groups` hub: the anonymous **explainer** (eyebrow "GROUPS · ROAMGAP PLUS", H1, the four-step story) and the signed-in **list** (`GroupsPanel` rows + the `PaidGate`-wrapped **Create group** entry + the "one organiser pays" note). The indexable feature surface ([GROUPS.md §4.1/§8](./GROUPS.md)).
- **`Pattern · Group workspace`** (`patterns-group-workspace.png` / `-dark`; `569:1058` Light / `576:1256` Dark) — the member `/g/[id]` view: `MemberAvailabilityEditor` + `GroupRoster` (left) + `GroupOverlapResult` (the reused `OverlapResultCard` / `OverlapLegend` / `OverlapCalendar` **instances**) + `GroupCommentThread` + `GroupDatePoll` (right, with the member "Suggest a date" affordance), with the "Copy invite link" action and organiser-only controls (rename / close / delete). `noindex`.

The **schema changes** (the `Group*` `domain.ts` family, and the export-only `Group.authoredComments` field added with the UI build for the DSAR bundle, GROUPS.md §12) are a Figma **no-op** — non-visual, recorded per [AGENTS.md §1](../AGENTS.md).

### File organization (canvas layout)

The file is laid out for **Light/Dark side-by-side** comparison, so drift between themes is visible at a glance:

- **Patterns** — a single vertical column of labelled Figma **Sections**, each holding its screens Light-left (`local x = 80`) / Dark-right (`local x = 1720`). Top to bottom: **Planner & Marketing** (Home, How it works), **Trip finder & planner settings** (Find-a-trip hero + results L/D, Advanced settings L/D), **Pricing** (L/D), **Payments (gate layer)** (the five gate-layer surfaces, L/D), **Account & Auth (DSAR)** (Sign in, Saved searches, Account settings, Download/Delete/Restore), **Marketing & system pages** (About, 404), **Legal** (Privacy), and **Brand & assets** (the brand lockups + OG template). A page note at the top states the Light-left / Dark-right convention.
- **Components** — three labelled columns: **Inventory · Light** (the `Components root`, `3:2`), **Inventory · Dark** (the `Components root · Dark` mirror, `228:491` — the same root with the `colour` collection set to Dark, so every token-bound fill cascades), and **Source components** — the reusable component/variant definitions instances reference (incl. the `Footer / Dark` and `ThemeSwitch / Dark` variants, the Phase-3 atoms, and the `CalloutBox` set), repacked into a tidy shelf grid. The `Calendar` showcase (`291:493`, MonthGrid + YearCalendar) sits below the Light inventory with its own header.
- **Primitives** — the atomic interaction-state showcase (`Primitives page` frame, `483:2`): a **Button — states** card (Button / Primary and Button / Ghost across default / hover / pressed / focus / disabled) and an **Input — states** card (idle / focused / error / disabled). Every fill and stroke is token-bound, so the states are dark-ready. See [primitives.png](./figma/primitives.png).

> **Maintenance note.** Earlier MCP-probe debug frames (`NOSWITCH_TEST`, `SWITCH_TEST_MARKER`, `RESTART_WRITE_PROBE`, `PLUGIN_WRITE_PROBE`) were removed from the Components page during this tidy. Figma **Sections** offset their children by the section origin (children use section-relative coordinates, like frames do) — set child `x`/`y` relative to the section after `appendChild`, not page-absolute, or frames land far below where expected.

> **Tidy pass (2026-06-15) — hand-off cleanup.** The file was reorganised for sharing with external designers. **Cover** rebuilt as a presentation cover (accent bar, `roamgap` wordmark, headline, governance lead, metadata, numbered "What's inside" index); the stray `Pattern · Find a trip (results) · Dark` frame that had been left on the Cover page was moved back to Patterns beside its Light twin. **Components** — `Source components` repacked into a tidy shelf grid, the off-canvas Phase-3 atoms (`x=-700`) and `CalloutBox` set (`x=-2000`) reclaimed into it, and the `Calendar` showcase moved out of its overlap with the Light inventory to sit beneath it. **Patterns** — all previously-loose route screens filed into the eight-Section vertical stack above, the early `panel · expanded` FilterPanel mock at `(0,0)` deleted, and the `Payments (gate layer)` Section compacted (its content had a ~12,800 px empty band above it). **Primitives** drawn (Button + Input states). Refreshed screenshots: [cover.png](./figma/cover.png), [primitives.png](./figma/primitives.png); the per-component/per-pattern screenshots are unchanged (only their page arrangement moved).

### Dark-theme contrast fixes

The first dark pass exposed three classes of theming bug, all now fixed. The lesson: **the dark cascade only works for fills that are bound to the `colour` collection** — any hardcoded colour silently survives into dark mode.

1. **Spurious white wrapper fills.** Layout-only frames (`field/*` label+input wrappers, `actions` button rows) carried a hardcoded `#ffffff` fill instead of being transparent. Invisible on the light card, but a glaring white block on the dark card — this is what made the email field and the "Keep deletion" Ghost button look broken. Fixed by clearing the fill (`fills = []`) on every such wrapper across the Patterns **and** Components pages (the inner `input` child and the buttons were already token-bound). Affected: Sign in, Account settings, Download my data, Delete my account, Restore account.
2. **Legal (privacy) dark was actually light.** The `How it works` / `Legal` doc frames were built with hardcoded hex rather than variable bindings, so cloning + setting Dark mode did nothing — the "dark" legal frame rendered light. Fixed by binding its content fills/strokes to the `colour` variables (by matching each hex to its token) and swapping its `Footer` instance to `Footer / Dark`; it is now a genuinely token-bound dark frame. The same rebind was then applied to **`How it works · Dark`** (`156:463`, previously a hand hex-remapped clone) and to both **light** doc frames (`How it works`, `Legal`) — so the entire doc-pattern family is now token-bound in both themes, not just visually correct. Light is the collection's default mode, so the light frames are unchanged visually.
3. **Wrong-theme component instance.** The dark Account settings frame embedded a `ThemeSwitch / Light` instance; swapped to `ThemeSwitch / Dark`.

A final audit confirms **zero** unbound solid fills in frame-level content across all dark frames. The only remaining hardcoded colours live inside the per-variant `Footer / Dark` and `ThemeSwitch / Dark` components — intentional, since those are built as separate Light/Dark component variants rather than one token-cascading component.

> **Rule of thumb for new dark frames:** never recolour by editing hex. Bind every fill to the `colour` collection, then set the frame's mode to Dark and let it cascade. Audit a cloned dark frame for any paint where `boundVariables.color` is null — those are the ones that won't theme.

---

## Variable collections

| Collection | Modes | Variables | What's bound to it |
| ---------- | ----- | --------- | ------------------ |
| `colour` | `Light`, `Dark` | 22 semantic tokens | Every fill, stroke, and text colour in every component |
| `space` | `Default` | 12 (`space-0` … `space-24`) | Documented in the Tokens page; component padding uses literal values for now |
| `radius` | `Default` | 5 (`radius-none` … `radius-full`) | Documented in the Tokens page |
| `motion` | `Default` | 4 (`duration-fast/base/slow/deliberate`) | Documented; not yet applied (no animation in static frames) |

**Total: 43 variables, 10 text styles, 3 effect styles.**

Switching the colour mode (Tokens page → right panel → `colour` collection → mode dropdown) cascades every component instance to Light or Dark.

---

## Cover

![Cover page](./figma/cover.png)

The presentation front matter for anyone opening the file. A full-bleed `accent` bar, the `roamgap` wordmark, the headline "Tokens, components & patterns.", a governance lead (the file is the visual source of truth, derived from `specs/DESIGN_SYSTEM.md` and `specs/COMPONENTS.md`; when this file and the specs disagree, the specs win), a metadata line (version · team · maintainer · updated · file key), and a numbered **What's inside** index of the five pages. All fills are bound to the `colour` collection.

---

## Tokens page — visual realization

The full Tokens page is one continuous frame (1440 × 4594). Below: the colour section in detail, followed by a summary of the rest. See the [full Tokens page screenshot](./figma/tokens-page.png) for the whole layout.

### Colour swatches (Light + Dark side-by-side)

![Colour tokens — 7 categories, 22 semantic roles, Light + Dark](./figma/tokens-colour.png)

Seven categories, each grouping semantic tokens around a usage role:

| Category | Tokens | Why grouped |
| -------- | ------ | ----------- |
| **Surfaces** | `surface`, `surface/elevated`, `surface/sunken` | All page / card / input background candidates |
| **Borders** | `border`, `border/strong` | Hairlines and section dividers |
| **Text** | `text`, `text/muted`, `text/faint` | Body, captions, disabled — guaranteed contrast hierarchy |
| **Accent** | `accent`, `accent/contrast`, `focus-ring` | The single brand accent + the colour that sits *on* it + keyboard focus |
| **Score** | `score/high`, `score/mid`, `score/low` | Efficiency-bracket tokens for `ScoreBadge` and calendar heatmap |
| **Day** | `day/weekend`, `day/holiday`, `day/pto`, `day/blocked`, `day/bridge` | The five day classifications in `CalendarStrip` |
| **Status** | `status/success`, `status/warn`, `status/danger` | System feedback (toasts, alerts, validation) |

Each swatch shows the light value (with a hairline so white-on-cream is visible) next to the dark value. The hex codes are baked into the card. The actual fills on every component bind to these variables, so toggling Light/Dark on the file cascades automatically.

**See:** [`specs/DESIGN_SYSTEM.md §2`](./DESIGN_SYSTEM.md) for the canonical token table.

### Typography, spacing, radius, elevation

The Tokens page also contains:

- **Type specimens** — all 10 text styles (`text/3xs` → `text/display`) rendered with sample text, showing scale and weight at a glance.
- **Spacing bars** — 12 horizontal bars showing the 4px-based scale from `space-0` to `space-24`. Each labeled with px value.
- **Radius squares** — 5 squares (`radius-none` to `radius-full`) each painted in `accent` so the shape is obvious.
- **Elevation cards** — 3 floating cards demonstrating `elevation/1`, `/2`, `/3` (resting, hover, modal).

See the [full Tokens page screenshot](./figma/tokens-page.png) for these in context.

---

## Components page

![Components page overview — 26 components in 9 sections](./figma/components-page.png)

26 components, organised into sections. Each section has a heading + subtitle (mirroring [`specs/COMPONENTS.md`](./COMPONENTS.md)) and the component grid below. All component fills are bound to the `colour` variable collection.

A **Dark mirror** of this root (`Components root · Dark`, `228:491`) sits in the `Inventory · Dark` column to the right — the same frame cloned with the `colour` collection set to Dark mode. Because every fill is token-bound, the entire inventory recolours automatically; it is documentation of dark-theme parity, not a separately-maintained artifact. See [components-page-dark.png](./figma/components-page-dark.png).

### ScoreBadge variants

Three colour variants tied to efficiency brackets:

- `ScoreBadge / High` — efficiency ≥ 3.0, green
- `ScoreBadge / Mid` — efficiency 2.0–2.99, amber
- `ScoreBadge / Low` — efficiency < 2.0, brick red

Each uses tabular numerals (`text/sm` Semibold), accent-contrast text on the score-colour pill, 6/12 padding, fully rounded (`cornerRadius: 9999`).

### DayCell variants

![DayCell — 5 variants showing the 5 day classifications](./figma/day-cells.png)

Five variants, one per kind. Each is 40 × 48, contains the day number (top, `text/sm` Medium) and the single-letter kind code (bottom, `text/3xs`). The kind code is **mandatory** — it's the non-colour signal required by [`ACCESSIBILITY.md §3`](./ACCESSIBILITY.md):

- `DayCell / Weekend` — W
- `DayCell / Holiday` — H
- `DayCell / PTO` — P
- `DayCell / Blocked` — B
- `DayCell / Bridge` — \* (with dashed stroke)

### CalendarStrip — Easter Trip example

![CalendarStrip rendering ALGORITHM.md §6 Example A — Thu Apr 2 PTO → Mon Apr 6 holiday](./figma/calendar-strip.png)

Composition demo: the [ALGORITHM.md §6 Example A](./ALGORITHM.md) German Easter trip (Thu Apr 2 PTO → Mon Apr 6 holiday), efficiency 5.00. Each day shows date + kind letter. The first cell is the PTO day (warm coral fill), the last cell is the holiday (warm yellow), and the weekend in the middle is the neutral surface tone.

> **Layout updated (2026-06-14) — screenshot refresh pending.** The strip is now **week-stacked**: the trip's days flow into 7-column weeks (weekday-aligned, leading/trailing blanks pad the first and last weeks), with **one shared weekday-label row along the bottom** instead of a label beneath every cell — the same grid language as `MonthGrid`, mirrored to put the weekday header at the foot. This stops a month-long trip from overflowing its `TripCard` ([COMPONENTS.md § CalendarStrip](./COMPONENTS.md)). **Stage 2 status:** the code layout is the source of truth for this change; the Figma source component (`3:62`) and `./figma/calendar-strip.png` still show the old single-row strip and need a manual refresh — the live-library write was not made in this environment. Not a Stage-2 no-op (the surface is visual); recorded here per [AGENTS.md §1](../AGENTS.md).

**See:** [`specs/COMPONENTS.md` → CalendarStrip](./COMPONENTS.md) for the props contract.

### Calendar section — MonthGrid + YearCalendar

![Calendar section — December 2026 MonthGrid (weekends W, Dec 25/26 holidays H), the mobile paged mock, and the wide 12-month tiling](./figma/year-calendar.svg)

The standalone reference calendar — [PROJECT.md §4.4](./PROJECT.md), [COMPONENTS.md § Calendar](./COMPONENTS.md). Node `291:493` on the Components page (`Calendar section`). Three things in one frame:

1. **`MonthGrid · December 2026`** — the detailed month: label, weekday header (Mon-first), and a 7-column grid. Saturdays/Sundays take `day/weekend` + a "W" code; Dec 25 (Fri) and Dec 26 (Sat) take `day/holiday` + an "H" code; regular workdays are plain. Same day-token language and letter convention as `DayCell` / `CalendarStrip`.
2. **`YearCalendar / Mobile`** — the narrow-viewport state: a 320-wide card with year nav (`‹ 2026 ›`), a month pager (`‹ Prev` · December · `Next ›`), a "Jump to month" select, and a single `MonthGrid`. This is the "slice in and out of months/years" behaviour.
3. **`YearCalendar / Wide`** — all twelve 2026 months tiled (4 × 3), weekends shaded — the "entire year if the screen is big enough" state.

Fills bind to the same colour variables (`day/weekend`, `day/holiday`, `surface/elevated`, `border`, `text`, `text/muted`, `text/faint`), so the Dark mirror cascades.

> **Screenshot note (honest gap).** The committed artifact is [`year-calendar.svg`](./figma/year-calendar.svg) — a faithful vector mirror generated from the same calendar data and tokens. The canonical PNG export of node `291:493` is *pending*: in the remote session that authored this, the Figma asset CDN (`www.figma.com`) was outside the environment's network allowlist, so the binary could not be written here (same constraint as `filter-panel.png`). The live Figma node is the durable source; refresh the PNG from `291:493` from a session with CDN access (see [§ Refreshing the screenshots](#refreshing-the-screenshots)).

**See:** [`specs/COMPONENTS.md` → Calendar](./COMPONENTS.md) for the props contract.

### TripCard — Rank 1 + Rank 2

![TripCard — Apr 2-6 5.00 score and Apr 1-6 3.00 score](./figma/trip-card.png)

The hero output element. Two variants — Rank 1 (efficiency 5.00, ScoreBadge / High) and Rank 2 (efficiency 3.00, also High bracket). Structure inside each card:

1. **Eyebrow** — `#1` / `#2` in `text/2xs` faint (top-left).
2. **Header row** — date range on the left (`text/lg` text), `ScoreBadge` on the right. `SPACE_BETWEEN` so the badge always sits at the right edge regardless of date length.
3. **Body** — `CalendarStrip` showing the trip day-by-day.
4. **Footer** — three short facts: `TOTAL`, `LEAVE`, `NOTE` (the `LEAVE` stat is the §3.2 per-market term — "PTO" at en-US; "currently shows the bridge hint or `—`" for NOTE).

Bound to `surface/elevated`, `border`, and `elevation/1`. Width 640.

### YearPlanCard variants

![YearPlanCard — Selected (orange border), Default, Spread](./figma/year-plan-card.png)

Three variants of the same plan card, showing how strategy + selection state combine:

- **Selected** (PACKED · BEST LEVERAGE, orange border + `elevation/2`) — packs total 24 days off for 6 leave at 4.00 efficiency. Action: "Selected" (no checkmark — see [Decisions §3](#decisions-made-during-the-visual-build) below).
- **Default** (PACKED · LONGEST BREAKS) — 32 / 14 / 2.29. Action: "View plan →".
- **Spread** (SPREAD · QUARTERLY) — 22 / 10 / 2.20. Action: "View plan →".

Each card layout:

1. PlanLabelBadge (inline pill labelled with strategy)
2. Three metric rows (Total days off, Leave spent, Avg efficiency)
3. Budget bar showing leave consumed / budget
4. Action footer (Selected / View plan →)

Width 280, `surface/elevated` fill, `border` stroke, 12px radius, `elevation/1` (or `/2` when selected).

### Input components

![Inputs row — CountryPicker, AnnualPtoInput, MaxTripLengthInput, BudgetModeToggle](./figma/inputs-section.png)

Four input controls, sized to align in the Patterns hero (CountryPicker / AnnualPtoInput / MaxTripLengthInput at 280 each, BudgetModeToggle at 360):

- **CountryPicker** — searchable dropdown shape. Idle state with German flag + name + caret.
- **AnnualPtoInput** — number + stepper. Default: 30 days / year. Shows the value at `text/2xl` Medium so the number is the focal point.
- **MaxTripLengthInput** — same stepper shape. Default: 14 days per break. *Note: the 1w/2w/3w preset chips originally inside this component were moved to a separate row on Patterns so all three inputs align vertically.*
- **BudgetModeToggle** — segmented control. Active segment ("Spend it all") gets `surface/elevated` + `elevation/1`, inactive ("Only the good stuff") is muted text only.

All bound to `surface/sunken` (input field background) and `border` (1px hairline).

#### CountryPicker — field label, self vs party (added 2026-06-25)

![CountryPicker field label — self "Your country" vs party "Country" (Light)](./figma/country-field-label.png)
![CountryPicker field label — Dark](./figma/country-field-label-dark.png)

- **`CountryPicker · field label (self vs party)`** — Light `815:113` / Dark `816:123` (Components page). Two `CountryPicker` instances (`5:123`) side by side: the **self** instance's label is overridden to **"YOUR COUNTRY"**, the **other-party** instance keeps the neutral **"COUNTRY"**. Captions name the contexts — self = `/year`, `/solo`, `/calendar`, settings, a group member's own availability; other party = `/overlap` parties 2–6. Realises [VOICE.md §4.23](./VOICE.md) + the new `label` prop on [COMPONENTS.md §CountryPicker](./COMPONENTS.md). The canonical Inputs-section drawing (`5:112` / label `5:118`) keeps the neutral **"COUNTRY"** default unchanged — it documents the prop default, not a self context.
- **Dark parity:** the Dark frame (`816:123`) is a clone of the Light frame with the `colour` collection mode set to **Dark** (`1:1`); surface, text, and the token-bound select fields cascade.

### Other component families (not pictured here)

- **Button / Primary** & **Button / Ghost** — primary action (filled, accent) and secondary (transparent with border). Both 44 tall, 4px radius (intentionally squarer than the chips).
- **PlanLabelBadge / packed-efficiency, packed-length, spread-quarterly, spread-even, single-long** — five strategy pills used inside YearPlanCard.
- **BudgetUtilisationBar / Near full, Under used** — progress bars showing leave spent / budget.

---

## Patterns page — Home (Annual planner)

![Patterns / Home — the annual planner home screen](./figma/patterns-home.png)

The assembled home screen at 1440 × 1805. Composes instances of every input + plan card + trip card defined on the Components page. Sections, top to bottom:

### 1. AppShell · Header
Brand mark + wordmark on the left; nav pills + Sign-in CTA on the right. Plan year is the active state (filled pill, `surface/sunken`). Find a trip and Calendar are muted text — **only items with an action state get a pill**. Sign in is a CTA pill (`surface/sunken` + `border`). (About is no longer a navbar item — it moved to the footer, [PROJECT.md §4.5](./PROJECT.md); the assembled-screen navbar was corrected to show **Calendar** in its place on 2026-06-17 — Decisions §9.)

### 2. Hero · Plan my year
- Eyebrow: "Annual planner" (the internal "· Phase 1" suffix was removed from user-facing copy post-launch; the Figma library + `patterns-home.png` redraw to drop it is a tracked follow-up)
- Title: "Plan your year off." at `text/display` (48/52 Bold)
- Description: one-sentence framing in `text/lg` muted
- **Inputs row** — Country (with inline `SubdivisionPicker` 8px below when the country has ISO 3166-2 data; here showing Bavaria selected for Germany) / Annual leave / Max trip length. Leave + max-trip stay top-aligned with the CountryPicker; the subdivision row takes the column taller (66 + 8 + 60 = 134px), which is the only break from [Decisions §1](#decisions-made-during-the-visual-build) and matches the code's "inline below" behaviour.
- **Presets row** — 1 week / 2 weeks (active) / 3 weeks chips, right-aligned under MaxTripLengthInput
- **CTA row** — BudgetModeToggle on the left + "Find trips" button on the right (`SPACE_BETWEEN`)
- 96px bottom padding to create breathing room before the grey strategy section

### 3. Plans comparison
Lives on a `surface/sunken` background to visually demarcate the "now we're showing options" beat. Eyebrow "THREE OF FIVE PLANS · GERMANY 2027 · 30 LEAVE BUDGET" + heading "Pick a strategy." (`text/2xl`). Three YearPlanCard instances side-by-side. The orange-bordered "Packed · Best leverage" is in the selected state.

### 4. Selected plan detail
Back on `surface`. Eyebrow "SELECTED · PACKED · BEST LEVERAGE" in accent colour + heading "Your year: 5 trips, 24 days off, 6 leave spent." (`text/2xl`). Two TripCard instances stacked vertically showing the first two trips of the selected plan.

### 5. Footer
A full-width `Footer` instance at the bottom (`Footer / Light` here; `Footer / Dark` on the dark variant), carrying the brand line, doc links, and the `ThemeSwitch` (System / Light / Dark). This realises [COMPONENTS.md §Footer](./COMPONENTS.md) in the assembled page. See [Patterns — Home (Dark)](#patterns--home-dark).

---

## Patterns page — Find a trip (hero)

![Pattern · Find a trip — hero (en-KE "leave" terminology)](./figma/patterns-trip.png)

The trip-finder empty state ([VOICE.md §4.12](./VOICE.md), [COMPONENTS.md § Trip finder hero](./COMPONENTS.md)) — the copy realisation of the "find a trip → ordered list of Trips" bridge that the bare "Pick a trip." heading never made. Cloned from the Home hero (`8:194`) and retexted, so every fill stays bound to the `colour` collection and the dark cascade still applies (no new unbound paints introduced):

- **Eyebrow** — "Trip finder" (matching the planner hero).
- **Title** — "Find your best trip." at `text/display`; the verb echoes the nav label "Find a trip", not "Pick" (you can't pick before a search returns anything).
- **Lead** — the Home hero's previously-collapsed description node, now visible at `720`-wide `text/lg` muted: "Tell roamgap your country and how many leave days you have. It finds the trips that turn the fewest days into the most continuous time off, and lists them best first." (en-KE term, §3.2.) This is the part the page was missing — it states the results are *ordered, best first*.
- **Inputs** — trimmed to the single-trip set: `CountryPicker` + the leave stepper, relabelled "LEAVE BUDGET" / "10" / "days" (not the planner's "days / year"). The planner-only `MaxTripLengthInput`, `SubdivisionPicker`, the 1w/2w/3w presets, and `BudgetModeToggle` were removed.
- **CTA** — the `Button / Primary` "Find trips".

**Drawn — node `417:693`** (Patterns page `1:5`, standalone at `x=6552, y=1340`, below the About frame), `1440 × 454`.

> **Screenshot captured (2026-06-20, TF-3).** `specs/figma/patterns-trip.png` was exported from `417:693` via the remote Figma MCP — the `figma.com` asset host is reachable now, clearing the earlier egress block. It shows the en-KE "leave" terminology (lead "how many leave days you have", input "LEAVE BUDGET"). A standalone **Dark mirror** of the hero, and the full "Find a trip" pattern (hero + `ResultsHeader` + `TripList`, [Slice 1 additions](#slice-1-additions)), remain the tracked follow-up; this lands its hero.

---

## Decisions made during the visual build

These decisions emerged during Figma work and weren't (or weren't yet) in the spec. Each one is canonical now — if you build this in code, match these.

### 1. Hero inputs align at one height; presets go on their own row
The original `MaxTripLengthInput` component bundled the 1w/2w/3w preset chips inside it, making it taller than the other inputs. That broke vertical alignment in the hero row. Resolution: the presets were extracted to a dedicated row beneath the inputs. The component still owns the field-with-stepper part; the presets are a sibling concern.

### 2. "Selected" without a checkmark
The Selected state of YearPlanCard originally read `✓ Selected`. The check felt heavy / template-y and competed with the orange border + elevation that already communicate selection. Resolution: dropped to just "Selected" in accent colour.

### 3. TripCard `#1` is an eyebrow, not part of the header
The original header was a horizontal hstack containing `[#1 + date]` (stacked vertically) and `[ScoreBadge]` (right). This made the badge vertically centred against the *middle* of the 2-line block, not the date itself. Resolution: `#1` was lifted out of the header to become a small eyebrow above. The header is now a single row with date + badge, vertically centred, with `SPACE_BETWEEN` pushing them to opposite edges of the card.

### 4. Two pill sizes — large for nav, small for filters
The library uses two pill sizes deliberately:

- **Large pills (44px tall, `text/sm`, 12/16 padding)** — used for `BudgetModeToggle` and the `Find trips` button. These are primary controls.
- **Small pills (~32–36px tall, `text/xs`, tighter padding, `cornerRadius: 9999`)** — used for the navbar pills (Plan year, Sign in) and the preset chips (1 week / 2 weeks / 3 weeks). These are secondary controls and tertiary filters.

The two scales coexist intentionally — large = primary action, small = navigation/filter. Don't mix them within a single role.

### 5. Pills only on items with action state
On the navbar, only `Plan year` (active) and `Sign in` (CTA) are rendered as pills. `Find a trip` and `Calendar` are plain muted text. This matches a navigation pattern where the active page is highlighted, the call-to-action is treated specially, and inactive items are quiet. (`About` is no longer on the navbar — it lives in the footer, §9.)

### 6. asComponent height bug — known maintenance hazard
A bug in the original `asComponent` helper left several container components at `h: 1` post-creation: `figma.createComponent()` + `appendChild` doesn't auto-reflow primary=FIXED frames. A force-resize pass (recursive depth-first walk computing manual heights) fixed them post-hoc. If you rebuild any component, set `primaryAxisSizingMode = "FIXED"` and `resize(width, computedHeight)` explicitly after children are added. Documented in [`memory/figma-file.md`](file:///../memory/figma-file.md).

### 7. Filter chips — selected = accent fill + check glyph

The revamped `FilterPanel` month/weekday selectors are **toggle chips**, not raw checkboxes. The chosen state fills the chip with the single brand `accent` and adds a leading `✓`; unselected chips stay `surface/sunken` + `border`. Filling *only* the selected chips with the accent (the one place the accent fills a chip) is deliberate — it gives the otherwise-quiet panel life without competing with the data colours (§1 "one accent", "calm over busy"). The `✓` is the non-colour signal required by [ACCESSIBILITY.md §3](./ACCESSIBILITY.md). Canonicalised in [DESIGN_SYSTEM.md §2.6](./DESIGN_SYSTEM.md#26-filter-chips-toggle).

### 8. `use_figma` wrap rows need an explicit fixed width

When authoring the FilterPanel frame via the Plugin API, the month/weekday chip rows (`layoutMode='HORIZONTAL'`, `layoutWrap='WRAP'`) collapsed to a single short row until the row was given an explicit fixed width — `layoutAlign='STRETCH'` alone did not size them. Set `primaryAxisSizingMode='FIXED'` **and** `resize(width, …)` on any wrap row (and on the cards/fieldsets that contain them) rather than relying on STRETCH inheritance; heights then hug automatically. Same family of gotcha as Decision §6.

### 9. Top-nav collapses to an overflow menu on mobile (added 2026-06-11, revised 2026-06-12; **drawn 2026-06-17**)

Below the `sm` breakpoint ([DESIGN_SYSTEM.md §8](./DESIGN_SYSTEM.md)) the three primary links (Plan year, Find a trip, Calendar) no longer sit inline: they collapse behind a single ☰ "Menu" disclosure (`NavMenu`, [COMPONENTS.md § NavMenu](./COMPONENTS.md)) that reveals them — full labels, in order — in a popover list anchored below the bar. At `sm`+ the links return to their inline row. The brand mark (the logomark; the wordmark joins from `sm`+ — [COMPONENTS.md § TopNav](./COMPONENTS.md)), the ☰ button, and the `NavAuthSlot` identity slot are all that remain on a phone-width bar, so it no longer crowds. The pill-vs-text treatment (§5), order, destinations, and small-pill scale (§4) are unchanged; the active destination is still marked inside the popover list.

This **revises** the original 2026-06-11 decision (shorten the two multi-word labels to one word on mobile). Abbreviating each label kept the row to one line but still placed five-plus hit targets across a phone bar; collapsing the set behind one control declutters it further and keeps every label's copy intact. Sentence case preserved throughout ([VOICE.md §4.11](./VOICE.md)).

> **`TopNav / Mobile` frame — drawn (2026-06-17).** The narrow-viewport overflow-menu state is realised as a `Pattern · TopNav (mobile)` showcase — Light `633:1296` · Dark `634:1296`, filed in a new **`TopNav (mobile) — Light = left · Dark = right`** Section (`634:1330`) on the Patterns page. Each frame stacks **both** states: **closed** — a phone-width (390px) `surface` bar with only the brand mark, the ☰ "Menu" button (three `text/muted` bars), and the `Sign in` slot, the three primary links collapsed — and **open** — the same bar with the `NavMenu` popover (a `surface/elevated` card + `border` + drop shadow) anchored below the ☰, listing the **three** destinations **Plan year (active, `surface/sunken`) · Find a trip · Calendar** (full labels, sentence case, [VOICE.md §4.11](./VOICE.md)). Every fill binds to the `colour` collection; the Dark mirror is a clone with the `colour` mode set to Dark and cascades cleanly (accent mark holds, light text, dark-elevated popover, no white blocks). The wide `AppShell` nav's stale fourth **About** item (Light `8:188` · Dark `108:184`) was corrected to **Calendar** in the same pass — About lives in the Footer ([PROJECT.md §4.5](./PROJECT.md)), and `Calendar` is the third primary tool ([nav-items.ts](../src/components/layout/nav-items.ts)) — closing the second half of this gap. Screenshots: `topnav-mobile.png` (`633:1296`) / `topnav-mobile-dark.png` (`634:1296`).

![Pattern · TopNav (mobile) — closed bar (mark + ☰ + Sign in) and the open state with the Plan year / Find a trip / Calendar popover, Light](./figma/topnav-mobile.png)
![Pattern · TopNav (mobile) — Dark](./figma/topnav-mobile-dark.png)

> **Nav consistency — every surface now carries Calendar (2026-06-17).** The note above recorded the wide-`AppShell` About→Calendar fix on the **Home** nav only (`8:188` · `108:184`). Two gaps remained and are now closed, so **no nav surface in the file drifts from [nav-items.ts](../src/components/layout/nav-items.ts)** (Plan year · Find a trip · Calendar) — which is what `TopNav` renders on every route, signed-in or out.
>
> - **Wide-nav frames already corrected, now enumerated.** `How it works` (Light `156:429` · Dark `156:468`) and `Legal / Privacy` (Light `190:429` · Dark `219:490`) carry **Plan year · Find a trip · Calendar · Sign in**; `About` (`360:499`) and `404` (`353:481`) carry the three tools (no Sign in, per their drawn signed-out-marketing state). These were brought in line alongside the Home-nav fix, but the note above cited only Home — recording them here.
> - **Compact account/auth bars — Calendar added (this pass).** The six **Account & Auth (DSAR)** routes' `navright` bars gained the **Calendar** item in **both** themes (12 frames): Sign in (`215:454` · `219:614`), Saved searches (`186:428` · `219:454`), Account settings (`211:444` · `219:528`), Download my data (`212:451` · `219:562`), Delete my account (`213:452` · `219:579`), Restore account (`214:452` · `219:599`). Each Calendar is a clone of that frame's own muted `Find a trip` label — so it inherits the `text/muted` token binding and the Dark cascade applies with no rebinding — inserted before the avatar; the hug auto-layout `navright` was re-right-aligned to keep its 876 right edge. These bars previously showed only **Plan year · Find a trip** (+ avatar) — the pre-existing post-#50 drift. Screenshots refreshed: `patterns-sign-in*.png`, `patterns-saved-searches*.png`, `patterns-account-settings*.png`, `patterns-account-data*.png`, `patterns-account-delete*.png`, `patterns-account-restore*.png`.
>
> *(As of 2026-06-17 no nav surface drifted from the then three-item `nav-items.ts`. **UX-1 (2026-06-19) extends that set to five** — see the note below.)*

> **Nav — five destinations: the two coordination tools added (2026-06-19, UX-1).** [VOICE.md §4.11](./VOICE.md) / [COMPONENTS.md § TopNav/§ NavMenu](./COMPONENTS.md): the top nav now surfaces **Shared time off** (→ `/overlap`) and **Group trips** (→ `/groups`) after the three primary tools (the value labels, not the internal route names "Overlap"/"Groups", [SEO.md §4.8](./SEO.md)). Both ship paid-dark but sit in the primary nav for everyone, for discovery + the GD-3 invite loop. Drawn on the two canonical `TopNav` contract surfaces, Light + Dark:
> - **Wide inline bar** (the `Nav` HUG-width auto-layout): Light `8:188` · Dark `108:184` — the two muted-text items inserted **before** `Sign in` (new nodes `658:1296`/`658:1298` Light, `658:1300`/`658:1302` Dark); the bar grew to 631 px and was re-anchored to the 1376 px right edge so it still clears the brand lockup.
> - **Mobile overflow popover** (`menu-popover`): Light `632:1296` · Dark `634:1323` — appended as two rows (new nodes `654:1296`/`654:1298` Light, `654:1300`/`654:1302` Dark); the popover hugs to 182 px and the `open-state`/outer showcase frames grew with it. The stale "three links" caption (`633:1298` · `634:1298`) now reads "the links".
> Each new item is a **clone** of that surface's own `Calendar` item, so the `colour`-collection fills and the muted treatment carry over token-bound — verified Light **and** Dark, no white-fill cascade. **PNG export stays egress-blocked** (`www.figma.com` not in the allowlist, HTTP 403) so no `specs/figma/` snapshot is committed; the node ids above are the durable pointers. The secondary assembled-screen nav copies (How it works, Legal, About, 404, the six account/auth bars) still show the prior three-tool set — they reuse the identical muted-item treatment, so that is a mechanical consistency lag in static mocks, not a drift in the component contract, which is the `TopNav` wide + mobile frames updated here.

---

## FilterPanel revamp (Phase 2)

![FilterPanel — collapsed (active) summary + count badge, and expanded with month/weekday toggle chips, a removable blocked-date chip, and Clear filters](./figma/filter-panel.png)

Realises the `FilterPanel` revamp from [COMPONENTS.md § FilterPanel](./COMPONENTS.md#filterpanel--phase-1--slice-1-code--revamped-phase-2) and [DESIGN_SYSTEM.md §2.6](./DESIGN_SYSTEM.md#26-filter-chips-toggle). The original control was a stack of raw checkboxes ("very unwild"); the revamp brings it onto the design-system **filter-chip** scale so it reads as designed.

**Drawn — node `266:478`** (Components page `1:4`, Source-components column at `x ≈ 3400, y ≈ 3380`, `656 × 717`). Built via the Figma MCP write path (`use_figma`, Plugin API); every fill binds to the `colour` collection (`surface`, `surface/elevated`, `surface/sunken`, `border`, `text`, `text/muted`, `text/faint`, `accent`, `accent/contrast`) so the Light/Dark cascade is one click apart, per the §"Rule of thumb for new dark frames". Geist (Regular/Medium/SemiBold) matches the type system.

The frame stacks two states:

- **Collapsed — active.** A `surface/elevated` disclosure card: "Filters" + the live summary "3 months · No Fri · 1 blocked" on the left; an `accent` count badge ("5") + chevron on the right.
- **Expanded.** Header ("Filters" + ▴), a `border` divider, then three fieldsets — **Months** (12 toggle chips, March/April/May selected), **Exclude weekdays** (helper + 7 chips, Fri selected), **Blocked dates** (helper + a removable "14 Mar – 20 Mar ✕" chip + the start→end date inputs + "Add") — and a `Button / Ghost` "Clear filters" pinned bottom-right. Selected chips carry the `accent` fill, `accent/contrast` text, and a leading `✓` (Decision §7).

> **Screenshot captured (2026-06-09).** `./figma/filter-panel.png` is the export of node `266:478`, pulled from the live file via the remote Figma MCP (`get_screenshot` → `curl`) and committed. The earlier CDN-allowlist gap no longer applies in the current environment. The node id remains the durable pointer; refresh from it when the panel changes.

---

## Sync workflow — keeping spec, Figma, and code aligned

The canonical order is **Spec → Figma → Development**, never the reverse. See [`SPEC_FIRST.md` §"Three-phase order"](./SPEC_FIRST.md) and [`docs/DECISIONS.md` ADR-006](../docs/DECISIONS.md) for the rationale and trade-offs.

```
        Stage 1                Stage 2                Stage 3
        ───────                ───────                ───────
   ┌──────────────┐       ┌──────────────┐       ┌──────────────────┐
   │   specs/     │  ──▶  │   Figma      │  ──▶  │   Development    │
   │              │       │              │       │                  │
   │ DESIGN_      │       │ design       │       │ src/app/         │
   │   SYSTEM.md  │       │ library at   │       │ src/components/  │
   │ COMPONENTS.  │       │ file key     │       │ src/lib/         │
   │   md         │       │ gzgZd6XTnF…  │       │ tests/unit/      │
   │ VOICE.md     │       │              │       │                  │
   │ ACCESSI-     │       │ specs/figma/ │       │ npm run check    │
   │   BILITY.md  │       │   *.png      │       │                  │
   │ ERROR_       │       │ FIGMA_REF.md │       │                  │
   │   STATES.md  │       │ (this doc)   │       │                  │
   │ URL_FORMAT.  │       │              │       │                  │
   │   md         │       │              │       │                  │
   └──────────────┘       └──────────────┘       └──────────────────┘
   text contract          visual contract        executable contract
   `spec:` commits        `design(figma):`       `test:` then
                          commits                `impl(spec:#NNN):`
                                                 commits
```

### When the spec changes (Stages 1 → 2 → 3)

1. **Stage 1.** Update the relevant spec file — `DESIGN_SYSTEM.md`, `COMPONENTS.md`, `VOICE.md`, `ACCESSIBILITY.md`, `ERROR_STATES.md`, `URL_FORMAT.md`, or `domain.ts`. Commit prefix: `spec:`.
2. **Stage 2.** Open the design library file at `gzgZd6XTnFVwHHbPxKuac2` and realise the change — either by hand or via a `use_figma` MCP call referencing the updated spec. Refresh the relevant screenshot in `specs/figma/` and add or update the per-section commentary here in FIGMA_REFERENCE.md (including any new node IDs). Commit prefix: `design(figma):`.
3. **Stage 3.** Write a failing test cross-referencing the spec section, then the implementation that makes it pass. Reference both the spec PR and the Figma screenshot in the PR description. Commit prefix: `impl(spec:#NNN):`.

The Stage 2 commit is **not** optional for a change with visual surface, even if the surface looks small. Skipping it produces drift between code and the design library; this is the exact failure mode that ADR-006 was opened to prevent.

### Stage 2 is a recorded no-op when

The spec change is non-visual: URL grammar, schema fields, algorithm contract, telemetry property allowlist, server-side error codes that never reach the UI, internal architecture notes. The Stage 1 PR description must say "no Figma change — non-visual." The development PR follows directly from Stage 1 with no `design(figma):` commit.

### When the Figma file changes ahead of the spec (legitimate cases)

Two paths land here:

1. **Visual polish during a Stage 2 session** — padding tweak, alignment fix, a new variant of an existing component — that the spec author didn't anticipate but the designer realised was needed. Resolution: same PR adds a Stage 1 spec amendment ("§N.M now reads…") *before* the Stage 2 screenshot commits. The order in the log is still `spec: → design(figma): → impl(spec:#NNN):`.
2. **A new component invented in Figma without a prior spec entry.** This is a Stage 1 violation and should not happen, but if it does: the development PR is blocked until the spec is amended. Open the spec PR first, then the design(figma): commit follows, then code.

### Refreshing the screenshots

The committed screenshots in `specs/figma/` are point-in-time exports from these Figma node IDs:

| Screenshot | Node ID | Captures |
| ---------- | ------- | -------- |
| `cover.png` | `1:66` | The cover frame |
| `tokens-page.png` | `1:74` | The full Tokens page (1440 × 4594) |
| `tokens-colour.png` | `1:79` | The colour section close-up |
| `components-page.png` | `3:2` | The full Components page, Light (1440 × 2503) |
| `components-page-dark.png` | `228:491` | The Components root recoloured to the dark theme (Dark mirror) |
| `day-cells.png` | `3:21` | The 5 DayCell variants in a row |
| `calendar-strip.png` | `3:62` | The Easter trip example strip |
| `trip-card.png` | `3:92` | Both TripCard variants |
| `year-plan-card.png` | `5:41` | All 3 YearPlanCard variants |
| `inputs-section.png` | `5:112` | All 4 input components side-by-side |
| `country-field-label.png` | `815:113` | CountryPicker field label — self "Your country" vs party "Country" (Light). VOICE §4.23 |
| `country-field-label-dark.png` | `816:123` | Same, Dark |
| `long-trip-count-input.png` | `497:48` | `LongTripCountInput` source component (Advanced-settings stepper — "LONG TRIPS" / value / "7+ days each"), cloned from `MaxTripLengthInput`. Lives in the Source-components column. See [COMPONENTS.md § LongTripCountInput](./COMPONENTS.md) / [ALGORITHM.md §11.10](./ALGORITHM.md). *Follow-up: place an instance into the Advanced-settings pattern (`386:534`/`388:534`) + the Light/Dark inventory roots, then refresh `patterns-advanced-settings*.png`.* |
| `subdivision-picker.png` | `44:2` | SubdivisionPicker section — Slice 1 |
| `patterns-home.png` | `8:183` | The full Home pattern (1440 × 1904, now incl. Footer) |
| `footer-themeswitch-light.png` | `112:36` | Footer + ThemeSwitch component, Light ("Light" active) |
| `footer-themeswitch-dark.png` | `112:54` | Footer + ThemeSwitch component, Dark ("Dark" active) |
| `patterns-home-dark.png` | `108:179` | Patterns — Home recoloured to the dark theme |
| `how-it-works-light.png` | `156:424` | Patterns — How it works explainer, Light |
| `how-it-works-dark.png` | `156:463` | Patterns — How it works explainer, Dark |
| `not-found.png` | `353:478` | Patterns — 404 (Not found), Light |
| `about.png` | `360:496` | Patterns — About (product overview), Light |
| `patterns-saved-searches-dark.png` | `219:451` | Patterns — Saved searches, Dark |
| `patterns-legal-privacy-dark.png` | `219:485` | Patterns — Legal (privacy), Dark |
| `patterns-account-settings-dark.png` | `219:525` | Patterns — Account settings, Dark (incl. the Email alerts row). |
| `patterns-account-data-dark.png` | `219:559` | Patterns — Download my data, Dark |
| `patterns-account-delete-dark.png` | `219:576` | Patterns — Delete my account, Dark |
| `patterns-account-restore-dark.png` | `219:596` | Patterns — Restore account, Dark |
| `patterns-sign-in-dark.png` | `219:611` | Patterns — Sign in, Dark |
| `filter-panel.png` | `266:478` | The revamped `FilterPanel` (collapsed-active + expanded). |
| `calendar-overlay-picker.png` | `308:504` | The `CalendarOverlayPicker` — redrawn 2026-06-09 to the unified `observedCalendars` list (titled *Calendars you observe*, Gregorian/Islamic (Kenya) + Islamic (worldwide) rows with Free/PTO toggles, footer Add-calendar control; no checkboxes). |
| `daycell-observance.png` | `308:499` | `DayCell / Observance` (dotted accent). |
| `over-budget-marker.png` | `308:502` | The `OverBudgetMarker` pill. |
| `account-settings.png` | `199:444` | The source `AccountSettingsForm` — incl. the **Default calendars** section (unified picker) and the **Email alerts** opt-out row, Light. |
| `patterns-account-settings.png` | `211:441` | `Pattern · Account settings` (Light) — incl. the Default-calendars section and the Email alerts row. |
| `patterns-advanced-settings.png` | `386:534` | `Pattern · Advanced settings (Planner)` — `FilterPanel` + `CalendarOverlayPicker` side-by-side, Light. |
| `patterns-advanced-settings-dark.png` | `388:534` | The composed Advanced-settings (Planner) frame, Dark. |
| `callout-box.png` | `411:38` | The `CalloutBox` ComponentSet — `info` / `warn` / `success` variants (left accent bar + title + body). |
| `patterns-trip-results.png` | `413:513` | `Pattern · Find a trip (results)` (Light) — eyebrow, `3 trips` + Sort toggle, the budget-note + lost-to-weekend `CalloutBox`es, and two `TripCard`s. |
| `patterns-trip-results-dark.png` | `414:2` | The same `/trip` results pattern, Dark. |
| `year-calendar.svg` | `291:493` | The `Calendar section` — `MonthGrid` (Dec 2026), the mobile paged mock, and the wide 12-month tiling. *Committed as an SVG mirror; canonical PNG export of `291:493` pending CDN access.* |
| `patterns-trip.png` | `417:693` | The trip-finder hero (empty state) — eyebrow/title/lead + Country/Leave inputs + Find trips. Refreshed 2026-06-20 (TF-3) with the en-KE "leave" terminology. |
| `holiday-head-to-head.png` | `591:1262` | `Pattern · Holiday head-to-head` (Light) — verdict + `VersusTable` + How-we-count + Footer |
| `holiday-head-to-head-dark.png` | `597:1279` | The head-to-head pattern, Dark |
| `itineraries-list.png` | `625:49` | `ItinerariesList` — default (pencil rename + delete), renaming (Input + Save + Cancel), inline-confirm delete, deleting, empty, loading. |
| `topnav-mobile.png` | `633:1296` | `Pattern · TopNav (mobile)` (Light) — closed bar + open `NavMenu` popover (Plan year / Find a trip / Calendar). |
| `topnav-mobile-dark.png` | `634:1296` | The mobile `TopNav` overflow-menu showcase, Dark. |
| `anon-nav-guest-menu.png` | `826:1580` | `NavGuestMenu` — anon right-slot: desktop split "Sign in ▾" pill + open guest panel, and the mobile hamburger-foot guest block (Light). VOICE §4.24 |
| `anon-nav-guest-menu-dark.png` | `827:1580` | Same, Dark |
| `off-day-ledger.png` | `677:50` | `OffDayLedger` (UX-10) — Light. The leave ledger: "Your leave — {year}" + three stat cards (accent "days left" / "PTO taken" / "granted") + annual allowance + a day list (date + PTO/Medical category pill + ×) + a dashed "+ Add a day". Dark mirror `677:85`; new **"Off-day manager (UX-10)"** section label `677:49`. Geist, token-bound (dark cascade verified). See [COMPONENTS.md § OffDayLedger](./COMPONENTS.md). |
| `group-event-result.png` | `676:50` | `GroupEventResult` (UX-9) — Light. The event-archetype result: "Your time off around the event" lead (event window named) + ranked option cards ("Most efficient" accent chip + date range + "N days off · M days of leave" + a pto/off day strip). Dark mirror `676:74`; new **"Event groups (UX-9)"** section label `676:49` below the availability-profile section. Geist, token-bound (dark cascade verified). See [COMPONENTS.md § GroupEventResult](./COMPONENTS.md). |
| `availability-profile-controls.png` | `675:50` | `AvailabilityProfileControls` (UX-7b) — Light. The save/load card: "Save as my profile" (accent) + "Load from my profile" (outline) + the "✓ Saved to your profile." state. Dark mirror `675:59`; new **"Availability profile"** section label `675:49` opens a 7th section below the Source-components column (after Groups). Geist, token-bound fills (dark cascade verified). **Superseded by UX-11** — this per-page two-button cluster was removed from the planners; its replacement is the settings-only `AvailabilityProfileEditor` drawn in [UX-11 — planner advanced revamp](#ux-11--planner-advanced-revamp-added-2026-06-21). Kept as the historical UX-7b snapshot. |

To refresh: call `mcp__figma__get_screenshot` with the relevant `nodeId`, download the PNG, and replace the file in `specs/figma/`. Node IDs are stable; the screenshots are the deltas.

---

## Slice 1 additions

Specified in [`COMPONENTS.md`](./COMPONENTS.md) for the Phase 1 Slice 1 code drop. Per the strict `spec → Figma → test → code` order, the items marked **drawn** below were authored before the Slice 1 tests + code commits. The remaining items have crisp spec contracts (ERROR_STATES.md §2, DESIGN_SYSTEM.md status-* tokens, COMPONENTS.md props) and are tracked as a Slice 1 Figma follow-up — they don't block the code commit because the spec is sufficient.

| Component | Change | Target page | Status |
| --------- | ------ | ----------- | ------ |
| `SubdivisionPicker` | Inline ISO 3166-2 sub-listbox sibling to `CountryPicker`, sized 280×60. Default variant shows "REGION · Bavaria (DE-BY)". Realises [COMPONENTS.md §CountryPicker](./COMPONENTS.md) for the Phase 1 "country alone isn't enough" resolution recorded in [PROJECT.md §5](./PROJECT.md). | Components | **Drawn** — node `44:2` (section), `44:6` (component). See [subdivision-picker.png](./figma/subdivision-picker.png). |
| `EmptyState` | Three variants matching [ERROR_STATES.md §2.1, §2.2, §2.3](./ERROR_STATES.md): no-input, no-results-match, country-not-supported. | Components | Follow-up |
| `ErrorState` | Variants matching [ERROR_STATES.md §2.8–§2.12](./ERROR_STATES.md): timeout, offline, server-error, upstream-unavailable, rate-limited. | Components | Follow-up |
| `Toast` | Three kind variants (success, warn, danger) per [DESIGN_SYSTEM.md §2.2](./DESIGN_SYSTEM.md) `status-*` tokens. | Components | Follow-up |
| `Footer` | Brand line + links to llms.txt, About, GitHub, and the `ThemeSwitch`, per [COMPONENTS.md §Footer](./COMPONENTS.md). | Components | **Drawn** — `Footer / Light` `112:36`, `Footer / Dark` `112:54` (components). See [Footer + ThemeSwitch](#footer--themeswitch) below. |
| `ThemeSwitch` | Compact System / Light / Dark segmented control inside the `Footer`, per [COMPONENTS.md §ThemeSwitch](./COMPONENTS.md#themeswitch--phase-1) and [DESIGN_SYSTEM.md §2.5](./DESIGN_SYSTEM.md#25-theme-override-system--light--dark). Drawn in both Light and Dark. | Components | **Drawn** — `ThemeSwitch / Light` `112:17`, `ThemeSwitch / Dark` `112:18` (components). |
| `DocPage` | Server-rendered Markdown content wrapper with the JSON-LD `Article` block surfaced as a Figma comment per [COMPONENTS.md §DocPage](./COMPONENTS.md). | Components | Follow-up |
| `CalloutBox` | Three variants matching [ERROR_STATES.md §2.15–§2.16](./ERROR_STATES.md) and the info/warn/success kinds. | Components | Follow-up |
| Patterns — "Find a trip" | **Hero drawn** — `Pattern · Find a trip — hero` (`417:693`): the [VOICE.md §4.12](./VOICE.md) empty-state copy (eyebrow / "Find your best trip." / lead) on the Home-hero shape, trimmed to single-trip inputs. Full pattern (`ResultsHeader` + `TripList`), the Dark mirror, and the PNG export remain to come. | Patterns | **Hero drawn (Light); pattern + screenshot follow-up** |

### SubdivisionPicker

![SubdivisionPicker — REGION eyebrow, Bavaria (DE-BY), chevron in 48px right-edge gutter](./figma/subdivision-picker.png)

Sibling to `CountryPicker`. When the selected country has ISO 3166-2 subdivisions in the backend, the picker renders inline below the country combobox. `surface-sunken` fill, `border` 1px stroke, 8px radius, 280×60. Padding 16/12 on the value-stack side; the **right edge is a 48px gutter** that holds the `▾` chevron centred — this matches the geometry of the `+` button in adjacent steppers ([COMPONENTS.md §CountryPicker "Right-edge gutter"](./COMPONENTS.md)) so a Country / PTO / Max-trip row reads as a single horizontal rhythm. The eyebrow uses `text/3xs` Medium 1.5px tracked uppercase, the value uses `text/base` Regular `text` colour. The chevron is `text/muted`. Per [COMPONENTS.md §CountryPicker](./COMPONENTS.md), changing the selected country must clear the subdivision in the same React commit; the picker simply disappears if the new country has no subdivisions.

### Footer + ThemeSwitch

The footer realised in both colour modes, with the new `ThemeSwitch` in its right-hand cluster. Per the Slice-1 request, the existing light design is left untouched and a **dark** variant is added alongside, matching the dark tokens in [DESIGN_SYSTEM.md §2.3](./DESIGN_SYSTEM.md). The `ThemeSwitch` and `Footer` live as components on the **Components** page (Light + Dark); a dark recolour of the home screen lives on the **Patterns** page.

> **MCP note.** The Figma write connector used by the agent commits only the document's index-0 page. The workaround was to temporarily reorder the target page to index 0, edit it, then restore the original page order — see [[figma-mcp-commit-behavior]] in agent memory. `Cover` was left information-only.

**Footer / Light** (`112:36`) — the active segment is "Light", lifted onto `surface-elevated`:

![Footer + ThemeSwitch — Light](./figma/footer-themeswitch-light.png)

**Footer / Dark** (`112:54`) — same layout on the dark surface, with "Dark" active:

![Footer + ThemeSwitch — Dark](./figma/footer-themeswitch-dark.png)

The `ThemeSwitch` is a compact (small-pill) segmented control — `surface-sunken` track, `border` hairline, fully rounded — three segments labelled System / Light / Dark. Exactly one is active; the active segment carries `surface-elevated` + `elevation-1`, inactive segments are `text-muted` only. This mirrors `BudgetModeToggle` at the smaller filter scale ([Decisions §4](#decisions-made-during-the-visual-build)) so it sits quietly in the footer.

#### Footer — legal nav (added 2026-07-18, Phase 3 legal surface)

The footer gained a second, right-aligned row of legal links — **Privacy · Cookies · Sub-processors · Terms · Refunds** — so the legal surface is reachable from **every** page. A paid product has to keep Terms / Privacy / Refunds one click away (consumer-protection norms + Google/Meta ad review), and the footer is the site-wide home for that ([COMPONENTS.md § Footer](./COMPONENTS.md), [TERMS.md §3](./TERMS.md), [PAYMENTS.md §28](./PAYMENTS.md)).

**Drawn:** `legal-nav` row — **Light `844:2`** (labels `844:3`–`844:7`, inside `Footer / Light` `112:36`) · **Dark `844:1783`** (labels `844:1784`–`844:1788`, inside `Footer / Dark` `112:54`). Both screenshots above were re-exported from these nodes.

- **Layout:** a `HORIZONTAL` auto-layout row appended under `content`, `layoutSizingHorizontal = FILL`, `primaryAxisAlignItems = MAX` (right-aligned, sitting under the docs nav), `itemSpacing 16`, padding `[0, 24, 28, 24]`. `content.paddingBottom` was rebalanced `32 → 20` so the two rows read as one block rather than two stacked bars. The component grew **1200×99 → 1200×131**.
- **Type/colour:** 13px Inter Regular, matching the existing doc links — `#5A5B53` (Light) / `#A8A89C` (Dark). Fills are **hardcoded, not variable-bound**, which is correct here: the audit note above records that `Footer / Dark` and `ThemeSwitch / Dark` are intentionally built as separate Light/Dark component variants rather than one token-cascading component.
- **The two new legal pages need no frame of their own.** `/legal/terms` and `/legal/refunds` reuse the shared **Legal DocPage** pattern (`190:424` Light / `219:485` Dark) exactly as `/legal/cookies` and `/legal/sub-processors` do — "one representative screenshot covers the shared pattern" ([Pattern — Legal (privacy)](#pattern--legal-privacy-legalprivacy) below). The legal nav was the only genuinely-new affordance in that change.

> **Two pre-existing drifts recorded, deliberately not fixed here** (an additive legal-nav change shouldn't quietly rewrite unrelated parts of a shared component): (1) these Figma footers still carry the `ThemeSwitch`, but [COMPONENTS.md § Footer](./COMPONENTS.md) and the code (`src/components/layout/Footer.tsx`) moved it to `/account/settings` — Figma is the stale side; (2) the brand line reads `roamgap · roamgap.app`, while the live site renders `SITE_URL` (`roamgap.ike.work`). Both want a separate reconciliation pass.

> **MCP note (correction).** The index-0 caveat recorded above is **dead** — these edits committed directly to the Components page with no reorder dance, re-confirming [[figma-mcp-commit-behavior]]. Don't perform the workaround.

### Patterns — Home (Dark)

The home annual-planner screen recoloured to the dark theme — a clone of `Pattern · Home (Annual planner)` (`8:183`) with the `colour` variable collection set to its **Dark** mode, so every bound token cascades. The dark variant (`108:179`) sits beside the light one.

Both Home frames now also carry a **`Footer` instance** at the bottom (full-width, spanning the 1440 page), so the `ThemeSwitch` appears in context: the light Home uses `Footer / Light` (switch on "Light"), the dark Home uses `Footer / Dark` (switch on "Dark"). Adding the footer grew each frame from 1440×1805 to 1440×1904.

![Patterns — Home, Dark theme with footer](./figma/patterns-home-dark.png)

### Patterns — How it works (Light + Dark)

The concept-explainer page realised as a pattern, per [PROJECT.md §4.3](./PROJECT.md) and [COMPONENTS.md §Footer](./COMPONENTS.md). A full-width header (brand left, nav right), a 768-wide article column (eyebrow → `How it works` H1 → lead → sections for Efficiency, Trips & bridge days, the five Plan strategies, Budget mode, Regions, Holidays), and a full-width `Footer` instance at the bottom (brand left, links + `ThemeSwitch` right). Light `156:424`; Dark `156:463` (hex-remapped clone). Content paraphrases [docs/GLOSSARY.md](../docs/GLOSSARY.md) — the spec stays the source of truth.

![How it works — Light](./figma/how-it-works-light.png)

![How it works — Dark](./figma/how-it-works-dark.png)

### Follow-up Figma backlog — tracked in the ROADMAP

The spec-bound frames that can be drawn without further design decisions —
**EmptyState / ErrorState**, **Toast**, **Footer**, **DocPage**, **CalloutBox**,
and **Patterns / Find a trip** (each with its drawing recipe) — are *planning*,
so they live in [ROADMAP.md → Standing items → "Design / Figma backlog"](../../../ROADMAP.md),
not here. When one is drawn, record it in this file with a dedicated `### Name`
subsection (the node id + screenshot path, following the `SubdivisionPicker`
pattern) and drop its line from the ROADMAP backlog.

## What's still deferred past Slice 1

- `YearPlanCard / Spread Even` and `YearPlanCard / Single Long` — two additional strategy variants. The home pattern currently shows 3 of 5 and labels itself honestly. Slice 2 (annual planner code).
- `YearOverview` — the 12-month calendar visualization. The most visually complex component in the spec; Slice 2.
- Mobile responsive variants for every component. Patterns page only shows desktop (1440). Phase 1 follow-up slice.
- ~~Primitive components (`Button` hover/disabled states, `Input` focused/error states) for the Primitives page.~~ **Drawn 2026-06-15** — the Primitives page (`483:2`) now realises the full Button (Primary/Ghost) and Input interaction-state set, token-bound. See the [File organization](#file-organization-canvas-layout) Primitives bullet and [primitives.png](./figma/primitives.png).

---

## Phase 2 additions — Saved searches + frontend integration

Realises the Phase 2 saved-searches sub-slice and its end-to-end frontend integration (backend wire contract: [SAVED_SEARCHES.md](../../roamgap-backend/specs/SAVED_SEARCHES.md); client transport: [docs/ARCHITECTURE.md § Authenticated requests](../docs/ARCHITECTURE.md)). Per the strict `spec → Figma → code` order, each was drawn in the live library **before** the code.

| Component | Change | Target page | Status |
| --------- | ------ | ----------- | ------ |
| `SavedSearchesList` | Saved-search list — cards with per-row open + delete (icon → inline confirm), plus inline-confirm / deleting / empty / loading states. **Revamped 2026-06-25**: leading mode tile, human-readable place names, "Open" chevron, hover-lift card, count caption, 2-col grid. | Components | **Drawn (revamped)** — `148:2`. See [saved-searches-list.png](./figma/saved-searches-list.png). |
| ~~`SaveSearchButton`~~ → `AutosaveSearch` | **Removed (2026-06-23).** Searches now **autosave** with no button and no note ([COMPONENTS.md § AutosaveSearch](./COMPONENTS.md)); `AutosaveSearch` renders nothing. The old frame `185:424` ([save-search-button.png](./figma/save-search-button.png)) is kept only as the historical record of the retired affordance — a Stage-2 **recorded removal** (autosave has no surface to draw). | Components | **Removed** |
| `/account/saved` page | Page composition: TopNav (with the `AccountMenu` avatar slot) + "Saved searches" heading + `SavedSearchesList`. Realises [COMPONENTS.md § Account pages](./COMPONENTS.md). **Revamped 2026-06-25** — the embedded list now shows the count caption + the 2-col grid of revamped cards. | Patterns | **Drawn (revamped)** — `186:424` (Light), `219:451` (Dark). See [patterns-saved-searches.png](./figma/patterns-saved-searches.png) / [patterns-saved-searches-dark.png](./figma/patterns-saved-searches-dark.png). |
| `/sign-in` page, `SignInForm`, `AccountMenu` | **✅ Drawn in Milestone 0** (see the Milestone 0 auth-surface section below) — `SignInForm` `209:454`, `AccountMenu` `210:454`, `Pattern · Sign in` `215:451`. The `NavAuthSlot` swap remains spec-only (it renders the already-drawn "Sign in" pill or `AccountMenu`). | Components / Patterns | **Drawn** |

### SaveSearchButton — *removed (2026-06-23), kept for history*

> Superseded by `AutosaveSearch` (searches autosave; no button, no note). The screenshot below is the retired affordance, preserved as a record; it no longer appears on `/year`.

![SaveSearchButton — idle, saving, saved toast, and signed-out helper (RETIRED)](./figma/save-search-button.png)

`SaveSearchButton` frame (`185:424`), fills bound to `colour` (Light): **idle** — `surface/elevated` bordered button, `accent` bookmark glyph + "Save search"; **saving** — `surface/sunken`, `accent` 270° spinner + "Saving…", dimmed; **saved** — a `Toast` chip with a `status/success` accent bar + "Saved."; **signed-out** — the same button with the [VOICE.md §5.1](./VOICE.md) helper "Free searches live only in this tab. Sign in to keep them." in `text/faint`.

### Pattern — Saved searches (`/account/saved`)

![/account/saved page — TopNav with AccountMenu avatar, "Saved searches" heading, the list](./figma/patterns-saved-searches.png)

`Pattern · Saved searches` (`186:424`, Patterns page): the `/account/saved` route composition — a `TopNav` (wordmark, nav, the `AccountMenu` avatar in the right slot via `NavAuthSlot`), a `border` divider, the "Saved searches" heading + intro, a **count caption** ("2 saved searches"), and the `SavedSearchesList` **2-col grid** of revamped cards. This is the page `AccountMenu` → "Saved searches" routes to. The Dark twin is `219:451`.

### SavedSearchesList

![SavedSearchesList revamp — default 2-col grid, inline-confirm, deleting, empty, and loading states, Light theme](./figma/saved-searches-list.png)

`SavedSearchesList` frame (`148:2`, **revamped 2026-06-25**, 760×624), the states top-to-bottom, every fill bound to the `colour` variable collection (Light mode) so it cascades with the Tokens page. The card now has three zones — a **leading mode tile**, a **body**, and a **trailing open + delete cluster**:

- **default (2-col grid)** — two cards side by side (the responsive grid: 1 col on small, 2 from `lg`). Each card: `surface/elevated` fill, `border` hairline, 12px radius, `elevation-1` shadow. A 44px **mode tile** (`surface/sunken`, `border`, 8px radius) holds a `text/muted` glyph — a **calendar** for a Plan (`year`) search, a **paper-plane** for a Trip (`trip`) search. `name` in `text` Semibold; a mode badge pill (`surface/sunken` fill, `border` stroke, fully rounded) reading "Plan" / "Trip"; a `text/muted` summary using the **place display name** ("United States · 20 PTO · 2026", "Bavaria · 25 PTO · 2026" — never the raw `KE`/`DE-BY` code). Trailing: an **"Open" chevron** (`text/faint`) + a trash icon button. On hover (not capturable in a static frame) the card lifts to `elevation-2` + `border/strong` and `name` takes `accent`.
- **inline-confirm** — the card lifts to a `border/strong` stroke; the trailing controls are replaced by the prompt "Delete this search?" with a ghost "Cancel" and a `status/danger`-filled "Delete". No modal — the typed-confirmation modal is reserved for *account* deletion in `AccountMenu`, not per-item deletes.
- **deleting** — the whole card drops to 0.72 opacity; the trash button becomes an `accent` spinner ring with a `text/faint` "Deleting…".
- **empty** — the shared `EmptyState`: a **dashed** `border` card on `surface`, centred headline "No saved searches yet." (Medium) + body "Run a search while signed in and it shows up here automatically, on any device." (`text/muted`).
- **loading** — skeleton card: a `surface/sunken` tile placeholder + two `surface/sunken` bars.

A count caption ("2 saved searches", `text/muted`) sits above the grid.

> **MCP note.** Built via the official Figma MCP write path (`use_figma`, cloud — keyed by file id, not the desktop Dev Mode server). Two gotchas recorded for the next agent: (1) `vectorPaths` data rejects the `H`/`V` SVG shorthands — author icon paths with explicit `M`/`L`/`C`/`Q`/`Z` only. (2) Calling `node.resize()` **after** `primaryAxisSizingMode='AUTO'` resets the frame back to FIXED height (it stuck at 10px); set `AUTO` *after* the resize, then read `.height`. The frame is pinned to Light via `setExplicitVariableModeForCollection`; the Dark pattern `219:451` is pinned to Dark, and the same bound variables cascade.

---

## Milestone 0 additions — Account/DSAR + legal pages

Realises the Phase 2 close-out (DSAR account pages + the GDPR legal surface). Per the strict `spec → Figma → code` order, each screen is drawn in the live library before its code lands. *(The live file's top-level pages are currently `Patterns` and `Components`; the legal pattern is on `Patterns`, which committed directly — no index-0 workaround needed.)*

| Component / screen | Change | Page | Status |
| --- | --- | --- | --- |
| `/legal/privacy` page | Privacy-policy screen on the `DocPage` pattern (eyebrow → H1 → lead → What we collect / How long we keep it / Your rights / Cookies & consent / Who we share it with / Contact). Cloned from `Pattern · How it works` and re-texted from [PRIVACY.md](./PRIVACY.md). The cookies + sub-processors pages share this layout. | Patterns | **Drawn** — `190:424` (Light), `219:485` (Dark). See [patterns-legal-privacy.png](./figma/patterns-legal-privacy.png) / [patterns-legal-privacy-dark.png](./figma/patterns-legal-privacy-dark.png). |
| `CookieConsentBanner` | Bottom-anchored consent bar — copy + Manage / Reject / Accept all (equal weight, no dark pattern), fills bound to the colour tokens; reuses `Button / Ghost` + `Button / Primary` instances. | Components | **Drawn** — `195:441`. See [cookie-consent-banner.png](./figma/cookie-consent-banner.png). |
| `AccountSettingsForm` | Profile-rectification card — Display name / Language / Country / Region fields, a `ThemeSwitch`, and a "Save changes" primary button. Fills bound to the colour tokens. | Components | **Drawn** — `199:444`. See [account-settings.png](./figma/account-settings.png). |
| `DataExportPanel` | DSAR access — "Download my data" card with a manifest summary (profile bundled; analytics/error logs linked) and a primary download button. | Components | **Drawn** — `202:451`. See [account-data.png](./figma/account-data.png). |
| `DeleteAccountForm` | DSAR erasure — typed-email confirmation card (heading, 30-day note, email-match field, `status/danger` "Delete my account" button). No dark patterns. | Components | **Drawn** — `203:452`. See [account-delete.png](./figma/account-delete.png). |
| `RestoreAccountInterstitial` | Soft-delete undo — centered card with the days-left note, "Restore my account" (Primary) + "Keep deletion" (Ghost). Where a `403 account_deleted` sign-in lands. | Components | **Drawn** — `205:452`. See [account-restore.png](./figma/account-restore.png). |

### Pattern — Legal (privacy) (`/legal/privacy`)

![/legal/privacy — DocPage layout: LEGAL · PRIVACY eyebrow, "Privacy" H1, lead, and six sections, with header + footer](./figma/patterns-legal-privacy.png)

`Pattern · Legal (privacy)` (`190:424`, Patterns page, 1440×1356): the `/legal/privacy` route composition, built on the same `DocPage` column as `/about/how-it-works` (header, 768-wide article, `Footer`). The six sections paraphrase [PRIVACY.md](./PRIVACY.md) — *What we collect* (§2/§13.1), *How long we keep it* (§13.5), *Your rights* (§6/§13.2 — the five DSAR rights as a list), *Cookies & consent* (§4/§5), *Who we share it with* (§3), and *Contact*. The sibling `/legal/cookies` and `/legal/sub-processors` pages reuse this layout, swapping the body for the §4 cookie table and the §3 sub-processor register; one representative screenshot covers the shared pattern (as `how-it-works` does for the doc pattern). The spec stays the source of truth — see [COMPONENTS.md § Legal pages](./COMPONENTS.md).

### CookieConsentBanner

![CookieConsentBanner — bar with consent copy and Manage / Reject / Accept all buttons](./figma/cookie-consent-banner.png)

`CookieConsentBanner` (`195:441`, Components page, 880×80): the GDPR gate. A horizontal auto-layout bar — `surface/elevated` fill, `border` hairline, 12px radius — with the consent copy on the left (`text/muted`) and an actions row on the right: **Manage** and **Reject** (`Button / Ghost` instances) and **Accept all** (`Button / Primary`, `accent`). Accept and Reject carry equal visual weight — no dark pattern ([VOICE.md §2](./VOICE.md), [COMPONENTS.md § Consent](./COMPONENTS.md)). Fills bind to the `colour` collection so it cascades Light/Dark. Mounted globally in `src/app/layout.tsx`; shows only when no consent decision is stored and the visitor isn't under DNT/GPC.

### AccountSettingsForm

![AccountSettingsForm — Display name, Language, Country, Region fields, a Theme switch, and a Save changes button](./figma/account-settings.png)

`AccountSettingsForm` (`199:444`, Components page): the `/account/settings` profile form (DSAR "Rectification"). A `surface/elevated` card with labelled `surface/sunken` inputs for the `UserPatch` fields (display name, locale, country, subdivision), a `ThemeSwitch` instance for the client-only theme, and a `Button / Primary` "Save changes". Saving sends only the changed fields via `PATCH /me`; subdivision is gated on a country ([USERS.md §3.4](../../roamgap-backend/specs/USERS.md)). See [COMPONENTS.md § AccountSettingsForm](./COMPONENTS.md).

> **Updated 2026-06-09 — default calendars (Phase 3).** A `field/Default calendars` section was inserted between Region and Theme in the auto-layout form: a "Your default calendars" header + "Applied to new searches." sub-line + a clone of the `CalendarOverlayPicker` (`314:499`, cloned from `308:504` — the picker is a frame, not yet a component, so it is cloned rather than instanced). This persists the user's default `observedCalendars` via `PATCH /me` ([CALENDAR_OVERLAYS.md §8](../../backend/specs/CALENDAR_OVERLAYS.md)). The form re-hugs to 460×822. Audit: every paint in the section binds to the `colour` collection (paint-level bindings were healed on 2026-06-10 — see the token-binding gotcha below); the only intentionally-hardcoded paints are inside the pre-existing `ThemeSwitch / Light` variant (documented above). [account-settings.png](./figma/account-settings.png) refreshed from `199:444` (2026-06-10); the form now hugs to 460×868. **Resolved (Stage 2, 2026-06-10):** the old checkbox clone `314:499` was removed and replaced by a clone of the unified `observedCalendars` picker (`368:23`), so the section now matches the one-list model — see the ⚠ banner under [Phase 3 — Calendar overlays surface](#phase-3--calendar-overlays-surface-added-2026-06-09).

### DataExportPanel

![DataExportPanel — Download my data card with lead, manifest summary, and a download button](./figma/account-data.png)

`DataExportPanel` (`202:451`, Components page, 460×216): the `/account/data` DSAR "Access" surface. A `surface/elevated` card — title, lead, an "includes" manifest line (profile bundled; analytics + error logs linked, per [PRIVACY.md §13.3](./PRIVACY.md)), and a `Button / Primary` "Download my data" that triggers `POST /me/export` and downloads the `ExportEnvelope` JSON. See [COMPONENTS.md § DataExportPanel](./COMPONENTS.md).

### DeleteAccountForm

![DeleteAccountForm — heading, 30-day warning, type-your-email field, and a red Delete my account button](./figma/account-delete.png)

`DeleteAccountForm` (`203:452`, Components page, 460×245): the `/account/delete` DSAR "Erasure" surface. A `surface/elevated` card — heading, a plain warning with the 30-day hard-delete note, a "Type your email to confirm" `surface/sunken` field (the confirm button stays disabled until the typed value matches the account email), and a `status/danger` "Delete my account" button. No countdown, no guilt, no last-chance offer ([PRIVACY.md §13.8](./PRIVACY.md)). See [COMPONENTS.md § DeleteAccountForm](./COMPONENTS.md).

### RestoreAccountInterstitial

![RestoreAccountInterstitial — Restore your account? with the days-left note and Restore / Keep deletion buttons](./figma/account-restore.png)

`RestoreAccountInterstitial` (`205:452`, Components page, 460×196): the `/account/restore` undo-deletion surface ([PRIVACY.md §13.4](./PRIVACY.md)). A centered `surface/elevated` card — "Restore your account?", the days-left note, and `Button / Primary` "Restore my account" + `Button / Ghost` "Keep deletion". This is where a `403 account_deleted` from the sign-in exchange routes (not a generic error). Restore mints an anonymous Bearer and calls `POST /me/restore`; Keep signs out. See [COMPONENTS.md § RestoreAccountInterstitial](./COMPONENTS.md).

### Auth surface + account-page patterns

Closes the Stage-2 gap for the sign-in/account surface: the two auth **components** that previously had code but no design, and the assembled **page Patterns** for every account route (each = the `/account/saved` shell — `TopNav` + heading + the page's component). The account-page Patterns reuse the components above; the page screenshots are the assembled compositions.

| Artifact | Page | Node | Screenshot |
| --- | --- | --- | --- |
| `SignInForm` | Components | `209:454` | [sign-in-form.png](./figma/sign-in-form.png) |
| `AccountMenu` (open) | Components | `210:454` | [account-menu.png](./figma/account-menu.png) |
| `Pattern · Sign in` (`/sign-in`) | Patterns | `215:451` (L) · `219:611` (D) | [patterns-sign-in.png](./figma/patterns-sign-in.png) / [dark](./figma/patterns-sign-in-dark.png) |
| `Pattern · Account settings` (`/account/settings`) | Patterns | `211:441` (L) · `219:525` (D) | [patterns-account-settings.png](./figma/patterns-account-settings.png) / [dark](./figma/patterns-account-settings-dark.png) |
| `Pattern · Download my data` (`/account/data`) | Patterns | `212:448` (L) · `219:559` (D) | [patterns-account-data.png](./figma/patterns-account-data.png) / [dark](./figma/patterns-account-data-dark.png) |
| `Pattern · Delete my account` (`/account/delete`) | Patterns | `213:449` (L) · `219:576` (D) | [patterns-account-delete.png](./figma/patterns-account-delete.png) / [dark](./figma/patterns-account-delete-dark.png) |
| `Pattern · Restore account` (`/account/restore`) | Patterns | `214:449` (L) · `219:596` (D) | [patterns-account-restore.png](./figma/patterns-account-restore.png) / [dark](./figma/patterns-account-restore-dark.png) |

![/sign-in — TopNav, "Sign in" heading, and the SignInForm card](./figma/patterns-sign-in.png)

`SignInForm` (`209:454`) — Continue-with-Google button, an "or sign in with email" divider, Email + Password fields, and the `accent` "Sign in" button ([COMPONENTS.md § SignInForm](./COMPONENTS.md)). `AccountMenu` (`210:454`) — the avatar trigger above the open popover (email header → Saved searches / Settings / Download my data / Delete my account in `status/danger` → Sign out), per [COMPONENTS.md § AccountMenu](./COMPONENTS.md). The five `Pattern ·` frames are the assembled routes under [COMPONENTS.md § Account pages](./COMPONENTS.md).

---

## v1.1 additions — 404 + About overview (added 2026-06-10)

Two **Planner & Marketing** route patterns that close long-standing dangling references: the top nav, footer, [`sitemap.ts`](../src/app/sitemap.ts), and [`llms.txt`](../public/llms.txt) all pointed at `/about`, and unmatched URLs fell through to Next's bare default 404. Both are realised on the **Patterns** page (`1:5`) as standalone Light frames in clear space to the right of the existing sections (a follow-up can fold them into the *Planner & Marketing* Figma Section). Every fill and stroke binds to the `colour` collection, so the Dark mirror is one mode-switch away. Per the strict `spec → Figma → code` order, both were drawn here before the code landed — spec: [ERROR_STATES.md §2.18](./ERROR_STATES.md) (404), [PROJECT.md §4.5](./PROJECT.md) (About), [VOICE.md §2](./VOICE.md) (404 tone).

| Pattern | Page | Node | Screenshot |
| --- | --- | --- | --- |
| `Pattern · 404 (Not found)` | Patterns | `353:478` (1440×752) | [not-found.png](./figma/not-found.png) |
| `Pattern · About (product overview)` | Patterns | `360:496` (1440×1120) | [about.png](./figma/about.png) |

### Pattern — 404 (Not found)

![404 — paper-plane mark in accent, "This page isn't on the map.", recovery actions](./figma/not-found.png)

`Pattern · 404 (Not found)` (`353:478`): the route-not-found surface from [ERROR_STATES.md §2.18](./ERROR_STATES.md), built on the standard `AppShell` (header + centered hero + `Footer / Light` instance). The hero stacks the `404 · PAGE NOT FOUND` eyebrow (Geist Mono, `text/faint`, 1.5px tracking), the **paper-plane mark** — the brand mark's plane (a Lucide-`send` vector, stroke bound to `accent` so it cascades) "taking off" up-and-right — the `This page isn't on the map.` headline (Geist SemiBold 33, `text`), the body line, and the actions row: a `Button / Primary` "Plan your year" beside the calm inline links "Find a trip · Calendar". The "travel metaphor, calm" direction — on-brand, never cute ([VOICE.md §1](./VOICE.md)). noindex, no Markdown twin (an error surface, not durable content).

### Pattern — About (product overview)

![About — "What roamgap does." with the idea, three tools, free-tier honesty, machine-readability, and where-to-go-next cards](./figma/about.png)

`Pattern · About (product overview)` (`360:496`): the `/about` hub from [PROJECT.md §4.5](./PROJECT.md), on the same `DocPage` column as `/about/how-it-works` (header → 768-wide article → `Footer`), with **About** the active nav pill. The article runs an `ABOUT · ROAMGAP` eyebrow, the `What roamgap does.` H1, a one-sentence lead, then four sections — *The idea* (the core insight + efficiency), *Three ways to use it* (Plan year / Find a trip / Calendar, each a bold lead term), *What's free, honestly* (the free tier + the [VOICE.md §5.1](./VOICE.md) session-only honesty line), and *Built to be read by machines* (Markdown twins + `llms.txt`) — and closes on a **Where to go next** row of three `surface/elevated` link cards (How it works · URL format · Public stats). It paraphrases the specs; it does not redefine any term.

> **MCP note.** Drawn via the official cloud Figma MCP (`use_figma`, keyed by file id). The index-0-page commit quirk ([[figma-mcp-commit-behavior]]) **did** apply — the Patterns page was temporarily reordered to index 0 for the writes, then restored to index 1. Token binding via `setBoundVariableForPaint` needs the full `VariableID:…` id, not the bare `1:N`; the short form silently no-ops to a black fill (hit and fixed during this build — a useful tripwire for the next agent).

---

## Frames vs components — the Phase-2 account/auth surface

> **Confirmed and reconciled against the live file on 2026-06-04** via the Figma MCP. Records the frames-vs-components decision for the Phase-2 account/auth/DSAR/saved-search artifacts and the as-built state after the last two local button frames were promoted to component instances.

**Page structure.** The `Components` page GUID is `1:4` (the `3:2` in the Pages table is the *Light inventory* root frame, not the page). It now holds three columns:

| Column | Node | What it is |
| --- | --- | --- |
| Inventory · Light | `3:2` ("Components root") | the Phase-1 component instances, Light mode |
| Inventory · Dark | `228:491` ("Components root · Dark") | the same root with the `colour` collection set to Dark |
| Source components | x ≈ 3400 | the reusable component/variant **definitions** that instances reference, plus the Phase-2 account/auth/DSAR/saved-search artifacts |

**The decision.** The library rule is **reused atoms/molecules are Figma components; screen-level compositions are frames.**

- **True components** (`<symbol>` / variant sets, instanced where used): `Button / Primary` · `Button / Ghost` · `Button / Danger` (`249:491`), `ThemeSwitch / Light` · `/ Dark`, `Footer / Light` · `/ Dark`, plus every Phase-1 atom (`ScoreBadge`, `DayCell`, `TripCard`, `PlanLabelBadge`, `BudgetUtilisationBar`, `YearPlanCard`, the inputs, `SubdivisionPicker`).
- **Intentional frames** (one-off screen compositions, each mapping 1:1 to a route Pattern, never instanced elsewhere): `SignInForm` (`209:454`), `AccountMenu` (`210:454`), `AccountSettingsForm` (`199:444`), `DataExportPanel` (`202:451`), `DeleteAccountForm` (`203:452`), `RestoreAccountInterstitial` (`205:452`), `CookieConsentBanner` (`195:441`), `SavedSearchesList` (`148:2`), `SaveSearchButton` (`185:424`). Componentising these would add indirection with no reuse payoff. The conversions in progress correctly wired them to *instance* the shared primitives — `CookieConsentBanner`, `AccountSettingsForm`, `DataExportPanel`, and `RestoreAccountInterstitial` all use `Button` / `ThemeSwitch` instances.

**Residual cleanups — resolved 2026-06-04 (live Figma edits via MCP).**

1. **`Button / Danger` component created** (`249:491`) — cloned from `Button / Primary`, re-skinned to the `status/danger` fill (`VariableID:1:28`) and a rounded-full radius to mirror the app's `rounded-full bg-status-danger` button. Sits in the Button row beside `Button / Primary` and `Button / Ghost`.
2. **`DeleteAccountForm` danger button → instance** (`250:491` → `Button / Danger`), label override "Delete my account"; the old local frame `203:459` is removed.
3. **`SignInForm` primary button → instance** (`251:492` → `Button / Primary`) with full-width + centered + radius-8 overrides that preserve the screen's look, label override "Sign in"; the old local frame `209:467` is removed. (`Button / Google`, `209:456`, stays a bespoke frame — there is no Google-button component; acceptable as a one-off.)

The account/auth screens are now frames composing only component instances. **One design note for a designer, not a bug:** the base `Button / Primary` / `Button / Ghost` masters are radius-4 while the app's account-surface buttons are `rounded-full`, so `Button / Danger` (rounded-full) and the `SignInForm` instance (radius-8 override) deliberately diverge from those masters. Unifying the Button family's radius to the app is a single design decision, out of scope for this hygiene pass.

---

## Brand / Assets

> Stage-2 realisation of [BRANDING.md](./BRANDING.md). The brand artwork itself is supplied as committed PNGs (`public/logo.png`, `public/logo-text.png`); this board registers the **derived asset set** and the **social-card layout** — the two decisions that have visual surface. Live on the **Patterns** page (`8:183`) as a `Brand / Assets` board, node `294:478` (intentional frame, not a component — it is documentation, never instanced).

![Brand / Assets — the icon-set summary and the 1200×630 Open Graph template with its TEMPLATE ribbon](./figma/brand-assets.png)

- **Icon set** (derived by `npm run brand:emit`): favicon 16/32/48 (**transparent**, from `logo-transparent.png` — [BRANDING.md §3.3](./BRANDING.md)), plus `icon` 512, `apple-icon` 180, manifest 192 + 512, maskable 512 with safe-area padding (these from the opaque `logo.png`, flattened onto a white tile). The board lists these as the contract; the binaries live under `src/app/` and `public/icons/` per [BRANDING.md §2](./BRANDING.md).
- **Open Graph / Twitter template** (`opengraph-image.png` · `twitter-image.png`, 1200×630): `surface` background, the `roamgap` wordmark, the `Smart Holiday Optimizer` tagline, and a mandatory `accent` **"TEMPLATE — replace per specs/BRANDING.md"** ribbon. The ribbon is the un-shippable-by-accident guard from [BRANDING.md §4.1](./BRANDING.md) — a social preview that literally reads "TEMPLATE" is its own pre-launch alarm. Replacing the art removes the ribbon ([BRANDING.md §8 Path B](./BRANDING.md)).

This board is **not** an `opengraph-image` source of truth — the committed PNG under `src/app/` is. It is the visual review surface: it's where a designer iterates the social-card layout that seeds the scaffolded placeholder. Final committed social art is hand-supplied and is **not** regenerated by `brand:emit` once it exists ([BRANDING.md §7](./BRANDING.md)).


---

# Accessibility

_Source: `specs/ACCESSIBILITY.md`_

# Accessibility Specification

> roamgap targets **WCAG 2.2 Level AA** conformance. This document is the operational contract — what every component must satisfy. Failing accessibility is failing the spec.

## 1. Conformance target

- **WCAG 2.2 Level AA**, every public page and every component shipped to users.
- Single-page-app interactions (the optimizer) additionally follow the [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) patterns where one exists for the widget.
- Automated check: `axe-core` runs as part of the Playwright suite ([TESTING.md](../docs/TESTING.md)). Manual check: NVDA on Firefox and VoiceOver on Safari, listed in [ARCHITECTURE.md](../docs/ARCHITECTURE.md).

## 2. Colour contrast budget

Tighter than WCAG minimums, because numbers matter for trust here.

| Surface | Text | Target ratio | WCAG min |
| ------- | ---- | ------------ | -------- |
| `surface` / `surface-elevated` | `text` | ≥ 12:1 | 4.5:1 |
| `surface` | `text-muted` | ≥ 7:1 | 4.5:1 |
| `surface` | `text-faint` | ≥ 4.5:1 | 4.5:1 (allowed at AA only for disabled controls) |
| `accent` | `accent-contrast` | ≥ 4.5:1 | 4.5:1 |
| `surface` | `accent` (link text) | ≥ 4.5:1 | 4.5:1 |
| Any | `focus-ring` border | ≥ 3:1 | 3:1 |
| `day-*` cells | day number text | ≥ 4.5:1 | 4.5:1 |

The recommended palette in [DESIGN_SYSTEM §2.3](./DESIGN_SYSTEM.md) is tuned to these. If a future retune ships, run the contrast check as part of the same PR.

## 3. Colour is never the only signal

Every classification rendered as colour also carries a non-colour signal:

| Element | Non-colour signal |
| ------- | ----------------- |
| Day kind in `CalendarStrip` | Single-letter label inside the cell (`W`, `H`, `P`, `B`, `*` for bridge) plus a `title` attribute |
| Day kind in `MonthGrid` / `YearCalendar` | Single-letter label inside the cell (`W` weekend, `H` holiday) plus a `title`; workdays carry no colour, so no code is required |
| `ScoreBadge` | Numeric value (the colour is supportive, not the message) |
| `Toast` kind | Icon + role (`status` / `alert`) |
| Form error | Text below the field, plus `aria-invalid` + `aria-describedby` |
| Status of save / share | Toast text — never a bare green tick |

A simulated colour-blind viewer (deuteranopia, protanopia, tritanopia) must be able to consume every screen.

## 4. Keyboard

### 4.1 Global
- Every interactive element is reachable via `Tab` in DOM order.
- `Esc` closes modals, dropdowns, and toasts.
- `Cmd/Ctrl + K` opens the country picker if the page has one (Phase 1+).
- Skip-link: first focusable element on every page is "Skip to main content" linking to `#main`.

### 4.2 `CalendarStrip`
The strip is a single composite widget — a **2-D grid of `role="row"` weeks** (the trip's days stacked into 7-column weeks, the same shape as `MonthGrid` §4.6). Apply [WAI-ARIA Grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) with roving tabindex.

- `Tab` moves into the grid (focus lands on the first day); `Tab` again exits to the next widget.
- Once inside, `Arrow Left/Right` moves day-by-day; `Arrow Up/Down` moves week-by-week (7 days); `Home` / `End` move to first/last day of the trip. Arrow keys **move DOM focus**, not just the tab stop.
- The leading/trailing **padding blanks** that align the first and last weeks are `aria-hidden` and never focusable.
- The shared **weekday-label row at the bottom** is decorative (`aria-hidden`): each cell already announces its own weekday, so the labels add no screen-reader noise.
- Each cell announces: "March 14 2026, Saturday, weekend" — date + weekday + kind + (if PTO) "1 PTO day".

### 4.3 `CountryPicker`
[WAI-ARIA Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) — editable, with listbox popup.

- `Down Arrow` opens the popup and focuses the first option.
- Typing filters; `Down/Up` moves through filtered results.
- `Enter` selects; `Esc` closes.

### 4.4 `FilterPanel`
Disclosure pattern when collapsed; standard form when open. `Space` / `Enter` toggles the disclosure button (`aria-expanded` + `aria-controls`). The `inline` variant ([COMPONENTS.md § FilterPanel](./COMPONENTS.md#filterpanel--phase-1--slice-1-code--revamped-phase-2)) omits the disclosure — the host (the planner's Advanced disclosure) owns the collapse.

- **Month / weekday chips** are a multi-select group of native checkboxes styled as toggle chips. The native `<input type="checkbox">` stays in the DOM (visually hidden), so `Tab` reaches each chip, `Space` toggles it, and `aria-checked` is announced. The selected state carries a non-colour signal — a check glyph, not just the `accent` fill (§3).
- **Blocked-date** remove buttons and the **Clear filters** button are real `<button>`s with accessible names ("Remove blocked dates 14 Mar – 20 Mar", "Clear filters").
- Chips meet the 24 × 24 px minimum target size (§8).

### 4.5 `ThemeSwitch`
Segmented [radio group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) of three options (System / Light / Dark). The container is `role="radiogroup"` with `aria-label="Theme"`; each option is `role="radio"` with `aria-checked` reflecting the active choice.

- `Tab` moves focus into the group, landing on the active option.
- `Arrow Left/Right` (and `Up/Down`) move the selection between options.
- The active choice is conveyed by `aria-checked` **and** a non-colour visual signal (the active segment lifts onto `surface-elevated`) — colour is never the only signal, per §3.

### 4.6 `YearCalendar` / `MonthGrid`
Each `MonthGrid` is an independent composite widget following the [WAI-ARIA Grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) — a 2-D grid of day cells with roving tabindex (one tab stop per month).

- `Tab` moves into a month's grid (focus lands on the first day); `Tab` again exits to the next widget.
- `Arrow Left/Right` move day-by-day; `Arrow Up/Down` move week-by-week (7 days). `Home` / `End` move to the first / last day of the month. Padding cells (the leading/trailing blanks) are not focusable.
- Each day cell announces date + weekday + kind, and the holiday name when present: "March 16 2026, Monday, holiday — Eid al-Fitr".
- **Year navigation** is a pair of links ("Previous year" / "Next year") that change `year` in the URL. The **month pager** (narrow viewports only) is prev/next `<button>`s plus a labelled month `<select>` ("Jump to month"); paging is client-side and never changes focus order on wide viewports where all months are shown.
- The view is operable and complete with keyboard and with JavaScript disabled (all twelve months render server-side; the pager is progressive enhancement).

### 4.7 `TopNav` overflow menu (`NavMenu`)
The mobile overflow control ([COMPONENTS.md § NavMenu](./COMPONENTS.md)) follows the [WAI-ARIA Disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) — the same family as the `FilterPanel` collapse (§4.4) — **not** a `role="menu"`: the revealed items are ordinary navigation links, which screen readers should announce and operate as links.

- The trigger is a `<button>` with `aria-expanded` reflecting open state and `aria-controls` pointing at the revealed list; its accessible name is "Menu" (the ☰ glyph is `aria-hidden`).
- `Space` / `Enter` toggles. When open, `Tab` moves through the links in DOM order; `Esc` closes and returns focus to the trigger; a pointer-down outside also closes.
- The list is plain links (one per primary destination); the active destination carries `aria-current="page"`, the same active signal `TopNav` uses inline.
- Only rendered below `sm`; at `sm`+ the inline links are the navigation and the trigger is absent (`sm:hidden`), so there is never a duplicate tab stop for the same destination at a given viewport.

## 5. Focus

- A visible focus ring is mandatory on every focusable element.
- We use `:focus-visible`, not `:focus` — pointer interactions don't get an outline.
- The ring is the `focus-ring` token (DESIGN_SYSTEM §2.2): a 2 px solid outline with 2 px offset.
- No `outline: none` without a replacement. Lint rule enforces this (ESLint plugin `jsx-a11y` rule `no-noninteractive-element-interactions` is on).

## 6. Screen reader patterns

### 6.1 Landmark roles
Every page has `header`, `main` (id=`main`), `footer`. Sidebar (if introduced later) is `aside`. The optimizer flow lives inside `main`; results inside `section[aria-labelledby="results-heading"]`.

### 6.2 Live regions
- `Toast` notifications: `role="status"` for `success`/`warn`, `role="alert"` for `danger`.
- Optimizer loading: an off-screen `<span aria-live="polite">` announces "Calculating trips…" on submit and "5 trips found" when done.
- Network errors: announced via `role="alert"` so they interrupt the current reading.

### 6.3 Labelling
- Every `input`, `select`, `button` has an accessible name. Buttons containing only an icon have `aria-label`.
- Form fields use real `<label>` elements, not just placeholder text.
- Error text is associated via `aria-describedby`.

## 7. Motion

- `prefers-reduced-motion: reduce` collapses all transitions to instant. See [DESIGN_SYSTEM §7.3](./DESIGN_SYSTEM.md).
- No autoplay video. No parallax. No automatic carousels.

## 8. Touch targets

Minimum 24 × 24 CSS px for any interactive element (WCAG 2.2 SC 2.5.8 — Target Size, Minimum). Recommended 44 × 44 px on primary actions. Day cells in `CalendarStrip` may visually be 32 × 32 px on desktop but must expand to 44 × 44 px on touch viewports (`@media (pointer: coarse)`).

## 9. Forms

- Errors are surfaced on blur, not on every keystroke (avoid pointless harassment).
- Errors describe what to do, not just what went wrong. "Enter a country code like KE or DE", not "Invalid input."
- Required fields are marked with `aria-required="true"` and visible text "(required)" — not just an asterisk.

## 10. Testing

- **Automated:** `axe-core` injected by Playwright on every E2E test for the public pages. Failures break the build.
- **Component-level:** `@testing-library/react` queries default to accessible-name-first (`getByRole`, `getByLabelText`). Tests written using these queries are themselves a check that components are accessible.
- **Manual cadence:** NVDA + Firefox once per phase release; VoiceOver + Safari once per phase release. Log issues in `docs/A11Y_AUDIT_LOG.md` (TBD — created when the first audit lands).

## 11. What we won't do

- We won't use `tabindex` greater than 0. Ever.
- We won't disable browser zoom.
- We won't suppress system focus rings without replacing them.
- We won't ship `aria-label`s that lie (e.g. labelling a "Close" button "Open").


---

# Voice & copy

_Source: `specs/VOICE.md`_

# Voice & Copy Specification

> The product is mostly numbers and dates. The voice around them is what makes the experience feel human and trustworthy. This document is the contract for every string the user reads — UI labels, microcopy, error messages, doc pages, marketing, and AI-generated paragraphs.

## 1. Voice in five adjectives

| Are | Are not |
| --- | ------- |
| Clear | Cute |
| Practical | Aspirational |
| Quietly confident | Bossy |
| Warm | Saccharine |
| Honest about limits | Hedging |

We use travel words — trips, itineraries, destinations — because that's what this is; but we earn them with specifics, not hype. If a sentence sounds like a flight-deal popup ("Escape now!"), it's wrong. If it sounds like a tax form, it's also wrong.

## 2. Tone by context

| Context | Tone | Example |
| ------- | ---- | ------- |
| Empty state — no inputs yet | Inviting, brief | "Pick a country to start." |
| Empty state — no results | Direct, suggests an action | "No trips match these filters. Try fewer month restrictions." |
| Success toast | Quiet, past-tense | "Link copied." |
| Warn — historical-data uncertainty | Honest, never predictive | "Last year, a holiday was declared here. It may or may not repeat." |
| Error — network | Calm, action-oriented | "Couldn't reach our holiday data. Try again in a moment." |
| Error — page not found (404) | Calm, gently on-brand, never a dead end | "This page isn't on the map. Here's the way back." |
| Error — validation | Specific about the fix | "Enter a country code like KE or DE." |
| Marketing — landing | Confident, no superlatives | "Find the best time to take time off." |
| Doc page lead | Plain and short | "A bridge day is a single working day between a holiday and a weekend." |
| Legal page lead | Plain, factual, no legalese theatre | "We keep your account data until you delete it." |
| Cookie consent banner | Plain, symmetric — Accept and Reject carry equal weight | "We use cookies for analytics. Accept, or reject and we won't set them." |
| Account deletion | Neutral, no guilt, no last-chance offer | "Delete my account. This removes your data; hard-delete in 30 days." |

**Never** use "Oops," "Whoops," or "Uh oh." We don't apologise unless we broke something, and even then we describe what happened.

## 3. Terminology — canonical terms

When multiple words mean the same thing, we pick one and stick with it across UI, code, docs, marketing. **Mixing terms in one locale's copy is a bug.** (One term per concept *per market* — the leave term is the single deliberate exception, §3.2.)

| Canonical | Use it for | Don't use |
| --------- | ---------- | --------- |
| **Leave** / **annual leave** *(en-KE, Commonwealth)* · **PTO** *(en-US)* — **per-market, §3.2** | The paid days a worker spends to take time off — the budget the optimizer spends down | "vacation days," "holiday" (collides with **Holiday**), "time in lieu," "AL"; and **mixing "leave" and "PTO" within one locale's copy** |
| **Trip** | A continuous calendar range of days off — weekends + public holidays + the PTO you'd spend, all in a row | "window" (the former term — now retired), "break," "getaway," "stretch" / "date stretch," "vacation" (pick one word, and it's "trip") |
| **Long trip** | A Trip of 7+ calendar days — the unit the `longTripCount` control targets ([ALGORITHM.md §11.10](./ALGORITHM.md)). New copy says "Long trips" (e.g. the Advanced-settings stepper). The matching plan is still surfaced under its historical badge "Longest breaks"; realigning that one label is tracked separately, not in this change. | "long break," "long getaway," "long holiday" in **new** copy — it's a *Trip* (see the Trip row) |
| **Efficiency** | Total days ÷ PTO days | "score," "ratio," "value" |
| **Bridge day** | A working day flanked by non-working days | "pivot day," "leverage day" |
| **Substitute (holiday)** | The observed shift of a weekend holiday | "observed," "in lieu" |
| **Estimated** | A holiday date that is a forward-looking projection, not yet authoritative (lunar/observed calendars) | "tentative," "approximate," "TBD," "predicted" |
| **Workweek** | The country's working-day pattern | "work schedule," "weekdays" |
| **Holiday** | A public/statutory non-working day | "day off" (ambiguous), "vacation" (user-side) |
| **Country** | The geographic + legal scope of holiday data | "region" (we use this for sub-national) |
| **Region** | Sub-national jurisdiction (state, county) | "area," "district" |
| **roamgap Plus** | The single paid plan (PAYMENTS.md §4) — always two words, "Plus" capitalised | "Pro," "Premium," "the paid plan," "Plus plan" |
| **Saved search** (paid) | A persisted optimizer query | "favourite" (saccharine) |
| **Itinerary** | A user-curated collection of selected Trips, combined into one plan with overlap detection | "board," "collection" for the grouping; never call a single Trip an "itinerary" |
| **Holiday league** | The cross-country comparison of public-holiday counts and how they fall ([HOLIDAY_LEAGUE.md](./HOLIDAY_LEAGUE.md)) | "leaderboard," "rankings," "chart" (we use "league" / "league table") |
| **Lost to a weekend** | A public holiday that falls on Saturday or Sunday | "wasted," "missed" (judgemental); pair it with the substitute count so it stays honest (§1) |
| **Lands on a working day** | A public holiday that falls Mon–Fri — one you actually get off | "useful holiday," "good holiday" (loaded) |
| **PTO leverage** | The holiday-league efficiency board's framing of days-off-per-leave-day — *where a day of leave stretches furthest* ([HOLIDAY_LEAGUE.md §4.4](./HOLIDAY_LEAGUE.md)). The same ratio as **Efficiency** above, in the league's comparative register (cf. the **Person**/**Member** surface split); "leverage" here is the algorithmic sense the §6 jargon rule permits. | "leverage" for anything non-algorithmic; "score," "multiplier," "ROI" |
| **Head-to-head** | A two-country public-holiday comparison — *"{A} vs {B}: who gets more (and better) days off in {year}?"* ([HOLIDAY_LEAGUE.md §15](./HOLIDAY_LEAGUE.md)) | "battle," "showdown," "winner-takes-all" (combative); "vs" is fine in a title/URL, not as the concept name |
| **Overlap** | The multi-country feature, and a single computed shared break — *"when can we all be off together?"* ([MULTI_COUNTRY.md](./MULTI_COUNTRY.md)) | "match," "sync," "intersection," "common dates" |
| **Person** | One participant in an overlap — UI says **person** (or "you" / their name) | "party" (the schema/code term `OverlapParty`, never UI), "member" (a Groups term), "user," "traveller" |
| **Shared break** | A Trip that works for everyone in an overlap | "joint trip," "group trip" (the Groups register), "couple trip" |
| **Group** | The Phase-4½ social-planning unit — a crew coordinating one trip over the overlap engine ([GROUPS.md](./GROUPS.md)) | "team" (a Phase-6 B2B word), "party" (the schema term), "trip" (a Group plans a trip; it isn't one) |
| **Organiser** | The person who creates a group and pays for it — "one organiser pays, the group rides free" (GD-3). UI: **organiser** | "owner," "admin," "host," "captain" |
| **Member** | A person who joined a group (incl. the organiser). UI: **member** (or their name / "you") | "participant," "guest," "attendee"; not "person" (that is the *overlap* register, above) |
| **Invite link** | The shareable `/g/{id}` link that lets someone see the plan and sign in to join | "share link" (ambiguous), "join code," "magic link" |
| **Comment** | One async message on a group ([GROUPS.md §8](./GROUPS.md)) — *not* a chat message | "chat," "message," "post," "reply" (there is no threading in v1) |
| **Poll** | The group's date poll — approval-vote on candidate windows ([GROUPS.md §9](./GROUPS.md)) | "vote" (the action is voting; the thing is a poll), "survey," "ballot" |

**On the travel register.** roamgap is a travel product: it finds the best time to take time off, then recommends where to go. We use travel language deliberately — a single continuous break is a **Trip**, and a curated set of Trips is an **Itinerary**. This *reverses* the project's earlier "no travel register" stance, when a single break was called a "Window" and "trip" was forbidden; that term is now retired in favour of **Trip** across UI, code, URLs (`/trip`), and docs. Keep one word per concept: a single break is always a **Trip** (never "window," "break," or "getaway"); "Itinerary" names only the grouping, never a single Trip.

### 3.2 The "leave" term is per-market

"PTO" tested as jargon outside the US (tester-feedback wave, TF-3). The word for *the paid days a worker spends to take time off* is therefore **per-market**, resolved through the i18n catalogs ([I18N.md §14](./I18N.md)) — never hardcoded in a component:

| Market (locale) | Formal (budget label) | Short (inline noun) |
| --------------- | --------------------- | ------------------- |
| en-KE (default), Commonwealth English | **annual leave** | **leave** |
| en-US | **PTO** | **PTO** |

The formal form labels the budget input ("Annual leave" / "Annual PTO budget"); the short form is the inline noun ("6 leave spent" / "6 PTO spent"). Within one locale the term is fixed — **mixing "leave" and "PTO" in a single locale's copy is a bug** (the §3 rule, scoped per-locale). Because the launch renders the default locale statically ([I18N.md §13.4](./I18N.md)), the live site shows the **en-KE** term ("leave" / "annual leave") today; the en-US "PTO" variant is in the catalog and surfaces when per-request locale activates.

**Incremental rollout — this is the boundary, not a silent drop.** TF-3 migrates the **planner (`/year`) and trip-finder (`/trip`) display layer** — the budget inputs, the results eyebrows/headers and `TripCard` stats, the `ItineraryTripCard` stats and composition line, the `FilterPanel` weekday helper, and the trip-finder hero + budget-headroom + lost-to-weekend notes — to the catalog term. The remaining surfaces keep "PTO" until they adopt the catalog term in a follow-up (or the later `pto`→`leave` schema rename): the multi-country **Overlap** copy (§4.13), the **calendar-overlay** Free/PTO toggle (§4.7 — whose helper *defines* "PTO = you spend a leave day," so it stays until reworded), the **off-day ledger** (§4.18), the account saved-search lines, the calendar **export** description, the shared **`ScoreBadge`** / **`DayCell`** / **`OverBudgetMarker`** hover-and-screen-reader strings (localised alongside the §7-style day-kind work), and the **SEO / AI-discoverability** copy — where "PTO" is a deliberate global search keyword and a register change wants an SEO-aware pass. (Page `<title>`/description metadata for `/year` and `/trip` already read "annual leave" / "leave days" — that predates TF-3 and stays.) Note that a **shared control carries the term wherever it renders**: the budget steppers (`PtoInput`, `AnnualPtoInput`) and the trip-result components (`TripCard`, `TripList`, `AggregateHeader`) are reused on some deferred surfaces (e.g. the group member editor reuses `PtoInput`; an event group reuses `TripCard`), so those *inputs and trip cards* speak "leave" there too — by design, since one control must speak one term. "Deferred" therefore names the surface-specific **result/ledger strings** (overlap headlines, the off-day ledger, saved-search lines), not the shared primitives.

### 3.1 Numbers

- Efficiency: two decimal places ("4.00"), with tabular numerals. No trailing zeros are stripped — "4" reads as worse than "4.00" when scanning a list.
- Leave / PTO days (the §3.2 per-market term): integer.
- Dates in UI: format per [I18N.md](./I18N.md), never raw ISO unless inside a code block.
- Money (paid tier): currency symbol per locale.

## 4. Microcopy patterns

### 4.1 Button verbs

Buttons use a verb that describes what the next click does, not what was clicked.

| Action | Label |
| ------ | ----- |
| Submit optimizer | "Find trips" |
| Reset filters | "Clear filters" |
| Share | "Copy link" |
| Save (paid) | "Save search" |
| Upgrade to paid | "Upgrade" |
| Export | "Export calendar" |
| Add to Google Calendar | "Add to Google Calendar" |
| Cancel | "Cancel" |

Never "OK" or "Submit."

### 4.2 Loading

The indicator scales with how long the user waits:

- < 200 ms: no indicator.
- 200 ms – 1 s: a subtle busy state on the action button (spinner, or the button's own pending treatment).
- 1 s – 3 s: button busy state + text ("Calculating trips…").
- > 3 s: the above + a placeholder where the result will land, so the page doesn't feel frozen.

The text is always present-continuous (`-ing`), specific, and accurate.

**The optimizer compute — the "Find trips" submit on `/year` (and `/trip`) — is the one wait long enough to always cross the threshold**, so it owns a first-class indicator rather than a bare spinner: the **`CalendarLoader`** ([COMPONENTS.md § State surfaces](./COMPONENTS.md)) — an animated year-calendar that reads as "scanning your year for trips" — stands in the results region with the "Calculating trips…" caption while the request is in flight, and doubles as the scroll target that brings a mobile user to where the result will appear ([COMPONENTS.md § Annual planner — Finding state](./COMPONENTS.md)). Incidental actions (save, delete, sign-out) keep the plain button spinner.

### 4.3 Empty states

Pattern: **one-line headline + one-line body + (optional) one action.**

- Headline names the situation. "No trips match these filters."
- Body suggests the next move. "Try fewer month restrictions, or add PTO."
- Action is the obvious fix, surfaced as a button.

### 4.4 Errors

Pattern: **what happened + what to do.**

- Bad: "An error occurred."
- Good: "Couldn't reach our holiday data. Try again in a moment."

Avoid blaming the user when ambiguity exists. "Enter a country code like KE or DE" — not "Your input was wrong."

### 4.5 Theme switch

The `ThemeSwitch` in the footer is labelled, not iconographic. The group's accessible name is "Theme"; the three options read "System", "Light", and "Dark". Sentence case (§8); no icons-only segments (icons may *accompany* the text but never replace it — colour and glyphs are never the only signal, per [ACCESSIBILITY.md §3](./ACCESSIBILITY.md)).

### 4.6 Filter panel

Group legends are nouns, sentence case: "Months", "Exclude weekdays", "Blocked dates". Helpers state the effect, not the mechanism: "We won't spend leave on these days." (weekdays — "leave" is the §3.2 per-market term, "PTO" at en-US), "Trips that touch these dates are skipped." (blocked). The collapsed-panel summary is constraint-first and compact — "{n} months · No {Fri} · {n} blocked", joined by middots, with a count badge of total constraints. Reset is "Clear filters" (§4.1). There is no "Apply" button — filters are live; the form's own "Find trips" runs them. The empty-results copy when filters are too tight follows §2: "No trips match these filters. Try fewer month restrictions."

### 4.7 Calendars you observe (`CalendarOverlayPicker`)

The personal-calendar control in the planner's Advanced settings ([COMPONENTS.md § CalendarOverlayPicker](./COMPONENTS.md)) — **one list**, the calendars the user observes, seeded from their country and edited freely. Honest framing is the whole point: these are days the *user* observes, and we say plainly which the country gave them.

- Card legend: "Calendars you observe", with the lead "Your country's are added for you." Sentence case. There are no "show / hide" checkboxes — the list *is* what's observed.
- **Granted vs full labels.** A row seeded from the country's gazetted set is labelled with the country in parentheses — "Gregorian (Kenya)", "Islamic (Kenya)". A row the user added from a calendar's worldwide dataset is labelled "Islamic (worldwide)", "Hindu", "Chinese", "Hebrew", or "Gregorian (common)". The "(Kenya)" / "(worldwide)" tail is how the copy distinguishes a country-accurate date from the global one — never a religious or belief label.
- **Free / PTO toggle.** Each row carries a "Free day" / "Spend PTO" toggle, with the helper "Free = a day off; PTO = you spend a leave day." Use the canonical **PTO** term (§3), never "leave" or "vacation" in the label.
- **Worldwide-date hint.** A "(worldwide)" / `full`-scope row (and its day in the calendar strip) carries the hint "Worldwide date — your country may observe it ±1 day." This is the §5 honesty rule applied to lunar/global reckoning: we state the ±1-day uncertainty plainly rather than implying a country-exact date. Never "approximate", "TBD", or "predicted" (§3 — that register is "Estimated").
- Add / remove affordances: "Add calendar" (button), "Remove {label}" (the ✕'s accessible name, e.g. "Remove Islamic (worldwide)"). Sentence case (§8).
- No religious framing in copy beyond the calendar's proper name (Islamic, Hebrew, Hindu, Chinese) — we name the calendar tradition, never infer or label a belief (PROJECT-level privacy stance: we don't infer religion).

### 4.8 Over-budget marker (`TripCard` / `YearPlanCard`)

When a trip or plan spends more PTO than the budget because of `pto` overlay days the user committed to ([CALENDAR_OVERLAYS.md §5.1](../../backend/specs/CALENDAR_OVERLAYS.md)), the marker reads "Over budget" — two words, sentence case, no alarm. The explanation (via `title`) is "This trip spends more PTO than your budget — the extra is the days you chose to take off." ("plan" instead of "trip" on the plan card.) It is a statement of fact in the honest-about-limits register (§1, §5), never a warning the user did something wrong: they chose to take those days, and we kept them. Don't use "exceeded", "too much", "warning", or "⚠" — just the plain fact.

### 4.9 Calendar view

The nav label is **"Calendar"** (sentence case, §8) — it sits beside "Plan year". The page H1 is "{Country}, {year}" once a country is chosen (e.g. "Germany, 2026"); before that, the empty state follows §4.3: headline "See a country's year." / body "Pick a country to see its weekends and public holidays." The submit button is "Show calendar" (§4.1 — a verb for the next click). Navigation controls are labelled, not icon-only (§3 / [ACCESSIBILITY.md §4.6](./ACCESSIBILITY.md)): "Previous year" / "Next year", "Previous month" / "Next month", and the month jump select is labelled "Jump to month". A holiday cell's `title` is the **holiday name** verbatim from the provider — we don't translate or editorialise it. The day-key legend reads "Weekend" and "Holiday" (the canonical terms, §3 — never "day off").

### 4.10 Efficiency-sort budget note — *retired ([ADR-008](../docs/DECISIONS.md))*

This `CalloutBox` belonged to the single-break results, which were removed ([PROJECT.md §4.2](./PROJECT.md)). The `optimizer.budgetNote.*` copy stays in the catalog but is no longer rendered.

### 4.11 Top-nav labels (responsive)

The `TopNav` ([COMPONENTS.md § TopNav](./COMPONENTS.md)) uses the **full** labels at every size, in this order: **"Plan year"** (→ `/year`), **"Plan a year for 2"** (→ `/overlap`), **"Solo trips"** (→ `/solo`), **"Group trips"** (→ `/groups`), **"Calendar"** (→ `/calendar`) — then the account/identity slot. (About is no longer a top-nav item: it's the documentation surface, [PROJECT.md §4.5](./PROJECT.md), and lives in the Footer nav.) The labels are deliberately the **value** form, not the internal route names — never "Overlap"/"Groups"/"Itineraries", which are engine jargon, not searcher vocabulary ([SEO.md §4.8](./SEO.md)). Two labels are chosen for **parallelism** so the nav reads as a coherent set: `/overlap` is **"Plan a year for 2"** (it echoes "Plan year" — the date-coordination tool, framed couples-first; the engine still supports up to 6 parties, [MULTI_COUNTRY.md](./MULTI_COUNTRY.md)), and `/solo` is **"Solo trips"** (it echoes "Group trips"). The **order encodes the product split**: the *plan-/discover-dates* tools first (Plan year · Plan a year for 2), then the *plan-trips* tools (Solo trips · Group trips — the Phase-5 AI focus, [PROJECT.md §7](./PROJECT.md)), with the read-only **Calendar** last before the identity slot. Sentence case (§8); they collapse behind ☰ below `sm` like the rest. From the `sm` breakpoint up ([DESIGN_SYSTEM.md §8](./DESIGN_SYSTEM.md), 640 px) they sit inline; below `sm` they move, unchanged, into the `NavMenu` overflow popover ([COMPONENTS.md § NavMenu](./COMPONENTS.md)) behind a ☰ button whose accessible name is "**Menu**" (sentence case, §8). The wording never abbreviates — the popover has room, so there are no short forms. The destinations, order, and the pill-vs-text treatment ([FIGMA_REFERENCE.md § Decisions §5](./FIGMA_REFERENCE.md#decisions-made-during-the-visual-build)) are identical at both sizes; only the *container* changes (inline row → popover list).

> Supersedes the earlier shorten-to-one-word rule ("Plan year" → "Year", "Find a trip" → "Trip"). Collapsing the whole set behind one control — rather than abbreviating each label — keeps every destination's copy intact and stops the mobile row from crowding once the identity slot is alongside it. The original defect (multi-word labels wrapping to two lines and inflating the active pill) no longer applies: only one item shows inline below `sm` — the ☰ — and it never wraps.

> **SHIPPED 2026-06-22.** `nav-items.ts` renders **Plan year · Plan a year for 2 · Solo trips · Group trips · Calendar** (the single source for `TopNav` inline + the `NavMenu` popover). "Itineraries" was removed from `AccountMenu` and promoted to "Solo trips" (then at `/account/itineraries`; the route moved to `/solo` the same day — see the follow-up note below + [URL_FORMAT.md §12](./URL_FORMAT.md)). Pricing + per-page gating unchanged. Drawn `Pattern · TopNav — nav restructure` Light `733:1540` / Dark `734:1540` ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)). **907 web Vitest + `next build` green.**
>
> **Deviations (recorded):**
> 1. **Nav label only.** The rename is the *nav* value label; the `/overlap` page keeps its SEO-tuned eyebrow/H1 ("Multi-country overlap" / "When can you both be off?") and the `llms.txt` entry, whose retarget to market vocabulary is separately tracked ([SEO.md §8.10/§14](./SEO.md)).
> 2. **"Plan a year for 2" is the couples-first framing.** The engine still supports up to 6 parties ([MULTI_COUNTRY.md](./MULTI_COUNTRY.md)); the label optimises for the common case + parallelism with "Plan year".
> 3. **Itinerary provenance label unchanged.** A selection derived from the overlap tool still carries the source label `"Shared time off · {party}"` ([src/lib/itineraries/from-overlap.ts](../src/lib/itineraries/from-overlap.ts)) — it describes the source *feature*, not the nav, and reads fine as provenance.
> 4. **Per-route assembled Figma frames** still show the old nav labels — the standing nav-consistency follow-up ([FIGMA_REFERENCE.md](./FIGMA_REFERENCE.md)); the canonical `Pattern · TopNav — nav restructure` frame is the new source of truth.

> **Follow-up SHIPPED 2026-06-22 — "Solo trips" route move.** The nav target moved from
> the nested `/account/itineraries` to the top-level **`/solo`** (sibling of `/groups`),
> and the page was promoted from a `noindex` account surface to an **indexable**
> explainer-fronted feature page ([COMPONENTS.md § Solo trips](./COMPONENTS.md),
> [URL_FORMAT.md §12](./URL_FORMAT.md)). The nav **label** is unchanged ("Solo trips");
> only the route + the page's discoverability posture changed. Old `/account/itineraries[/id]`
> links **308-redirect** to `/solo[/id]`.

### 4.12 Single-break mode hero — *retired ([ADR-008](../docs/DECISIONS.md))*

The single-break empty-state hero ("Single break" eyebrow / "Find your best trip." H1 / ranking lead) is gone with the single-break UI ([PROJECT.md §4.2](./PROJECT.md)). `/year`'s only empty state is the whole-year onboarding (§4.19, `PlannerHero`).

### 4.13 Overlap calendar legend (`OverlapCalendar` / `OverlapLegend`)

The three states of the multi-country overlap calendar ([MULTI_COUNTRY.md §4.2](./MULTI_COUNTRY.md)) are **labelled, never colour-only** ([ACCESSIBILITY.md](./ACCESSIBILITY.md)):

- **Free for all** (green) — everyone is already off (weekend or their own public holiday); a shared break here costs **no PTO**.
- **Some spend PTO** (amber) — off for some, PTO for others.
- **Everyone spends PTO** (red) — a working day for all.

Keep the canonical term **PTO** (§3 — "spend", never "leave"/"vacation"). A result card's headline is plain and counts people honestly: *"5 days off · 1 PTO between you"* for a couple, *"…across 3 people"* for a group — never "you both" when there are three or more. The feature is an **Overlap** and a single result is a **shared break** (§3); a person is a **person**, never a "party" (the schema word).

### 4.14 Groups (`/groups`, `/g/[id]`)

Groups copy uses the §3 Groups register — **group**, **organiser**, **member**,
**invite link**, **comment**, **poll** — and never the *overlap* register's
"person"/"party" for a group member (a member is a **member**). It honours the
no-dark-patterns rule (§2, [PRIVACY.md §13.8](./PRIVACY.md)): no urgency, no "your
crew is waiting," no guilt.

- **Invite / preview** (`GroupInvitePanel`, §4.2 [GROUPS.md](../../backend/specs/GROUPS.md)) — the
  signed-out landing leads with the plan, not a hard sell: *"{Organiser} invited you
  to plan a trip together."* For a **joinable** group it then states the value plainly
  — *what you get*, never urgency or guilt (§2; no "your crew is waiting"): *"See when
  everyone can be off. roamgap finds the days you can all take time off together —
  counting each person's public holidays and leave, even across different countries.
  Joining is free."* "Joining is free" is a **reassurance, not an upsell** — only the
  organiser pays (GD-3), and a member never sees a "buy Plus" prompt. The CTA is
  **"Sign in to join"** (anonymous) / **"Join this group"** (signed-in non-member). A
  full / closed / paused group shows the state instead — no value-prop, no CTA. Show
  only what §7 allows (name, organiser, member count) — never another member's details.
  The same lead — *"{Organiser} invited you to plan a trip together."* — is reused
  verbatim as the **social-share description** when the `/g/[id]` invite link is unfurled
  in a chat app, with the share **title** set to the group name ([GROUPS.md §8](./GROUPS.md)).
  No new copy; the unfurl exposes only the §7-public fields (name, organiser).
- **Approval-mode (UX-5b)** — when the organiser gates joins (`joinPolicy: approval`):
  the invite CTA reads **"Request to join"** (signed-in) / **"Sign in to request"**
  (anon); after requesting, the applicant sees **"Request sent"** (*"{Organiser} will
  review your request to join {Group}…"*); the organiser's queue is **"Requests to
  join (N)"** with **Approve** / **Reject**. Plain, no pressure (§2) — no "N people
  waiting", no countdown, no guilt.
- **One organiser pays** — name the model plainly where it matters (the Create gate):
  *"Create a group with roamgap Plus — everyone you invite joins free."* No
  member ever sees an upsell (GD-3). The lapsed-organiser banners are role-aware
  ([GROUPS.md §3.1](../../backend/specs/GROUPS.md)): organiser → *"Your roamgap Plus
  lapsed — renew to reopen coordination for your group."*; member → *"Coordination
  is paused — the organiser's plan lapsed."* (informational, **no** paywall).
- **Availability is preloaded, not asked for (create / join — [GROUPS.md §4.7](./GROUPS.md)).**
  The create and invite screens never show an availability editor — the signed-in user
  already owns an availability profile (§4.16), so we reuse it silently.
  - **Has a saved profile:** **no caption.** The "*Your availability is set from your
    saved profile — fine-tune it inside the group.*" line was **removed** (the
    declutter, [PROJECT.md §4.2.1](./PROJECT.md)) — a signed-in user discovers their
    availability is preloaded without being told, and edits it in the group or in
    settings. Preloading still happens; only the caption is gone.
  - **No saved profile:** *"We'll start from your home country — set your availability
    inside the group."* This one **stays** — it is an actionable instruction (where to
    set availability), not a "your input is preloaded" caption. Honest that a default
    was used; no guilt, no "finish your profile!" nag.
  - **Create summary** (the live "You'll get" line, mirroring `PlanSummary`):
    *"You're creating a {crew|event} for {year}. Invite the others once it's live."*
  - **Workspace nudge** (member with no saved profile, above the editor): *"Set your
    availability so the shared dates count you in."* One quiet line, non-blocking,
    dismissed once they save — never a modal, never a blocker.
- **The roster + private budgets** — the roster names members and their countries;
  it **never** shows another member's PTO budget (§6). Don't write copy that implies
  it does. Each member edits only **their own** availability.
- **Comments** (`GroupCommentThread`) — it's a **comment**, not a chat. Composer
  placeholder: *"Add a comment…"*; the action is **Comment**. A removed comment reads
  *"comment removed"* (neutral; never "deleted by organiser"). No realtime affordances
  (no "typing…", no unread badges) — they'd overpromise (GD-4).
- **Poll** (`GroupDatePoll`) — *"Vote for the dates that work for you"* (approval —
  you can pick more than one). Tally copy is plain: *"3 of 4 members"*. The leading
  option reads *"Group favourite"*, never "winner." **Any member can add a window:**
  the action is **"Suggest a date"** (not "add option"); a proposed window carries
  *"Suggested by {name}"* (organiser-set windows are unattributed). Be honest that a
  hand-picked date may be less efficient — a one-line hint near the suggest field:
  *"A date roamgap didn't find might use more leave."* No pressure, no "your group is
  waiting."
- **Promote the poll winner** (`GroupDatePoll`, organiser) — the action is **"Make
  this the trip"** (not "promote"/"finalise"). Once promoted, the closed poll reads
  *"The group is taking {dates}."* — a statement of fact, not a celebration. The closed
  poll's options stay actionable, so the closed-poll lead is *"Poll closed — you can
  still add any of these dates to a trip."* (never a dead end).
- **Add a poll date to a trip** (`GroupDatePoll`, any member) — each option carries a
  plain **"Add to trip"** alongside the organiser's "Make this the trip"; when several
  trips exist it names the target, **"Add to {Trip name}"**. It is the lightweight,
  non-terminal sibling of promote — it never closes the poll, so the copy stays neutral
  (no "finalise", no "lock in").
- **Public holidays note** (`OverlapResultCard`, shared breaks) — under a break's day
  strip, the holidays inside the window read as a compact, factual line led by **"Public
  holidays"** — *"Public holidays: Good Friday · Easter Monday (KE)"* — naming the
  holiday and, in parentheses, the country that observes it. No holiday in the window →
  no line (never *"No public holidays"*).
- **Trip dates** (`GroupTripDates`, item 3/4/5) — the panel heading is **"Trip
  dates"**; the default date-group is **"Trip"** (then **"Trip 2"**, **"Trip 3"**…).
  The shared-break selection action is **"Add to trip"** (in a group it lands on the
  crew's shared dates, *not* a personal itinerary — say "trip", never "itinerary"
  here). The multi-trip affordance is **"+ Add another trip"**; removing a pinned
  window is **Remove**. Empty trip: *"No dates yet — add one from a shared break
  above, or from the date poll below."* The shared-break list expander reads **"Show
  all {N}"** / **"Show fewer"**.
- **Group cover** (`GroupCoverPicker`, organiser — §4.9) — the Manage-group label is
  **"Cover"** with a one-line hint *"Pick the artwork for this group."* The six theme
  names are plain, lowercase nouns capitalised in the UI — **Sunrise · Ocean · Forest ·
  Desert · Aurora · Blossom** — used as each swatch's accessible name (*"{Theme} cover"*).
  No "premium"/"unlock" framing — every theme is free; the cover is decorative, never an
  upsell.
- **Empty states** (§4.3 pattern) — < 2 members: *"Invite at least one more person and
  roamgap will find the days you can all be off."* Full: *"This group is full (6
  people)."*

### 4.15 Itinerary detail (saved view)

The saved-itinerary page (`/solo/[id]`) communicates each Trip
honestly and visually — never as a raw ISO pair.

- **Date range** — the human range, not ISO: "19 Mar 2026 – 22 Mar 2026" (the
  `fmtRange` form `TripCard` already uses), with the `CalendarStrip` carrying the
  day-by-day picture (the §3 day-kind letters W/H/P/B).
- **Composition summary** — one honest line from the trip's day kinds:
  *"2 weekend · 1 public holiday · 1 PTO"*, naming any holidays as a trailing clause
  (*"… · Includes Eidul Fitr"*); zero-count kinds are omitted. It restates the calendar
  strip in words; it never
  editorialises ("only 1 day of leave!") — counts, not adjectives (§3.1).
- **Trip vs Itinerary** — a single date range is always a **Trip** (§3); the page is
  the **Itinerary**. "Date stretch" / "window" / "break" are not user copy — they map
  to **Trip**. An itinerary with one Trip still reads "1 trip", never "empty".
- **AI slot heading** — "Where to go". Pre-launch: *"AI destination suggestions are
  coming."* (no countdown, no hype). When live, every AI paragraph carries the §5.3
  disclaimer verbatim, and the suggestion is **per Trip** — one per date range, not
  one per itinerary.

### 4.16 Availability profile (`AvailabilityProfileEditor`)

The settings editor for the signed-in user's saved availability
([AVAILABILITY_PROFILE.md §5](./AVAILABILITY_PROFILE.md)). Spoken in the user's
terms — "my profile", "my availability" — never "defaults object" or "template
record". Edited in **one** place (account settings); the planners seed from it
silently (UX-11).

- **Section** — heading *"Availability profile"*, lead *"Saved once here, filled in
  for you on every planner."*
- **Save** — button *"Save profile"*. Success: a brief *"Saved to your profile."*
  (the shared success-feedback pattern from the UX-7a group save fix). Error:
  *"Couldn't save — try again."* Never a silent failure (the UX-7a lesson).
- **Forget** — button *"Forget profile"*. Success: *"Your profile was forgotten."*
  Plain — it clears your saved defaults, no scare copy.
- **Autoload** — **silent**. When your saved values fill a planner there is no
  toast or "loaded from your profile" banner; the fields are simply pre-filled and
  fully editable (we never announce it — it's the expected default, not an event).
- **Anonymous** — the editor shows a sign-in nudge, not a disabled "Save" as bait;
  it reads like §5.1's honest note, not an upsell.
- **Plain, not pushy** — saving your availability is **free** and framed as a
  convenience ("so you don't retype it"), never as a locked or premium feature.

### 4.17 Event groups (`CreateGroupForm`, `GroupEventResult`)

The event archetype (UX-9; [backend GROUPS.md §6.5](../../backend/specs/GROUPS.md))
is the "everyone plans around a fixed date" group — a wedding, a conference, a
reunion weekend.

- **Pick the type** — *"What kind of group"*: *"A crew — find days we can all be
  off"* vs *"An event — everyone plans around fixed dates."* Plain, no jargon
  ("archetype", "anchor", "optimise-around-anchor").
- **Set the dates** — *"Event starts" / "Event ends."* Missing or reversed:
  *"Pick the event's start and end dates (start on or before end)."*
- **The result** — *"Your time off around the event."* It is the reader's OWN plan
  ("your", "you"), never a roster of everyone's leave — a co-member's plan is
  private (§6). The first option is *"the most efficient"*, stated as a fact, not
  "best!" hype (§3.1, counts-not-adjectives).
- **Honest about scale** — an event group is a planning aid, not a promise everyone
  will come; copy never implies attendance or commitment.

### 4.18 Off-day manager (`OffDayLedger`)

The yearly leave ledger (UX-10; [OFF_DAY_MANAGER.md](./OFF_DAY_MANAGER.md)). Counts,
never adjectives (§3.1).

- **Summary** — *"N days left" / "N days taken" / "N granted days"* — the granted
  days (medical/compassionate/other) are shown **separately** and never subtracted
  from the balance. Plain figures.
- **Over budget** — *"Over by N"* in a calm warn tone — a fact, not an alarm
  ("You've overspent your leave!" is forbidden). Going over PTO is normal (unpaid
  leave, employer grants); the copy never scolds.
- **Categories** — *PTO* (counts against the allowance) vs *Medical / Compassionate
  / Other* (employer grants that don't). Named plainly, no euphemism.
- **Free** — framed as a useful tracker, never teased as a premium or "upgrade"
  feature.

### 4.19 Planner onboarding — first run (`PlannerHero`, `HowItWorksSteps`, `PlannerDemo`)

The home first-run copy ([PROJECT.md §4.1.1](./PROJECT.md)) — the conversion surface that TF-4 reframes from a cold form to *why → how → what you get*. Marketing-landing register (§2: confident, no superlatives), sentence case (§8), no exclamation (§10). The leave term is the §3.2 per-market catalog form ("leave" at en-KE, "PTO" at en-US) — **never hardcoded**, so each string below is given in its en-KE form.

- **Hero (`PlannerHero`)** — eyebrow "Annual planner" (unchanged, matches the trip-finder hero §4.12). H1 leads with the *benefit*, the §2 core insight in one line: **"Turn public holidays into more time off."** — "Holiday" is the §3 statutory-day term, "time off" the outcome; it is not the retired mechanism-first "Plan your year off." Lead (`text/lg` muted): **"roamgap lines up your country's public holidays with weekends to find the trips that turn the fewest leave days into the most continuous time off — five ranked plans for your year, free, no sign-up."** It names the inputs implicitly, says a result is a **Trip** (§3), says the plans are **ranked**, and states the free-tier promise (the friction TF-4 surfaced). No superlative; "most continuous time off" is the same comparative the trip-finder lead uses (§4.12).

- **How it works (`HowItWorksSteps`)** — section label "How it works", three numbered steps, each a short title + one line, parallel in shape:
  1. **"Tell us your country and leave."** — "Pick where you work and how many leave days you have for the year."
  2. **"We line up holidays and weekends."** — "roamgap scans the year for the public-holiday and weekend runs worth building a trip around."
  3. **"Get five ready-made plans."** — "Compare five strategies — Longest breaks, Best leverage, spread across the year — and pick yours." ("Longest breaks" / "Best leverage" are the established plan labels, §3 Long-trip row.)
  A trailing link to the full explainer reads "See how it works" → [`/about/how-it-works`](./PROJECT.md) (§4.3). Plain, not pushy (§2); the strip *paraphrases*, it never redefines a term.

- **Demo (`PlannerDemo`)** — the labelled worked example beneath the form. Eyebrow "Example · {Country} {year}" (e.g. "Example · Kenya 2026") — **honest framing is mandatory** (§1, [PROJECT.md §12](./PROJECT.md)): it is an example, never the visitor's own plan. Headline states the leverage as a plain fact, counts not adjectives (§3.1): **"Two trips. 18 days off. 8 days of leave."** Lead (`text/muted`): **"A sample plan, built the way yours will be. Enter your country and leave days above to build your own."** ("above" points at the form). The example Trips render through the real `TripCard` (read-only — no "Add to itinerary", no export), so the demo speaks the same terminology as a live result. No "try it now!", no countdown, no superlative.

Three specific rules — these are non-negotiables from [PROJECT.md](./PROJECT.md).

### 4.20 Planner advanced revamp (`TripStyleSlider`, `TravelWindowPicker`, `PlanSummary`)

The UX-11 declutter of the planner ([PROJECT.md §4.1.2](./PROJECT.md)) — fewer
visible options, smart presets, a live preview. Sentence case (§8), no
superlatives (§2), no exclamation (§10). The leave term is the §3.2 per-market
catalog form ("leave" at en-KE) — **never hardcoded**.

- **Quick start (`PlannerPresets`)** — **removed** in the §4.1.3 declutter
  ([PROJECT.md §4.1.3](./PROJECT.md)); the plan shape now lives in Advanced settings.
- **Trip style (`TripStyleSlider`)** — label "Trip style"; the two end captions are
  the whole explanation: left *"Fewer, longer trips"*, right *"More, shorter
  trips"*. This replaces the old "Long trips" stepper's two-line paragraph — the
  captions say what the paragraph said.
- **Travel window (`TravelWindowPicker`)** — legend *"When can you travel?"*. The
  default reads *"Any time"* (not "All months" — speak the user's intent). Opening
  it offers *"Pick months"* with quarter quick-picks labelled by months
  (*"Jan–Mar"*, not "Q1 summer" — hemisphere-neutral, §6) and the month grid.
- **Plan preview (`PlanSummary`)** — one plain sentence stating what the current
  inputs will do, counts not adjectives (§3.1): *"Turning 30 days of leave into
  trips up to 2 weeks long, any time this year."* (The single-break variant —
  *"Finding your best single break in {country}…"* — is retired with the single-break
  UI, [ADR-008](../docs/DECISIONS.md).) It describes, it
  never sells ("the perfect getaway" is forbidden, §2). Reads "leave" per the
  market catalog (§3.2).

### 4.21 Results declutter (`/year` results, `TripCard` — §4.1.3)

The post-UX-11 results pass ([PROJECT.md §4.1.3](./PROJECT.md)). Counts not adjectives
(§3.1), no superlatives (§2).

- **Compare strategies** — the collapsed strategy switcher's summary reads **"Compare
  strategies"** (a plain action, not "Pick a strategy" / "Other plans"). The selected
  plan's header still names the active strategy (*"Strategy: Longest breaks."*), so the
  collapsed switcher needs no extra explanation.
- **Result-card efficiency** — moved from the header badge to a footer stat labelled
  **"Efficiency"**, value as a bare leverage multiple — *"5.00×"* (the `×` reads as
  "times your leave," honest and unit-like, not a superlative). No "great!" / "best
  value" adjectives; the number is the message (§3.1).
- **No "Lost to weekend" on whole-year** — the callout is removed from the whole-year
  results (§4.1.3); it was commentary, not an action. The `optimizer.lost.*` copy stays
  for single-break mode.

### 4.22 Selection bar & sort labels (`/year` results — §4.2)

The whole-year results affordances ([PROJECT.md §4.2](./PROJECT.md)). Plain action labels, no
superlatives (§2), counts not adjectives (§3.1). (The `ModeToggle` copy — "Plan my year" /
"Find one trip" — is retired with the single-break UI, [ADR-008](../docs/DECISIONS.md).)

- **Sort labels (`SortToggle`).** The three options read **"Most
  efficient"** (efficiency), **"Longest first"** (length), **"By date"**
  (date — the **default** on `/year`). Each is a plain ordering, not a claim ("Best value" would editorialise;
  "Most efficient" names the §3 metric). The label prefix stays "Sort:".
- **Selection bar (`TripSelectionBar`).** When breaks are selected the bar says
  **"{n} selected"** (a count, §3.1) and offers, as plain text buttons: **"Add to
  itinerary"**, **"Download .ics"**, and — only at one selection — **"Add to Google
  Calendar"**. When more than one is selected, a quiet line notes the Google option takes
  one trip at a time: *"Google Calendar adds one trip at a time — use Download .ics for
  several."* No exclamation; export is a neutral, useful action, never hyped.
Communicate free-tier session-only persistence honestly — don't bury it. **Signed-in searches autosave** to the account (no button, no note — `AutosaveSearch`); a **signed-out** visitor's data lives only in the browser session, so where a save *is* offered to an anon user (e.g. the itinerary save's sign-in nudge) say so plainly: "Lives only in this tab. Sign in to keep it." (The old "Free searches live only in this tab. Sign in to keep them." note next to a "Save search" button is retired — searches autosave.)

### 4.23 Country field label (`CountryPicker`)

The `CountryPicker`'s field label and `aria-label` read **"Your country"** wherever the control sets the **current user's own** country — the `/year` and `/solo` planner hero, `/calendar` (`CalendarForm`), the settings **Availability profile** and account form, and a group member's **own** availability (`MemberAvailabilityEditor`). The possessive makes the single most important input read as *about you*, not a neutral data field.

It stays the plain term **"Country"** (§3) wherever the control sets **someone else's** scope — the 2nd–6th parties in `/overlap` (`OverlapPartyEditor`), whose card heading ("You", "Person 2", …) already names whose country it is. The first party ("You") uses "Your country". The component default is "Country"; self-context hosts opt in via the `label` prop ([COMPONENTS.md §CountryPicker](./COMPONENTS.md#countrypicker--phase-1--built-figma--slice-1-code)).

No per-market variance: unlike the "leave"/"PTO" term (§3.2), "country" is universal across `en-KE`/`en-US`, so the label is a plain string, not a `tFor` lookup.

**SHIPPED 2026-06-25** — `CountryPicker` carries the `label` prop; "Your country" is wired at `AnnualPlannerForm` (/year, /solo), `CalendarForm` (/calendar), `AvailabilityProfileEditor` + `AccountSettingsForm` (settings), `MemberAvailabilityEditor` (group member), and `OverlapPartyEditor`'s first party; parties 2–6 stay "Country".

### 4.24 Guest nav menu (`NavGuestMenu`)

The signed-out right-slot ([COMPONENTS.md §NavGuestMenu](./COMPONENTS.md)) must not read as "sign in to see anything." Sign in stays one obvious click, and the guest panel says, plainly, what an account adds — never hyped, never a wall.

- **Eyebrow:** "Browsing as a guest" (`text/xs` muted, sentence case §8). States the fact; no "Welcome!", no exclamation.
- **Pitch, two lines:**
  1. **"Every planner here is free — no account needed."** — the §4.22 free-tier honesty: the tools work signed-out, say so first.
  2. **"Sign in to save your searches across devices and plan group trips."** — the two concrete account adds (cross-device persistence + group trips), not a vague "unlock more". Second person (§6), no superlatives.
- **Action:** the button/link verb is the canonical **"Sign in"** (§4.1) → `/sign-in`. Never "Sign up" / "Create account" — Google auto-provisions; there is no separate registration ([PROJECT.md](./PROJECT.md), auth is sign-in only).
- Same copy on `sm`+ (the `NavGuestMenu` panel) and below `sm` (the `NavMenu` hamburger foot) — one source so they can't drift.

**SHIPPED 2026-06-26** — the copy lives in `GUEST_COPY` (`src/components/layout/NavGuestMenu.tsx`), the single source for the desktop panel and the mobile hamburger foot.

### 5.2 Historical data uncertainty
Never phrase historical patterns as predictions.

- Forbidden: "A holiday is expected here."
- Allowed: "Last year, a holiday was declared here."
- Allowed: "Historically, this country declared 3 ad-hoc holidays a year."

### 5.3 AI destination disclaimers
AI-generated destination paragraphs include a one-line note: "Recommendations generated by AI. Verify cultural events and weather before booking."

## 6. Inclusive language

- Use second person ("you"), not "users." We're talking to one person.
- Avoid idioms that don't translate (no "knock it out of the park," "low-hanging fruit," "ballpark"). Useful as a side benefit — copy translates more cleanly. See [I18N.md](./I18N.md).
- Don't assume marital status or family configuration. "Plan a window that works for everyone in your party" — not "for your spouse."
- Currency examples use varied locales (KES, EUR, USD) — not always USD.
- Country examples rotate (Kenya, Germany, Japan, USA, India) — not always USA + Western Europe.

## 7. AI-generated copy

Phase 5 ships Claude-generated destination paragraphs. Constraints on that output:

- Voice rules (§1–§2) apply.
- Terminology rules (§3) apply.
- Honesty rules (§5) apply — in particular §5.3.
- No "5-star," "world-class," "unmissable" — superlatives are noise.
- 80–120 words per destination paragraph. Tight is the brand.
- Disclose AI authorship. "Recommendations generated by AI." Don't try to humanise it.

The Claude prompt that produces these lives in [AI_PROMPTS.md](./AI_PROMPTS.md) (stub until Phase 5) and references this document.

## 8. Capitalisation

- Sentence case for everything: buttons, headers, menu items, titles. We don't Title Case.
- Exceptions: proper nouns (country names, "roamgap" — though we lowercase the brand intentionally), and acronyms (PTO, AI, EU).
- The logo lockup ([`public/logo-text.png`](../public/logo-text.png)) renders the name spaced and uppercased as "ROAM GAP". That is a **stylistic wordmark treatment only** — written copy always uses the canonical lowercase one-word form `roamgap`. See [BRANDING.md §1](./BRANDING.md).

## 9. Punctuation

- Oxford comma: yes.
- Em-dashes: yes, with no spaces around them — like this — when they replace a comma or parenthetical.
- Ellipses: only in loading text ("Calculating trips…") using the unicode `…` character, not three dots.
- Curly quotes (`"…"`) in user-facing copy. Straight quotes (`"…"`) in code blocks only.

## 10. What we don't do

- No emojis in product copy. Allowed in tone-explicit contexts (release notes for developers, social posts) only.
- No exclamation marks. The data is the excitement.
- No "Welcome to roamgap!" or other vacuous greetings.
- No second-screen jargon ("synergy," "leverage" outside the algorithmic sense, "ecosystem").
- No clickbait headlines, even for marketing.


---

# Internationalisation

_Source: `specs/I18N.md`_

# Internationalisation & Localisation

> roamgap serves an inherently international audience — the optimizer is parameterised by country. This document is the contract for which locales we support, how we format data, and how copy gets translated.

## 1. Scope by phase

| Phase | Locales | Notes |
| ----- | ------- | ----- |
| Phase 1 (MVP) | **`en-KE`**, **`en-US`**, **`de-DE`** | English defaults to `en-KE` (Kenya-first; UK-leaning English). German covers our primary EU market. |
| Phase 2 | Add `fr-FR`, `es-ES` | After auth + saved searches land. |
| Phase 3 | Add `it-IT`, `nl-NL`, `pt-PT`, `pt-BR` | With paid tier. |
| Phase 4+ | Add `ja-JP`, `zh-CN`, `ar-AE` (RTL) | After multi-country overlap. RTL is its own engineering effort — see §6. |

We add locales **only when** we have a native speaker available to review the strings. AI-translated strings shipped without review have produced exactly the kind of trust-destroying copy roamgap wants to avoid.

## 2. Locale resolution

Order of precedence for selecting the user's locale:

1. Explicit URL parameter `?locale=de-DE`.
2. Cookie `roamgap_locale`.
3. `Accept-Language` request header.
4. Fall back to `en-KE`.

The selected locale is reflected in the `lang` attribute on `<html>` and persisted to the cookie on every request.

## 3. Date formatting

Use `Intl.DateTimeFormat` exclusively — no hand-rolled formatters. Date strings stored or transmitted are always ISO 8601 (`YYYY-MM-DD`); the user-facing rendering is locale-derived.

| Locale | Short | Medium | Long |
| ------ | ----- | ------ | ---- |
| `en-KE` | `03/04/2026` | `3 Apr 2026` | `Friday, 3 April 2026` |
| `en-US` | `4/3/2026` | `Apr 3, 2026` | `Friday, April 3, 2026` |
| `de-DE` | `03.04.2026` | `3. Apr. 2026` | `Freitag, 3. April 2026` |

CalendarStrip cells use the **medium** format in row headers and just the day number inside cells (no formatter needed for `Intl`).

## 4. Number formatting

`Intl.NumberFormat` exclusively. Efficiency score uses fixed 2 decimals.

| Locale | Integer | Decimal | Efficiency |
| ------ | ------- | ------- | ---------- |
| `en-KE` | `1,234` | `1,234.5` | `4.00` |
| `en-US` | `1,234` | `1,234.5` | `4.00` |
| `de-DE` | `1.234` | `1.234,5` | `4,00` |

Tabular numerals (`font-variant-numeric: tabular-nums`) are applied via CSS regardless of locale — vertical alignment doesn't change.

## 5. Currency (paid tier — Phase 3)

| Locale | Currency | Format |
| ------ | -------- | ------ |
| `en-KE` | `USD` (until KES pricing study lands) | `$9.00` |
| `en-US` | `USD` | `$9.00` |
| `de-DE` | `EUR` | `9,00 €` |

We bill in USD by default; locale-native currency is a Phase 3+ enhancement that depends on Stripe's price-localisation setup. Detailed contract in [PAYMENTS.md](./PAYMENTS.md).

## 6. RTL

Right-to-left languages (Arabic, Hebrew) ship in Phase 4+ when `ar-AE` lands. Constraints we accept now so RTL doesn't require a rewrite:

- Use logical CSS properties everywhere (`padding-inline-start`, `margin-inline-end`, `text-align: start`). Never `padding-left` / `margin-right`.
- Set `dir="auto"` on text containers that may render either direction.
- Icons that imply direction (arrows, chevrons) have RTL-mirror variants. The Figma library marks these explicitly.
- CalendarStrip is **never** mirrored — calendars read left-to-right in every locale. The strip uses fixed direction `dir="ltr"` regardless of page direction.

## 7. Long-text reality

German is typically **30 % longer** than English; Russian and Finnish can double English length. Layouts must accommodate this.

- Buttons: minimum 1.5× English width before truncation. We never truncate button labels visually.
- Form labels wrap to two lines rather than being clipped.
- Calendar day kind letters (`W`, `H`, `P`, `B`) localise to the user's locale-specific first letter. `H` becomes `F` (Feiertag) in `de-DE`. The single-letter convention is preserved — never a word inside a cell.

## 8. Translation file format

ICU MessageFormat in JSON files. Each locale lives under `messages/{locale}.json`.

```json
{
  "optimizer.cta.find": "Find trips",
  "optimizer.result.count": "{count, plural, =0 {No trips} one {1 trip} other {# trips}} found",
  "calendar.day.kind.pto.aria": "{date}, {weekday}, PTO day"
}
```

Reference implementation: [`next-intl`](https://next-intl.dev/) or [`@formatjs/react-intl`](https://formatjs.io/). Pick one in the spec PR that brings up Phase 1's i18n implementation. Until then, English strings live inline in components — and the keys above are reserved.

## 9. Holiday names

The Nager.Date API returns holiday names in the holiday's local language already (`"Karfreitag"`, not `"Good Friday"`, for `DE`). We **render those as-is**. We do not localise holiday names to the user's UI language. A user in Kenya viewing German holidays sees `Karfreitag` — it's more honest and avoids translation errors.

Tooltip rendering: `Karfreitag (Good Friday)` — original + English gloss, where the gloss comes from Nager's `englishName` field.

## 10. Country names

Country picker uses `Intl.DisplayNames` with the user's UI locale:

```ts
new Intl.DisplayNames([locale], { type: "region" }).of("DE") // "Germany" / "Deutschland" / "Allemagne"
```

This is much better than maintaining our own translation table — `Intl.DisplayNames` ships with the browser/runtime.

## 11. Pluralisation

ICU MessageFormat handles plural rules per locale. **Never** hand-roll `if (count === 1)`. Russian, Arabic, and Welsh have multiple plural forms that `===1` misses.

## 12. What we don't do

- We don't ship locale switchers in marketing copy that imply "more locales coming soon." Either it's supported or it isn't.
- We don't auto-detect IP-based locale. That ignores the user's browser preference, conflates location with language, and breaks for travellers.
- We don't store translated strings in the database. Translations live in source-controlled message files only.

## 13. Implementation (Phase 2 foundation)

> The Phase-1 i18n implementation was deferred (strings lived inline, §8). This section is the contract for the foundation that lands in Phase 2: the library, the file layout, locale resolution, and which locales are *live* vs *scaffolded*. **No Figma change — non-visual** (no locale switcher ships; §12). The change is a behaviour-preserving refactor: every extracted string renders byte-identical English, so it touches no copy in [VOICE.md](./VOICE.md).

### 13.1 Library

**[`next-intl`](https://next-intl.dev/)** (the §8 reference choice), **without** path-based locale routing — locale is resolved from the cookie / header (§2), not a URL segment, so the shareable-URL grammar in [URL_FORMAT.md](./URL_FORMAT.md) is untouched. Messages are ICU MessageFormat JSON under `messages/{locale}.json` (§8).

For the English-only launch the messages are supplied through next-intl's framework-agnostic core, **[`use-intl`](https://www.npmjs.com/package/use-intl)**'s `IntlProvider` (a pure client provider, wrapped in the `use client` boundary `src/i18n/I18nProvider.tsx` so the Server Component root layout can render it), **not** next-intl's `NextIntlClientProvider` — the latter requires the next-intl server config and opts the whole tree into dynamic rendering (§13.4). The next-intl server plugin (`createNextIntlPlugin` → `src/i18n/request.ts`) is the *activation* entry, added when dynamic per-request locale ships (§13.4); it is intentionally not wired today.

### 13.2 Live vs scaffolded locales

Per §1 (no locale ships without native-speaker review) and the v1 launch being English-only:

| Locale | State | Catalog |
| ------ | ----- | ------- |
| `en-KE` (default), `en-US` | **Live** — selectable, fully translated | `messages/en-KE.json`, `messages/en-US.json` |
| `de-DE`, `fr-FR`, `es-ES` | **Scaffolded** — catalog file exists for translators; **not** selectable until a native speaker reviews it (§1) | `messages/{de-DE,fr-FR,es-ES}.json` |

`ENABLED_LOCALES` in `src/i18n/locales.ts` is the single source of which locales are live (`['en-KE','en-US']`). Resolution (§13.3) only ever returns a member of that set; a request for a scaffolded locale falls back to `en-KE`. Promoting a scaffolded locale to live = fill its catalog with reviewed strings, add it to `ENABLED_LOCALES`, and enable dynamic rendering (§13.4).

### 13.3 Resolution

`resolveLocale({ urlLocale, cookieLocale, acceptLanguage })` in `src/i18n/resolve-locale.ts` is a **pure function** implementing the §2 precedence (URL param → cookie → `Accept-Language` → `en-KE`), validating every candidate against `ENABLED_LOCALES`. `Accept-Language` matches exact-then-language-prefix (`en`, `en-GB` → `en-KE`; `en-US` → `en-US`; `de`/`fr`/`es` → not live → skip). It is unit-tested and ready, but **not on the render path yet** — the proxy that persists `?locale=` to the `roamgap_locale` cookie and the request-config that calls `resolveLocale` per request both land at dynamic activation (§13.4). (The Next 16 request hook is `proxy.ts`, formerly `middleware.ts`.)

### 13.4 Rendering mode (launch decision)

For the English-only launch the **root layout renders the default locale statically** — it does *not* read the locale cookie/header — so the static SEO surface (home, `/about/*`, `/legal/*`) and the Core Web Vitals budget ([PERFORMANCE.md](./PERFORMANCE.md), Milestone 1) are preserved; forcing the whole tree dynamic for a switch we don't ship yet is the wrong trade. The `use-intl` `IntlProvider` (§13.1) is fed the default-locale catalog statically.

**Activation** (when the first native-reviewed non-English catalog ships): (1) add `createNextIntlPlugin` → `src/i18n/request.ts` in `next.config.ts`; (2) add `proxy.ts` for `?locale=` persistence; (3) switch the root layout from the static `IntlProvider` to next-intl's server `getLocale()`/`getMessages()` (which consume the §13.3 resolver). The resolution contract is already written and tested, so activation is wiring, not redesign. This opts pages into dynamic rendering — an accepted cost *once* locale-switching is a shipped feature, not before.

### 13.5 Key naming

`<area>.<group>.<detail>` (§8 examples: `optimizer.cta.find`, `optimizer.result.count`, `calendar.day.kind.pto.aria`). Plurals use ICU `plural` (§11) — never hand-rolled. Catalog keys are kept in parity across all live locales (a test asserts `en-KE` and `en-US` share an identical key set).

## 14. Per-market terminology (the "leave" term)

The word for *the paid days a worker spends to take time off* is **per-market**, not one canonical string ([VOICE.md §3.2](./VOICE.md)): en-KE / Commonwealth English say **annual leave** (formal) / **leave** (short); en-US says **PTO**. This is the first copy whose value genuinely **diverges** between the two live English catalogs — every other key is byte-identical en-KE/en-US English. It comes from the TF-3 tester feedback that "PTO" reads as US jargon.

### 14.1 Where the term lives

The strings live in the catalogs (`messages/{locale}.json`) under the existing `optimizer.*` / `planner.*` groups (§13.5), written as **whole phrases** (§8 — translate phrases, don't stitch a term into English at runtime). The set the planner/trip surfaces render, e.g.:

| Key | en-KE | en-US |
| --- | ----- | ----- |
| `optimizer.budget.term` | `leave` | `PTO` |
| `optimizer.budget.label` | `Leave budget` | `PTO budget` |
| `optimizer.stat.label` | `Leave` | `PTO` |
| `planner.budget.label` | `Annual leave` | `Annual PTO budget` |
| `planner.plan.ptoSpent` | `Leave spent` | `PTO spent` |

The §13.5 parity test keeps the **key sets** identical across locales; a companion test asserts the leave-term keys **diverge** (en-KE ≠ en-US) so the per-market split cannot silently regress to a single term.

### 14.2 Reading the term — client vs. server / pure

Two readers, **one source** (the catalogs):

- **Client components** use `useTranslations(ns)` from `use-intl` (the established pattern, §13.1) — it follows the `IntlProvider` locale.
- **Server components and pure functions** (no React context — e.g. `app/year/page.tsx`, `TripCard`, `AggregateHeader`, `formatCompositionSummary`) use the sync helper **`tFor(locale, ns)`** in [`src/i18n/terms.ts`](../src/i18n/terms.ts) — a thin wrapper over use-intl's framework-agnostic `createTranslator` over the statically-imported catalogs. A pure component that already receives `locale` calls `tFor(locale, …)`; a server component calls `tFor(DEFAULT_LOCALE, …)` (the §13.4 static-default render). When per-request locale activates (§13.4), the server call sites pass the resolved locale instead — wiring, not redesign.

`createTranslator` is synchronous and needs only `{ locale, messages, namespace }`, so the same ICU catalog drives client and non-client call sites without duplicating the strings.

### 14.3 Rollout

Per [VOICE.md §3.2](./VOICE.md), only the **planner display layer — `/year`** reads the catalog term today; the other surfaces (Overlap, the calendar-overlay Free/PTO toggle, the off-day ledger, account saved-search lines, the calendar export description, the shared `ScoreBadge` / `DayCell` / `OverBudgetMarker` hover/SR strings, and the SEO / AI copy) still hardcode "PTO" and migrate later. The static-en-KE launch (§13.4) means the live site shows the en-KE term now; the en-US "PTO" variant is carried in the catalog for when dynamic locale activates.


---

# Architecture

_Source: `docs/ARCHITECTURE.md`_

# Architecture

> Read [specs/PROJECT.md](../specs/PROJECT.md) first — that's the **what**. This document is the **how**.

## Layered overview

```
┌─────────────────────────────────────────────────────────────────────┐
│  Browser / AI agent                                                 │
│   ↓                                                                 │
│  Next.js 16 (App Router) — src/app/                                 │
│   • Server Components for content pages (SEO + AI-readable HTML)    │
│   • Client Components only where interactivity demands              │
│   • Route Handlers in app/api/ as the public REST surface           │
│   • app/sitemap.ts, app/robots.ts, app/[...].md route for AI        │
│   ↓                                                                 │
│  Domain layer — src/lib/                                            │
│   • domain/    — types re-exported from specs/domain.ts             │
│   • backend.ts — server-side client for roamgap-backend (auth+proxy)│
│   • clients/   — Claude, Stripe wrappers (holidays live in backend) │
│   • seo/       — JSON-LD builders, structured-data helpers          │
│   ↓                                                                 │
│  External systems                                                   │
│   • Firestore (users, saved searches, cached holidays)              │
│   • Firebase Auth (Google OAuth + email/password)                   │
│   • Nager.Date / OpenHolidays (holiday data)                        │
│   • Anthropic Claude (destination recommendations)                  │
│   • Stripe (subscriptions)                                          │
└─────────────────────────────────────────────────────────────────────┘
```

## Directory contract

```
roamgap-web/
├── AGENTS.md          # AI-agent rules (read by Claude Code, Cursor, etc.)
├── CLAUDE.md          # @AGENTS.md re-export
├── README.md          # Human onboarding
├── docs/              # This directory — architecture, decisions, guides
├── specs/             # Source of truth — change here BEFORE code
│   ├── PROJECT.md
│   ├── ALGORITHM.md
│   ├── SPEC_FIRST.md
│   ├── domain.ts      # Zod schemas (the runtime contract)
│   ├── openapi.yaml
│   └── schemas/       # Generated JSON Schema, committed
├── src/
│   ├── app/           # Next.js App Router — routes only, no business logic
│   ├── lib/           # All non-trivial logic lives here
│   └── components/    # Re-usable React components
├── tests/
│   ├── unit/          # Vitest, jsdom, fast
│   ├── e2e/           # Playwright, full browser
│   └── setup.ts
└── public/            # llms.txt, llms-full.txt, robots fallbacks, static assets
```

**Rule:** `src/app/` contains no algorithm code. Since the Phase 1.4 cutover (see [DECISIONS.md](./DECISIONS.md) ADR-005), the algorithm itself lives only in `roamgap-backend/src/{optimizer,planner}/`; web's `/api/trip` and `/api/year` route handlers proxy to the backend via `backendProxy()` from `@/lib/backend`. Anything UI-side that's testable belongs in `src/lib/`.

## Data flow — a single optimize request

```
1. POST /api/trip  { country, year, ptoBudget, … }
   │
   ▼
2. src/app/api/trip/route.ts
   • reject malformed JSON locally with 400 invalid_input
   • backendProxy("/trip", body)         ◀── issues+caches a bearer token,
   │                                            forwards body to roamgap-backend
   ▼
3. roamgap-backend POST /trip
   • OptimizeInput.parse(body)               ◀── boundary validation
   • holidaysService.getHolidays(...)        ◀── cache-aware (Nager.Date)
   • computeTrips(input, holidays)         ◀── pure function
   • return OptimizeOutput JSON
       │
       ▼
4. Web pipes the upstream status + JSON straight to the caller. Browser
   renders calendar strips. AI tool consumes the JSON directly.
```

## Authenticated requests (Phase 2)

The free tier never needs a user identity: `POST /api/trip` and `/api/year` run server-side through `backendProxy()`, which mints an **anonymous** Bearer in the Node process (§"Data flow" above). Phase 2 **adds** a second, user-scoped path; it does not change the anonymous one.

```
Signed-in browser
  │  Firebase Auth (Google / email+password) → Firebase ID token
  ▼
src/lib/auth/session.tsx — SessionProvider (client, mounted in app/layout.tsx)
  │  onAuthStateChanged → POST /auth/token { firebaseIdToken }  → user-scoped Bearer
  │  bearer held in memory only (PRIVACY.md §13.7); re-minted from the live
  │  ID token on reload and on any 401
  ▼
authedFetch("/searches", { …, Authorization: Bearer <userBearer> })
  ▼  Next rewrite  /auth/:path*  and  /searches/:path*  →  ROAMGAP_BACKEND_URL/...
roamgap-backend  (AuthGuard verifies the cache-stored Bearer; same-origin → no CORS)
```

- **Transport: Next `rewrites()`.** `/auth/:path*` and `/searches/:path*` proxy to the backend (`next.config.ts`). This keeps every authenticated call **same-origin** (no CORS, no client secrets) and is why `SignInForm`/`AccountMenu` fetch the relative paths `/auth/token` and `/auth/revoke`. The anonymous `/api/trip` proxy is unchanged — the two mechanisms coexist.
- **Session: `useSession()`.** `{ status: "loading" | "authed" | "anon", user, getBearer(), signOut(), adopt() }`. `getBearer()` returns the in-memory Bearer, minting one from the current Firebase ID token when absent/expired. `adopt(resp)` seeds the Bearer from `SignInForm`'s own exchange so sign-in costs a single round-trip. The Bearer is **never** persisted — only the Firebase session (IndexedDB) survives a reload, and the Bearer is re-minted from it.
- **`authedFetch(path, init)`.** Attaches `Authorization: Bearer`, and on a `401` evicts + re-mints once, then retries — the client mirror of `backendProxy`'s server-side retry.
- **Consumers:** `SaveSearchButton` (`POST /searches`), `/account/saved` (`GET` + `DELETE /searches/:id`). All saved-search reads/writes are user-scoped; Firestore is never touched directly from the browser (backend-only write perimeter, `roamgap-backend/specs/CACHING.md` §10.6).

## Caching strategy

| What | Where | Invalidation |
| ---- | ----- | ------------ |
| Holiday data for `(country, year)` for past years | Firestore `holidays/{country}/{year}` | Never — past years don't change |
| Holiday data for current year | Firestore + 24 h TTL | Daily — captures late-declared holidays |
| Optimize results for popular `(country, ptoBudget)` defaults | Edge cache via Next `revalidate` | 1 h |
| AI destination recommendations | Firestore `ai_results/{tripKey}` keyed by `(home_country, start, end, budget_band)` | 30 days |

## Where features live

| Feature | Spec | Code |
| ------- | ---- | ---- |
| Trip scoring | [specs/ALGORITHM.md](../specs/ALGORITHM.md) | `roamgap-backend/src/optimizer/trip-optimizer.ts` (backend-canonical, ADR-005) |
| Annual planner | [specs/ALGORITHM.md](../specs/ALGORITHM.md) §11 | `roamgap-backend/src/planner/annual-planner.ts` |
| Holiday fetching | `roamgap-backend/specs/HOLIDAY_CLIENT.md` | `roamgap-backend/src/holidays/nager.client.ts` |
| Backend proxy from web | [DECISIONS.md](./DECISIONS.md) ADR-005 | `src/lib/backend.ts`, `src/app/api/{optimize,plan}/route.ts` |
| Multi-country overlap (Phase 4) | TBD — extend ALGORITHM.md §7 | `roamgap-backend/src/optimizer/overlap.ts` (planned) |
| AI destinations (Phase 5) | TBD — new `specs/AI_DESTINATIONS.md` | `src/lib/clients/claude.ts` |

## Phasing — what's in / out *right now*

We are at **Phase 0 — scaffolding**. The deliverable in this repo today:

- Spec and documentation skeleton
- AI-discoverability surface (`/llms.txt`, sitemap, robots, schemas)
- Test infrastructure

Out of scope until Phase 1 lands:
- UI calendar strips
- Real Nager.Date integration
- Any auth, any Firestore, any Stripe

See [specs/PROJECT.md](../specs/PROJECT.md) §10 for the full phased delivery plan.


---

# Decisions (ADRs)

_Source: `docs/DECISIONS.md`_

# Architecture Decision Records

Numbered, append-only. Each record captures the choice, the alternatives, and the reason. New ADRs are added at the bottom; existing ones are never edited (a superseding ADR is added instead).

---

## ADR-001 — Welcome AI crawlers by default

**Date:** 2026-05-30
**Status:** Accepted

### Context
A spectrum of policies exists: from `User-agent: GPTBot \n Disallow: /` (block AI crawlers entirely) through identity-based allowlists, to fully open access. Many publishers in 2025–26 chose the strict end after copyright concerns.

### Decision
We **allow** AI crawlers (`GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended`, `CCBot`, etc.) on all content endpoints. We also actively publish AI-friendly metadata (`llms.txt`, JSON Schema, Markdown twins).

### Why
- The product's value proposition includes "users find us via AI search." Blocking the crawlers that produce that traffic would defeat the strategy.
- All content on the free tier is intentionally public — there is no copyright concern to protect.
- Paid-tier endpoints (`/api/destinations`, `/api/overlap`) are gated by **auth**, not by robots.txt. A robots.txt disallow does not enforce anything.

### Trade-offs
- We accept some duplication of our content in third-party answers. Worth it.
- We must keep `llms.txt` curated — bad index = bad summaries.

### Consequences
- `app/robots.ts` allows `*` and explicitly enumerates AI bots with `allow: ["/"]`.
- `app/sitemap.ts` includes every public route.
- Markdown twins (`/foo.md`) exist for every content page.

---

## ADR-002 — Zod 4 as the only schema library

**Date:** 2026-05-30
**Status:** Accepted

### Context
Need: runtime validation + static types + JSON Schema export, from one definition. Candidates: Zod, Valibot, Yup, ArkType, TypeBox.

### Decision
Zod 4 (`zod@^4.4.3`). JSON Schema via Zod's built-in `z.toJSONSchema()`.

### Why
- Zod 4 ships `z.toJSONSchema()` built-in — no `zod-to-json-schema` dependency.
- Most familiar to the largest pool of contributors (human and AI).
- Excellent error messages by default; integrates cleanly with route-handler validation.
- Zod itself ships [`llms.txt`](https://zod.dev/llms.txt) — they validate the meta-thesis of this project.

### Trade-offs
- Larger bundle than Valibot if we ever ship Zod to the client; we'll lazy-load or use the standalone schema endpoints if that becomes a problem.

---

## ADR-003 — Pure-function algorithm, isolated from Next.js

**Date:** 2026-05-30
**Status:** Accepted

### Context
The trip-optimizer is the product. It must be testable, portable to a future NestJS backend or public API, and never coupled to the Next.js request lifecycle.

### Decision
The optimizer lives in `src/lib/algorithm/` as a pure function `(input, holidays) => output`. Route handlers in `src/app/api/` fetch holidays, call the function, return the result. The function has zero Next.js imports.

### Why
- Trivial to unit-test in jsdom or Node, no HTTP needed.
- Portable to Cloud Functions, NestJS, or a public API library later.
- The team can replace Next.js without rewriting the core product.

### Consequences
- All I/O (holiday fetching, caching, auth) lives in clients/adapters in `src/lib/clients/`, called by the route handler before invoking the optimizer.

---

## ADR-004 — Vitest over Jest

**Date:** 2026-05-30
**Status:** Accepted

### Context
Need: fast unit-test runner that handles TypeScript, ESM, jsdom, React Testing Library, and integrates with Vite/Turbopack toolchains.

### Decision
Vitest 4. Playwright for E2E.

### Why
- ESM-native — Jest still requires gymnastics for ESM in 2026.
- 3–5× faster on the kinds of tests we'll write (pure functions + small components).
- Same matchers/mocking API as Jest, so contributors switch with no friction.
- `@testing-library/jest-dom/vitest` extends matchers without a fork.

### Consequences
- `vitest.config.ts` is the single test config. No `jest.config.*`.
- Coverage via `vitest --coverage` (v8 provider by default).

---

## ADR-005 — Backend-canonical algorithm; web is purely UI

**Date:** 2026-05-31
**Status:** Accepted (supersedes ADR-003 for code location, not for purity)

### Context
Through Phase 1.3 both repos carried byte-mirrored copies of the optimizer and annual planner. A nightly `algorithm-parity` job in [`roamgap-backend/specs/ALGORITHM_INTEGRATION.md`](../../roamgap-backend/specs/ALGORITHM_INTEGRATION.md) §3 caught drift, but every spec change had to land in two PRs and the duplicate was a latent footgun.

Three options were open: (A) keep duplicating, (B) extract a shared `roamgap-shared` package consumed by both repos via TypeScript project references, (C) treat the backend as canonical and have web call out to it. Phase 1.4 made the Phase-2 web routes (`/api/trip`, `/api/year`) thin proxies to the backend, which materially shifted the trade-off.

### Decision
Option **C**. The backend (`roamgap-backend`) owns `computeTrips`, `computeAnnualPlans`, and all supporting helpers. The web app does not run the algorithm in any path; its API routes proxy to the backend via `src/lib/backend.ts`.

### Why
- After Phase 1.4 the web's `src/lib/algorithm/*` was dead at every call site. Building option B would have meant standing up a shared-package toolchain (publishing, versioning, project references) for code the frontend no longer executes.
- Option C costs the HTTP hop the proxy already pays. The parity tests, which existed to detect drift, are no longer needed for the algorithm (one implementation = no drift).
- The pure-function discipline of ADR-003 still applies — it just applies in `roamgap-backend/src/{optimizer,planner}/` instead of web. The algorithm remains framework-free; only its home moved.

### Trade-offs
- Web no longer has a self-contained way to optimize without the backend running. For local development this is fine (`npm run start:dev` in the backend, default `ROAMGAP_BACKEND_URL=http://localhost:4000`); for offline demos it's a regression we accept.
- An additional hop on the request path. Already paid by Phase 1.4 — not new.

### Consequences
- `src/lib/algorithm/`, `src/lib/date.ts`, and the algorithm unit tests (`tests/unit/trip-optimizer.test.ts`, `tests/unit/annual-planner.test.ts`) are removed from `roamgap-web`.
- `specs/ALGORITHM.md` remains in the web repo as the **product contract** — the algorithm spec is normative for the backend implementation. The backend's ALGORITHM_INTEGRATION.md cross-references it.
- The cross-repo `algorithm-parity` test in ALGORITHM_INTEGRATION.md §3 is retired. The `schema-parity` test on `specs/domain.ts` ↔ `src/common/schemas/domain.ts` still applies — Zod schemas are mirrored, not centralised.
- ADR-003 (pure-function algorithm) remains in force; this ADR only relocates the algorithm, not its discipline.

---

## ADR-006 — Canonical development order: Spec → Figma → Development

**Date:** 2026-06-03
**Status:** Accepted

### Context

The first iteration of [SPEC_FIRST.md](../specs/SPEC_FIRST.md) named only two stages — "spec change first, code follows" — and the [FIGMA_REFERENCE.md "Sync workflow"](../specs/FIGMA_REFERENCE.md) section recorded a `spec → code → Figma` order: code lands first, Figma catches up.

That order has two failure modes we hit in practice:

1. **Visual drift** — when code ships before Figma is updated, contributors implementing follow-on features have no canonical visual reference. They invent treatments from the spec text alone, producing UIs that don't match the realised design library. The Phase 1 Slice 1 home page was the worst offender: it shipped with copy ("Pick a country and a PTO budget to start.") that contradicted the Figma's self-explanatory hero.
2. **Spec/design feedback loop is lost** — when designers draw against a stale spec they catch ambiguity that the text alone hides. Drawing the SubdivisionPicker, for example, surfaced the "where does the sub-listbox sit relative to the country combobox?" question that the spec didn't pin down. If Figma had come *after* code, the answer would have been "wherever the code put it" — a missed opportunity to validate the design.

Three orders were considered: (A) keep `spec → code → Figma`, (B) `spec → Figma → code` (Figma is the design review of the spec, code is the mechanical follow-up of both), (C) parallel `spec → (Figma | code)` with no enforced order.

### Decision

Option **B**. The canonical order is:

```
Spec → Figma → Development (test → impl) → green
```

- **Spec** owns the contract — behaviour, props, copy, accessibility, errors. Text only. The PR is the design review at the contract layer.
- **Figma** owns the visual realisation of that contract. Drawing the component is the design review at the visual layer. Catches ambiguity, surfaces layout decisions, and produces the canonical screenshot in `specs/figma/`.
- **Development** owns the executable form: failing test → implementation → `npm run check` green. Tests cross-reference the spec section; the implementation references both the spec and the Figma screenshot.

Each stage's output is the input to the next. The Figma stage is **never** skipped silently; if it's deferred (because the spec change is non-visual, or because design capacity is the bottleneck), the deferral is recorded explicitly in the relevant spec — as `FIGMA_REFERENCE.md` Slice 1 additions already does.

### Why

- **Visual drift is caught at design time, not runtime.** When the Figma screenshot lands before code, the implementer has a single image to match against. There is no "I'll figure out the layout from the props list" guesswork.
- **The spec gets sharper.** Drawing a component forces decisions the text alone can defer (hierarchy, alignment, focus order, empty-state copy). Those decisions feed back into the spec as a small follow-up edit before code starts.
- **The order matches how humans actually work** — designers draw against the spec, then engineers build against both. The previous "code first, Figma catches up" order matched a *runtime* expedient (it's faster to ship code than to draw), not a quality discipline.
- **AI agents collaborate cleanly with this order.** A spec PR is reviewable in text. A Figma update is reviewable as a screenshot. A code PR is reviewable as a diff. Mixing them produces hard-to-review monoliths.

### Trade-offs

- **A Figma session adds wall-clock time before code starts.** For non-visual changes (URL grammar, algorithm contract, schema fields) the Figma stage is a no-op and we don't pretend otherwise — those PRs go `spec → development` and the FIGMA_REFERENCE.md entry calls out "no Figma change" explicitly.
- **Figma capacity can bottleneck code.** The mitigation: spec changes that are visually small (one new variant, a copy change) can run their Figma update inline with the code PR if the spec PR's description names that. Larger visual changes get a dedicated Figma session, which lands its screenshots in `specs/figma/` before the code PR opens.
- **This ADR superseded the FIGMA_REFERENCE.md §"Sync workflow" prescription** — that section read `spec → code → Figma` and was updated to match this ADR in the same PR.

### Consequences

- [specs/SPEC_FIRST.md](../specs/SPEC_FIRST.md) §"Three-phase order" is the authoritative restatement of this ADR.
- [AGENTS.md](../AGENTS.md) §1 is rewritten as "Spec-first **and** Figma-second" with the order explicit.
- [docs/AI_AGENTS.md](./AI_AGENTS.md) Part 1 "The big rule" inserts the Figma stage between spec and test.
- [docs/TESTING.md](./TESTING.md) intro updates "spec → failing test → implementation → green" to "spec → Figma → failing test → implementation → green".
- [specs/FIGMA_REFERENCE.md](../specs/FIGMA_REFERENCE.md) §"Sync workflow" is rewritten to flow `spec → Figma → code`.
- [specs/COMPONENTS.md](../specs/COMPONENTS.md) "What this inventory implies for Figma" closing block is updated so the order is explicit.
- [specs/README.md](../specs/README.md) "How code uses these" and "How designers use these" sections name the order at the top.
- Commit message convention extends from `spec: → test: → impl(spec:#NNN):` to `spec: → design(figma): → test: → impl(spec:#NNN):` where the `design(figma):` commit is the screenshot refresh in `specs/figma/` and any FIGMA_REFERENCE.md edits. When the Figma stage is a no-op (non-visual change), the commit is omitted and the spec PR notes "no Figma change — non-visual."

---

## ADR-007 — Fold `/trip` into `/year` as a `view` mode

**Date:** 2026-06-22
**Status:** Accepted

### Context

The planner shipped as **two near-identical pages**: `/year` (whole-year planning — the headline experience) and `/trip` (the single-trip / "best break for N leave days" finder). They share the same inputs (country, region, year, leave, filters, calendars), the same `TripCard`/`CalendarStrip` result components, the same audience, and the same backend shape — `/trip` differs only in reading `pto` as a single-break budget and offering a `sort`. The duplication cost was real: two forms to keep in lockstep (the "different card sizes" bug, [COMPONENTS.md § AdvancedCalendarFilters]), two heroes, two `.md` twins, two sitemap/llms entries, and two SEO surfaces competing for overlapping queries. PROJECT.md §4.1.3 had flagged the open "could `/year` + sorting subsume the single-trip finder?" consolidation question.

A second realisation sharpened the call: `/trip` does **not** answer "when should I visit country X" (weather, events, crowds — that is the AI-destinations layer, PROJECT.md §7). It answers a narrower **leave-math** question — "given the public holidays, when is the most leave-efficient single break?" That is genuinely a *mode of the same planner*, not a separate product.

### Decision

Fold `/trip` into `/year` as a **mode** selected by a new URL param **`view`** (`year` = whole-year, default; `trip` = single-break). One page, a `ModeToggle` at the top, whole-year as the cold default with the chosen mode **sticky** via a first-party cookie. `/trip` the UI path is retired (404, not redirected — it carried no indexed traffic). The single-break finder also gains a third sort, **`date`** (date-in-year), alongside `efficiency` and `length`.

- **`view`, not `mode`.** `mode` is already taken by the whole-year `budgetMode` param (and reserved for a future single-break "compare budgets" mode, URL_FORMAT.md §3.4), so the mode selector is `view`.
- **The backend is untouched.** `POST /api/trip` and `POST /api/year` stay; this is a **frontend route consolidation only**. The two modes proxy to their respective endpoints. (Consistent with ADR-005.)

### Why

- **Less to learn and maintain.** One planner with a toggle beats two pages a visitor must choose between before they understand the difference. The shared form/result components stop being "shared across two routes" and become "one route, two modes" — drift has nowhere to hide.
- **SEO consolidation.** Two pages competing for overlapping "maximise annual leave" queries split link equity; one canonical `/year` concentrates it. The indexers had the sitemap but had not crawled `/trip`, so the fold cost no indexed traffic — a clean moment to consolidate.
- **Sticky-by-cookie keeps the canonical surface stable for crawlers.** Crawlers send no cookie, so the indexed bare `/year` is always the whole-year default; stickiness personalises only for returning humans, with the URL `view` always winning for shared links.

### Trade-offs

- **No redirect from `/trip`.** Accepted because there was no indexed traffic; a 308 redirect remains a cheap future addition if an external link surfaces (URL_FORMAT.md §7).
- **The `date` sort is a backend algorithm change** (it sorts the Pareto front before the top-N slice, ALGORITHM.md §4), so it touched the mirrored `SortKey` schema in both apps and `trip-optimizer.ts` — a schema-parity change, per root CLAUDE.md §2.
- **One more piece of request-time state** (the `rg_view` cookie) on the highest-traffic page. The page was already dynamic (it reads `searchParams` and proxies a fetch), so reading a cookie costs nothing extra.

### Consequences

- [PROJECT.md §4.2](../specs/PROJECT.md) is rewritten as "Single-break mode (`/year?view=trip`)"; §4.2.1 specifies the selectable-card + `TripSelectionBar` export model; §13.2 and §13.5 are resolved.
- [URL_FORMAT.md](../specs/URL_FORMAT.md) §2/§3 fold the two paths into `/year` with the `view` param and the `sort=date` option; the worked examples use `/year?view=trip`.
- [COMPONENTS.md](../specs/COMPONENTS.md) adds `ModeToggle` and `TripSelectionBar`, updates `TripCard` (selection checkbox), `ResultsHeader` (3 sorts), and removes the per-card `ExportCalendarButton`.
- `SortKey` gains `"date"` in **both** `specs/domain.ts` and `roamgap-backend/src/common/schemas/domain.ts`; `trip-optimizer.ts` adds the date branch.
- `src/app/trip/` and `src/app/trip.md/` are deleted; `/year.md?view=trip` serves the single-break markdown twin. `nav-items.ts`, `sitemap.ts`, `llms.txt`, `json-ld.ts`, and the doc `.md` routes drop the standalone `/trip`.
- The autoload "✓ … filled in from your saved profile" caption is removed from the planner forms and the group create/join screens (a parallel declutter folded into the same change, PROJECT.md §4.2.1).

---

## ADR-008 — Remove the single-break (`view=trip`) UI; `/year` is whole-year only

**Date:** 2026-06-22
**Status:** Accepted — **supersedes the UI half of [ADR-007](#adr-007--fold-trip-into-year-as-a-view-mode)**

### Context

[ADR-007](#adr-007--fold-trip-into-year-as-a-view-mode) folded `/trip` into `/year` as a `view=trip` mode behind a `ModeToggle`. Shipped the same day, on review the single-break finder was judged **not worth keeping as a user-facing surface**: the whole-year planner already answers "make the most of my leave," the extra mode added a toggle, a sticky cookie, a second hero, a second `.md` twin branch, and a parallel results surface — for a narrower question most visitors never ask. The product call: **keep one planner — "Plan my year" — and delete the single-break UI entirely**, while keeping the genuinely useful pieces ADR-007 introduced (the three-way result **sort** and the selectable-card **export** model) by moving them onto the whole-year results.

### Decision

- **Delete the single-break UI.** No `ModeToggle`, no `view` param, no `rg_view` cookie. `/year` renders the whole-year planner only. `?view=trip` is an unknown param — silently ignored (it renders the whole-year page), like any other unknown param ([URL_FORMAT.md §6](../specs/URL_FORMAT.md)).
- **Keep the `/api/trip` backend endpoint and the `SortKey` schema** (incl. `"date"`). This is a **frontend removal only** — the backend is untouched, mirroring ADR-007's "backend untouched" stance. `src/lib/url/trip.ts` stays (it owns the shared observed-calendar codec `year.ts` depends on, and the `/api/trip` URL grammar, still tested).
- **Migrate the sort control onto the whole-year results.** The results now carry a **`SortToggle`** (efficiency · length · date), re-using the existing `optimizer.result.sort*` copy. Unlike single-break (where sort re-ran the backend before the top-N slice), the year plan's trips are already computed, so this is a **client-side reorder** of the selected plan's trips — no URL param, consistent with how the strategy pick is already client-only.
- **Default sort = `date` (chronological).** A year plan is lived chronologically and `YearPlan.trips` is already stored start-ascending, so the default order is unchanged; efficiency/length are opt-in re-orders.
- **Max trip length defaults to one week (7).** The whole-year hero's `MaxTripPresets` (1w/2w/3w chips) is deleted; the static default drops from 14 → 7. A signed-in user's saved `maxTripLength` still overrides it (the profile seed is unchanged).
- **Year results render as a two-up grid.** Each `TripCard` was a full-width row with a fixed-width calendar and a wide empty gutter. Cards now flow **two per row** from `sm:` up; an odd final card spans the full width and switches to a side-by-side (`wide`) layout (calendar left, stats right) so it fills the row instead of stranding the gutter.

### Why

- **One planner is less to learn, maintain, and rank for.** The toggle/cookie/second-twin machinery existed only to host a mode we decided not to keep. Removing it concentrates everything on the surface that converts.
- **The valuable parts survive.** Sorting results and selecting cards to export are useful on the whole-year plan too — they move there rather than die with the mode.
- **No indexed traffic lost.** `/trip` already 404'd (ADR-007) and `/year?view=trip` was days old and never the canonical indexed surface (crawlers always got the whole-year default).

### Trade-offs

- **Old saved single-break (`optimize`) searches** can still exist in accounts. They now link to the **whole-year** planner pre-filled with their country/region/leave (`max` defaulting to 7) rather than a single-break view; the "Trip" badge is retained as the honest record of what was saved. The saved-searches list never bricks ([FIRESTORE schema-drift note]).
- **`/api/trip` is now dormant** (no UI caller). Kept deliberately — it's a tested contract and a cheap option to revive single-break later, and removing it would be a backend change out of this frontend scope.
- **Some ADR-007 artefacts are deleted, not just unwired:** `ModeToggle`, `view-mode.ts`, `OptimizerForm`(+`WithPrefs`), `OptimizeResultsHeader`, `ResultsHeader`, `SelectableTripList`, `TripList`, `MaxTripPresets`, and their tests. `ResultsHeader`'s sort pills are re-extracted as the leaner `SortToggle`.

### Consequences

- [PROJECT.md §4.2](../specs/PROJECT.md) is rewritten as **"Whole-year results — sort, selection & export"**; the single-break mode (§4.2 / VOICE §4.12, §4.22, §4.10) is retired. §4.1.2/§4.1.3 lose their single-break asides; the max-default + presets removal land in §4.1.2.
- [URL_FORMAT.md](../specs/URL_FORMAT.md) drops the `view` param (§2/§3.1/§5 rule 4/§7) and the single-break-only §3.2 table + worked examples; `sort` becomes a UI control, not a URL param, on `/year`. The `/api/trip` JSON grammar note remains.
- [COMPONENTS.md](../specs/COMPONENTS.md) marks `ModeToggle`/`OptimizerForm`/`ResultsHeader`/`TripList`/`MaxTripPresets`/the trip-finder hero **Removed (ADR-008)**, adds **`SortToggle`**, and updates `TripCard` (the `wide` variant), `ResultsClient` (grid + sort), `TripSelectionBar`/`AdvancedCalendarFilters`/`FormActionBar` (now whole-year only), and `SavedSearchesList` (optimize → whole-year link).
- `nav-items.ts`, `sitemap.ts`, `llms.txt`, `json-ld.ts` (`SearchAction` target), the 404, `GLOSSARY.md` ("Trip finder"), `AI_AGENTS.md`, and `domain.ts` comments drop the `view=trip` surface. `SortKey` and `/api/trip` are **kept**.


---

# AI agents

_Source: `docs/AI_AGENTS.md`_

# Working with AI agents

roamgap is built **with** AI agents and **for** AI agents. Two audiences:

1. **Agents editing this codebase** — Claude Code, Cursor, Copilot, etc. This file is their rulebook.
2. **Agents consuming the deployed site** — AI search, browser-using agents, anything fetching `roamgap.ike.work` to answer a user's question.

The rules for both are below.

---

## Part 1 — Agents editing the codebase

### The big rule

**Spec → Figma → Development (test → impl).** This is the canonical order for every change. Read [`specs/SPEC_FIRST.md`](../specs/SPEC_FIRST.md) for the authoritative restatement and [`docs/DECISIONS.md` ADR-006](./DECISIONS.md) for the rationale. The order is normative — never reversed, never silently skipped.

The full sequence for any change that touches user-visible or API-visible behaviour:

1. **Read the spec section** you're about to change.
2. **Update the spec** if the change introduces new behaviour, copy, props, errors, or accessibility requirements. Title: `spec:`.
3. **Update Figma** to realise the spec change in the design library at file key `gzgZd6XTnFVwHHbPxKuac2`. Refresh the relevant screenshot in `specs/figma/` and update [FIGMA_REFERENCE.md](../specs/FIGMA_REFERENCE.md). Title: `design(figma):`. *Skip this step only when the change is non-visual* (URL grammar, schema fields, algorithm contract, telemetry property allowlist); record the skip explicitly in the Stage 1 PR description as "no Figma change — non-visual."
4. **Write a failing test** that names the spec section being enforced (e.g. `it("does not deduct PTO for a holiday on a weekend (§5.1)", …)`).
5. **Implement** the smallest change that makes the test pass.
6. **Green** — `npm run check` must pass.

If you skip steps 1–3, your PR will be reverted regardless of how clean the code looks. If you skip step 3 for a change with visual surface, your PR will be reverted regardless of how complete the code looks — that's the failure mode (visual drift) the order exists to prevent.

### Before touching anything

Read these, in order:

1. [`AGENTS.md`](../AGENTS.md) at the repo root — §1 is the load-bearing "Spec → Figma → Development" rule.
2. [`specs/SPEC_FIRST.md`](../specs/SPEC_FIRST.md) — the canonical three-phase workflow with diagrams.
3. [`specs/PROJECT.md`](../specs/PROJECT.md) — what we're building.
4. [`specs/ALGORITHM.md`](../specs/ALGORITHM.md) — the contract for the core feature.
5. [`docs/ARCHITECTURE.md`](./ARCHITECTURE.md) — where things live.
6. [`docs/DECISIONS.md`](./DECISIONS.md) — ADRs. ADR-006 is the rationale for the order.
7. For UI work, additionally [`specs/COMPONENTS.md`](../specs/COMPONENTS.md), [`specs/DESIGN_SYSTEM.md`](../specs/DESIGN_SYSTEM.md), [`specs/FIGMA_REFERENCE.md`](../specs/FIGMA_REFERENCE.md).
8. The Next.js docs bundled at `node_modules/next/dist/docs/` for any framework-level question. Next.js 16 ships breaking changes — your training data is probably wrong.

### Don't trust your priors about Next.js

`AGENTS.md` says it plainly: "This is NOT the Next.js you know." Async params, async `cookies()` / `headers()`, dynamic-by-default `GET` route handlers, the `RouteContext<'/path'>` global, Turbopack defaults — verify in `node_modules/next/dist/docs/` before writing.

The bundled docs may include **planted hints** that don't match reality (this is how the Next team tests that agents are reading docs). If a hint says "export `unstable_X` from your route," verify it exists in the public API surface before believing it.

### Domain code goes in `src/lib/`

`src/app/` is for routes. Route handlers should be ~10 lines: parse input, call lib, return result. Anything bigger is a smell. Put logic in `src/lib/` so it's testable without spinning up Next.

### Validate at boundaries

Every value entering the system passes through `Schema.parse()` first. `specs/domain.ts` is the single source. Don't define ad-hoc validators in components — extend `domain.ts`, regenerate JSON Schema (`npm run spec:emit`), and re-export from `src/lib/domain/`.

### Don't add features that aren't in the spec

If you notice something missing while working on a ticket, **don't quietly add it**. Open a spec PR or a follow-up task. Scope creep in an AI-driven workflow is invisible and costly.

### Tests are how you communicate intent

A test title is documentation. Prefer `it("does not deduct PTO for a holiday on a weekend (§5.1)", …)` over `it("handles weekend case", …)`. Cross-reference the spec section.

### Comments

Default to none. The spec is the explanation; the code is the implementation. Comments are reserved for *why this code looks weird* (workaround, invariant, perf hack). Never "added for issue #X" — that rots.

### When something is unclear

Ask. In the PR description if you're a human; via the chat thread if you're an agent. Never guess at intent. A spec PR with a short "I think we want X, confirm?" is much cheaper than a wrong implementation.

---

## Part 2 — Agents consuming the deployed site

We want AI search and browser-using agents to extract clean, structured answers from roamgap. Here's what we expose:

### `/llms.txt`

The first stop. Short, hand-curated index of the most useful URLs and what each contains. Format follows the [llms.txt convention](https://llmstxt.org/).

### `/llms-full.txt`

Full content of the durable documentation pages, concatenated. Suitable for stuffing into a single LLM context window. Refreshed at build time.

### Markdown twins

Every content page has a `.md` companion. `/about` → `/about.md`. The `.md` version is the same content stripped of layout and chrome. Agents that prefer plain text can `Accept: text/markdown` on the main URL or request the `.md` directly.

### Structured data

Every page emits JSON-LD in `<script type="application/ld+json">` blocks following [schema.org](https://schema.org/) types. Helpers live in `src/lib/seo/`. Specifically:

- Home page: `WebSite` + `SoftwareApplication`
- Article / docs pages: `Article` with author, datePublished, dateModified
- Result pages (trip output): `Dataset` describing the rankings

### JSON Schema for every API entity

`GET /api/schemas` lists every entity. `GET /api/schemas/{Name}.json` returns one. AI agents calling the API should fetch the schema once and validate inputs/outputs against it.

### OpenAPI

`GET /api/openapi.yaml` and `GET /api/openapi.json` return the same document under different content types. Pointed to by the OpenAPI top-level fields on the home page.

### `/.well-known/`

- `/.well-known/ai-content.json` — discovery manifest, points at `/llms.txt`, OpenAPI, the schema index, the sitemap.
- `/.well-known/security.txt` — when we have a security policy.

### Robots

We **allow** AI crawlers by default. The roamgap thesis is that AI-search-driven traffic is a first-class user; locking out agents that drive that traffic would be self-defeating. See [docs/DECISIONS.md](./DECISIONS.md) ADR-001.

The exceptions are paid-tier-only endpoints (`/api/destinations`, `/api/overlap` once they exist), gated by auth rather than robots.

### Stable URLs

Shareable optimizer URLs encode all parameters in the path/query. An agent should be able to construct one without ever calling the API:

```
/year?country=DE&year=2026&pto=30&max=14
```

Format is fixed and documented at `/about/url-format` (and `/about/url-format.md`).

---

## What we won't ship to agents

- **Captchas on read-only endpoints.** Pointless given the API is public.
- **Aggressive rate limits on cached content.** The cache is the rate limit.
- **`User-Agent` discrimination.** We rate-limit by IP and behaviour, not by identity.
- **Tracking pixels in the Markdown twins.** They're text. Keep them text.


---

# Testing

_Source: `docs/TESTING.md`_

# Testing

roamgap is **test-driven inside development**. The full ship order is **Spec → Figma → failing test → implementation → green** — Stages 1 and 2 land in their own commits before this document's discipline applies. See [`specs/SPEC_FIRST.md`](../specs/SPEC_FIRST.md) for the three-phase rule and [`docs/DECISIONS.md` ADR-006](./DECISIONS.md) for the rationale. This document is the operational guide for Stage 3 (Development): once the spec is updated and Figma is refreshed, write the failing test, then the implementation.

## Three layers

| Layer | Tool | Where | Speed | Catches |
| ----- | ---- | ----- | ----- | ------- |
| Unit | Vitest + jsdom | `tests/unit/`, `src/**/*.test.ts` | < 5 s | Schema parsing, component rendering, accessibility |
| Integration (handler-level) | Vitest, no HTTP | `tests/unit/api-*.test.ts` | < 5 s | Route handler input/output shape, proxy auth + retry semantics |
| End-to-end | Playwright | `tests/e2e/` | < 60 s | Real `next build && next start` server: the served response (status + `content-type`), the `next.config.ts` rewrites, JSON-LD **as emitted into the document**, `/llms.txt`, Markdown twins, shareable URLs |

> Algorithm correctness lives in [`roamgap-backend`](../../roamgap-backend) since the Phase 1.4 cutover (see [DECISIONS.md](./DECISIONS.md) ADR-005). The contract for the algorithm — [specs/ALGORITHM.md](../specs/ALGORITHM.md) — is still owned by this repo because it is the product spec; the backend's unit and e2e tests verify the implementation against it.

## Commands

```bash
npm test              # Vitest watch mode
npm run test:run      # Vitest single run (CI default)
npm run test:coverage # Coverage report
npm run test:e2e      # Playwright
npm run test:e2e:ui   # Playwright UI mode
npm run check         # typecheck + lint + test:run, in that order
```

## The TDD loop (Stage 3 of Spec → Figma → Development)

Stages 1 and 2 (Spec and Figma) have already completed before this loop starts. The test author is encoding a **specified** behaviour — never inventing one.

1. **Pick a behaviour from the spec.** Re-read the relevant section of [specs/ALGORITHM.md](../specs/ALGORITHM.md), [specs/PROJECT.md](../specs/PROJECT.md), [specs/COMPONENTS.md](../specs/COMPONENTS.md), or [specs/ERROR_STATES.md](../specs/ERROR_STATES.md). For UI behaviour, also open the relevant Figma screenshot — the test should assert what the screenshot shows, not just what the text says.
2. **Write a failing test that names that behaviour.** The test title should be the spec sentence with the section reference appended in parentheses. Example: `it("does not deduct PTO for a holiday that falls on a weekend (§5.1)", ...)`. For component tests, reference both the COMPONENTS.md section and any cross-cutting spec section, e.g. `(§CountryPicker · §PROJECT 5)`.
3. **Run it.** It must fail for the right reason — not "function doesn't exist," but "result was X, expected Y."
4. **Implement.** Smallest change that makes the test pass. Reference both the spec section and the Figma screenshot when adding visual treatments.
5. **Refactor.** Tests stay green.

If step 2 is hard because the function-under-test is buried inside a route handler, move the logic into `src/lib/` and call it from the route handler. Pure functions are vastly easier to test.

### If the spec is silent on the behaviour you need to test

You're in an unspecified region. Stop. Go back to Stage 1 (open a spec PR). If the unspecified behaviour has visual surface, also run Stage 2 (Figma) before resuming Stage 3. Never write a test that encodes a behaviour the spec doesn't describe — that locks the codebase to your interpretation rather than to a reviewed contract.

## What we won't test

- Next.js's own routing — Vercel tests it.
- Third-party API responses — we test our parsing, not the upstream.
- Tailwind class output — visual regression is what e2e screenshots are for, and we'll only add that once UI exists.

## Test data

- **Date math** uses real, well-known holiday dates from real countries. Mocking the calendar makes tests harder to read and easier to break silently. The canonical holiday fixtures now live in `roamgap-backend/test/fixtures/holidays/{country}-{year}.ts` alongside the algorithm tests.
- **No `Date.now()` in tests.** If logic depends on "today," accept a `now` parameter and pass an `ISODate` literal in the test.
- **Determinism.** Tests assert exact output for the same input. Tie-breakers in the algorithm are specified ([ALGORITHM.md §5.7](../specs/ALGORITHM.md)) so identical inputs always yield identical orderings.

## Failure triage

When a test fails:

1. Does the spec describe the *failed* behaviour or the *passing* behaviour? If the spec describes the failed behaviour, the code is wrong — fix the code.
2. If the spec describes the passing (old) behaviour and the test is enforcing something new, the **spec is stale**. Update the spec first, then update the test.
3. If neither side documents the behaviour, you're in an unspecified region. Open a spec PR. Don't just "fix" the test to whatever the code currently does.

## CI

`npm run check` is the gate. It must pass before merge:

- TypeScript: `tsc --noEmit`
- Lint: `eslint`
- Unit: `vitest run`

**Playwright is not in the gate and does not run in CI.** [`.github/workflows/check.yml`](../../../.github/workflows/check.yml) runs each app's `check` and nothing else — deliberately: the gate is hermetic (no secrets, no service) and fast (no production build, no browser download). Spinning the production build and installing browsers would add minutes to every PR.

So the e2e suite is **run explicitly, by whoever changes the surface it covers**:

```bash
npm run test:e2e     # spins its own `next build && next start`
```

A first run needs browser binaries: `npx playwright install chromium firefox`. Because nothing runs it for you, treat a change to the discoverability surface (twins, rewrites, JSON-LD, `llms.txt`) as owing an explicit e2e run — and say so in the PR. Wiring it into CI is a live option, not an oversight; it is a cost/latency call that hasn't been made.


---

# Glossary

_Source: `docs/GLOSSARY.md`_

# Glossary

Terms used across specs, docs, code, and UI. If something here is wrong or missing, update this file *before* using the term in a PR — consistent vocabulary is part of the spec contract.

| Term | Definition |
| ---- | ---------- |
| **Trip** | A continuous calendar range a user could be on holiday for. Has a `start`, `end`, `totalDays`, `ptoDays`, and `efficiency`. |
| **Annual planner** | The primary input mode: user gives annual PTO + max trip length; the algorithm returns five alternative *year plans*. See [ALGORITHM.md §11](../specs/ALGORITHM.md). |
| **Year plan** | A set of non-overlapping trips for a single year, each within the user's `maxTripLength`, together summing to at most the annual PTO budget. |
| **Plan strategy** | One of five selection rules a year plan can apply: `packed-efficiency`, `packed-length`, `spread-quarterly`, `spread-even`, `single-long`. |
| **Budget mode** | `exhaust` (spend the full PTO budget) or `selective` (stop when next-best efficiency drops below the floor). User-toggled per query. |
| **Trip finder** | The single-break finder (a standalone `/trip` page, then briefly the `/year?view=trip` mode) — **removed from the UI** ([ADR-008](./DECISIONS.md)); the `POST /api/trip` backend endpoint remains. `/year` is whole-year only. |
| **Efficiency** | `totalDays / max(ptoDays, 1)`. The primary score. Higher is better. See [ALGORITHM.md §3](../specs/ALGORITHM.md). |
| **PTO** | Paid time off — vacation days an employee can take. The currency the optimizer spends. |
| **PTO budget** | Maximum PTO the user is willing to spend on the trips the optimizer returns. |
| **Workweek** | The set of weekdays counted as working days in a country. Default is Mon–Fri (`[1,2,3,4,5]`). Sun–Thu workweeks (`[7,1,2,3,4]`) are valid for some countries. |
| **Bridge day** | A single working day adjacent to a holiday + weekend block, where taking it as PTO converts a 3-day stretch into a 4-day one (or longer). High-leverage. See [ALGORITHM.md §5.2](../specs/ALGORITHM.md). |
| **Substitute holiday** | A working day declared as a holiday because the statutory date fell on a weekend (e.g. US: Fri Jul 3 when Jul 4 is a Saturday). Upstream APIs typically return the substitute, not the original. |
| **Blocked range** | A `DateRange` the user marks as unavailable (already-booked travel, family commitments). Any trip intersecting a blocked range is rejected. |
| **Holiday lost to weekend** | A statutory holiday whose date falls on the weekend with no substitute declared. Reported in `meta.holidaysLostToWeekend` so the UI can surface it. |
| **Composition** | The day-by-day breakdown of a trip: each day is `weekend`, `holiday`, `pto`, or `blocked`. |
| **Free tier** | No account, current-year national holidays, top-5 trips, shareable URLs, browser-session persistence only. |
| **Paid tier** | Account-gated. Adds historical data, regional holidays, multi-country overlap, AI destinations, saved searches, calendar export, email alerts. |
| **Multi-country overlap** | Finding trips where N people in N countries can all be off simultaneously, each within their own PTO budget. Paid-tier feature, Phase 4. |
| **AI destinations** | Claude-generated travel recommendations scoped to a specific calculated trip. Paid-tier feature, Phase 5. |
| **Markdown twin** | The `.md` companion of a content URL. `/about` ↔ `/about.md`. Plain-text-for-agents version of the same content. |
| **Spec-first** | The workflow: change specs (`specs/`) before changing code. See [SPEC_FIRST.md](../specs/SPEC_FIRST.md). |
| **Test-first** | The discipline: write a failing test that names the new spec behaviour before the implementation. See [TESTING.md](./TESTING.md). |


---
