# ADR-0016: Appearance themes and car colour

The repository's architectural decision, rationale and consequences.

# ADR-0016: Appearance themes and car colour

Status: accepted (owner request, 2026-10-23). Amends DESIGN.md §2 ("the photo is the only colour") by making it the default instead of the only option. Pattern taken from the Doro app's ADR-101 (appearance and design tokens).

## Decision [#decision]

Three themes, chosen per device in Profile → Appearance:

- **Monochrome** (default): today's tokens, unchanged.
- **Car colour**: like album covers in Spotify, the app is tinted by the dominant colour of the current car's cover photo. Background is a vertical gradient from the tinted colour to black, cards and controls become translucent white over it, secondary text takes a light tint, the tab bar glass takes the tint. The primary pill stays white; green/red/orange stay reserved for state. Outside a car (Garage list, Finance, Profile), the colour of the first car in the garage is used; with no car, Monochrome.
- **Warm**: coffee-brown backgrounds and cream text (Doro "Quente"): bg `#120E0B`, card `#211A15`, card2 `#2E2620`, line `#3A3029`, text `#F6EFE6`, text2 `#B8AB9C`, text3 `#9C8F80`.

Screens: `Ap-Perfil-Aparencia`, `Ap-Garagem-{Mono,Cor,Quente}`, `Ap-Veiculo-{Mono,Cor,Quente}` on the "Aparência" page of the all-screens canvas.

## How [#how]

- **Tokens**: `packages/tokens` gains `themes.ts` (LEAF): `monochrome`, `warm` and `tintedTheme(seed)`, which derives the full colour set from one seed colour. The seed is clamped in OKLCH (lightness 0.28–0.38 for the top of the gradient, chroma ≤ 0.12) so text keeps WCAG AA on every surface; seeds with chroma < 0.03 (white, silver, black, grey cars) fall back to Monochrome. A table test checks contrast for text, text2, text3 and the primary pill on bg/card/card2 for every theme and for a sample of 24 seeds around the hue wheel.
- **Seed colour**: extracted once per cover photo on the server when the photo is processed (downscale to 64 px, k-means or a median cut over the car region if a Studio mask exists, else the centre 60 %), stored as `vehicle_photo.dominant_color` (hex, nullable) and returned with the vehicle. No client-side image decoding. Backfill job for existing covers.
- **Preference**: stored on the device in `expo-secure-store`, read synchronously at start-up, exposed through a `useSyncExternalStore` store, so there is no flash of the wrong theme. Also stored in `account_preference.appearance` so a new device starts with the same choice (device value wins once set).
- **One seam**: `useAppTheme()` (`apps/mobile/src/design/use-app-theme.ts`) already is the only way components read colours; it now returns the active theme, and screens set the "current car" through a small context (`AppearanceCarProvider`) on the vehicle screens. No component reads hex values directly (an oxlint rule or a test grep enforces it in `apps/mobile/src`).
- **Motion**: changing car cross-fades the colours over 400 ms (Reanimated `interpolateColor`); off with Reduce Motion or with the "Transition between cars" switch.
- **Outside the app**: the passport and the public showcase stay neutral; the showcase may use the car colour only if the owner turns on "Colour on the showcase too". Share cards may use the seed for their background.
- **Web**: the authenticated web app gets the same three themes through CSS variables (`data-theme` on `<html>`), preference from `account_preference.appearance`; the marketing site stays as designed.

## Consequences [#consequences]

Every new component must take colours from the theme; the contrast table test is the gate for any change to `themes.ts`. Monochrome screenshots in tests stay valid because it is the default.

## Implementation notes (AP-1, #63) [#implementation-notes-ap-1-63]

- **Clamp**: the top of the page gradient sits at OKLCH lightness 0.28–0.34, not 0.38. At 0.38, `text3` on the tinted top measured 4.2:1 for most hues, below AA; the table test in `packages/tokens/src/themes.test.ts` holds the band.
- **Seed extraction**: `vehicle-photo.color.ts` reads only each 8×8 JPEG block's DC coefficient (a 1/8-scale image), because a Worker cannot hold a decoded 12-megapixel photo; ~140 ms per 12 MP photo. Pixels in the centre 60 % are weighted toward the middle, k-means clustered, and the cluster with the most weight × saturation wins (a dark blue car beats the warm-lit wall behind it). PNG and WebP covers give no colour. `vehicle_photo.color_checked_at` marks a cover as read, so the cron job `coverColors` is both the pipeline and the backfill.
- **Outside a car**: the first current car _whose cover colour tints_ (`firstTint`), so one white or silver car does not keep the whole app grey.
- **Current car**: instead of a context provider, each car screen calls `useAppearanceCar(vehicleId)`. On focus it sets the car in the appearance store, so the tab bar, which sits outside the screen's tree, also takes the colour. The buyer's passport preview passes `null` and shows no car colour.
- **Colour on the showcase too**: an account switch (`account_preference.showcase_color`, off by default) under Car colour in Profile → Appearance. The showcase snapshot keeps each car's cover colour; the public view carries it only while the switch is on, read when the page opens, so turning it off makes every link neutral again at once. The page takes the first car whose colour tints (`firstTint`), as the app does outside a car.
- **Deferred**: share-card backgrounds. Text colours switch at once; only the page tint cross-fades.

