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
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
- Tokens:
packages/tokensgainsthemes.ts(LEAF):monochrome,warmandtintedTheme(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 auseSyncExternalStorestore, so there is no flash of the wrong theme. Also stored inaccount_preference.appearanceso 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 inapps/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-themeon<html>), preference fromaccount_preference.appearance; the marketing site stays as designed.
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)
- Clamp: the top of the page gradient sits at OKLCH lightness 0.28–0.34, not 0.38. At 0.38,
text3on the tinted top measured 4.2:1 for most hues, below AA; the table test inpackages/tokens/src/themes.test.tsholds the band. - Seed extraction:
vehicle-photo.color.tsreads 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_atmarks a cover as read, so the cron jobcoverColorsis 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 passesnulland 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.