Ripe design system
Tokens and components for a photo-first calorie calculator. Every value comes from tokens.json (W3C format). tokens.css is generated from it, and components.css uses nothing but those variables. Screens are 390×844, light theme, WCAG AA. Verified with npm run check.
Read me firstAbbreviations
- kcal
- kilocalories: food energy
- P
- protein, in grams
- F
- fat, in grams
- C
- carbohydrates, in grams
- g
- grams
- ml
- millilitres
- pt · dp
- iOS points · Android density-independent pixels (1 pt ≈ 1 dp ≈ 1 CSS px here)
- USDA
- source of every nutrition value (FoodData Central)
FoundationsColour
Two layers. Primitives are the raw palette, and every family is named after a food. Semantic tokens say what a colour is for; components only use these. Each contrast ratio is measured on the background that colour is actually used on.
Primitives
#FFFFFFOnly for text and icons on Persimmon. Never a background.#FFFCF6Rice: cards, sheets#FBF6EEOat milk: app background#F4ECDFToasted oat: sunken areas, inputs#E9DDCBOat flakes: dividers, disabled fills#D6C6B0Bran: decorative borders#B3A18AHusk: disabled text and icons (exempt from contrast)#8C7A66Hazelnut: input borders (3.8:1)#705F49Toasted walnut: disabled text (4.6:1, legible but clearly lighter than secondary)#6B5A4AWalnut: secondary text#4E4034Dark walnut: secondary text (7.2:1 on every light surface)#2B2118Rye crust: primary text#FEF3EC#FCE3CCApricot: tints, selected chips#F9C9A6#F4A06BPersimmon highlight#E2552AFruit orange (illustration only)#C4431APersimmon: brand, primary buttons#9B3515Deep persimmon: primary button fill. White text 7.2:1 (was #A23614, 6.8:1)#7C2A10Persimmon text: links, active tab, accent text (≥ 7:1 on light surfaces); pressed CTA fill#5E200BPressed deep persimmon#F2F6EB#E6EFD9Avocado tint: 'fits your day' tags#A9C47F#4A7326Avocado: freshness accent#3A5B1EText on avocado tint#36551CFresh text (7.2:1 on avocado tint)#FDF3DFWarning tint#FBE7B8#F2B63DHoney: highlights, 'not sure' marker (fill only)#9A6400Turmeric: warning text, over goal#674300Warning text: 'check portion', over goal (7.1:1)#EAF4EE#2E7D4FBasil: success icons, high confidence#24633ESuccess text on tint#215A38Success text (7.2:1 on basil tint)#FCECEA#A8241CChili: errors (fills, icons, rings). White text 7.2:1 (was #B3261E, 6.5:1)#8E1E18#F8E9F0#9B2F63Protein#7C2550#F8F0DA#A77A0BFat (graphics 3.6:1; use 700 for text)#86620A#674B08Fat text (7.1:1 on mustard tint)#EAF0FA#3B6CC0Carbs. The only blue: data marks only, never surfaces.#2F5699#2B4E8BCarbs text (7.2:1 on blueberry tint)#2B211814Rye at 8%: hover and pressed overlays#2B21181FRye at 12%: hairline dividers#2B211866Rye at 40%: scrim behind sheetsBackground
| Swatch | Token | Points to | Value | Contrast | Use |
|---|---|---|---|---|---|
--color-bg-canvas | --color-oat-50 | #FBF6EE | — | App background | |
--color-bg-surface | --color-oat-0 | #FFFCF6 | — | Cards, sheets, bars | |
--color-bg-sunken | --color-oat-100 | #F4ECDF | — | Inputs, steppers, segmented track | |
--color-bg-disabled | --color-oat-200 | #E9DDCB | — | ||
--color-bg-inverse | --color-oat-900 | #2B2118 | 14.64:1 on canvas | Toasts, dark stickers | |
--color-bg-inverse-pressed | --color-oat-700 | #4E4034 | — | Pressed actions inside toasts | |
--color-bg-accent | --color-persimmon-700 | #9B3515 | 7.04:1 on surface | Primary buttons, scan button, badges with white text (white 7.2:1) | |
--color-bg-accent-pressed | --color-persimmon-800 | #7C2A10 | — | ||
--color-bg-accent-subtle | --color-persimmon-100 | #FCE3CC | — | Selected chips, secondary buttons | |
--color-bg-accent-subtle-pressed | --color-persimmon-200 | #F9C9A6 | — | ||
--color-bg-fresh-subtle | --color-avocado-100 | #E6EFD9 | — | 'Fits your day' tags | |
--color-bg-highlight | --color-honey-400 | #F2B63D | — | 'Not sure' marker | |
--color-bg-pressed | --color-alpha-rye-8 | #2B211814 | — | Overlay for pressed rows | |
--color-bg-scrim | --color-alpha-rye-40 | #2B211866 | — |
Text
| Swatch | Token | Points to | Value | Contrast | Use |
|---|---|---|---|---|---|
--color-text-primary | --color-oat-900 | #2B2118 | 14.64:1 on canvas | Body text and headings | |
--color-text-secondary | --color-oat-700 | #4E4034 | 9.26:1 on canvas | Meta, units, helper text, placeholders (≥ 7:1) | |
--color-text-disabled | --color-oat-550 | #705F49 | 5.70:1 on canvas | Disabled only: exempt from contrast, kept legible at ≥ 4.5:1 | |
--color-text-inverse | --color-oat-50 | #FBF6EE | 14.64:1 on inverse | On bg.inverse | |
--color-text-on-accent | --color-white | #FFFFFF | 7.21:1 on accent | On bg.accent | |
--color-text-accent | --color-persimmon-800 | #7C2A10 | 8.89:1 on canvas | Links, text buttons, active tab (≥ 7:1) | |
--color-text-fresh | --color-avocado-800 | #36551C | 7.16:1 on fresh-subtle | On bg.fresh-subtle (7.2:1) | |
--color-text-inverse-accent | --color-persimmon-300 | #F4A06B | 7.56:1 on inverse | Actions on bg.inverse, e.g. 'Undo' in a toast | |
--color-text-inverse-success | --color-avocado-300 | #A9C47F | 8.17:1 on inverse | Success icons on bg.inverse | |
--color-text-inverse-warning | --color-honey-400 | #F2B63D | 8.65:1 on inverse | Warning icons on bg.inverse |
Border
| Swatch | Token | Points to | Value | Contrast | Use |
|---|---|---|---|---|---|
--color-border-subtle | --color-alpha-rye-12 | #2B21181F | — | Dividers (decorative) | |
--color-border-default | --color-oat-200 | #E9DDCB | 1.31:1 on surface | Card outlines (decorative) | |
--color-border-strong | --color-oat-500 | #8C7A66 | 4.03:1 on surface | Input borders: meets 3:1 | |
--color-border-accent | --color-persimmon-600 | #C4431A | 4.92:1 on surface | Selected and focused inputs | |
--color-border-focus | --color-oat-900 | #2B2118 | 15.38:1 on surface | Keyboard focus ring (2 px + 2 px offset) | |
--color-border-error | --color-chili-600 | #A8241C | 6.99:1 on surface | ||
--color-border-inverse | --color-oat-50 | #FBF6EE | 1.05:1 on surface | Lines on dark or camera surfaces: plate guide, shutter ring |
Status
| Swatch | Token | Points to | Value | Contrast | Use |
|---|---|---|---|---|---|
--color-status-success | --color-basil-600 | #2E7D4F | 4.93:1 on surface | ||
--color-status-success-text | --color-basil-800 | #215A38 | 7.23:1 on success-subtle | ||
--color-status-success-subtle | --color-basil-50 | #EAF4EE | — | ||
--color-status-warning | --color-honey-700 | #9A6400 | 4.54:1 on warning-subtle | Warning icons, over-goal ring (graphics ≥ 3:1). Use warning-text for text | |
--color-status-warning-text | --color-honey-800 | #674300 | — | Warning TEXT (7.1:1). status.warning is for icons and graphics only | |
--color-status-warning-subtle | --color-honey-50 | #FDF3DF | — | ||
--color-status-error | --color-chili-600 | #A8241C | 6.99:1 on surface | Real errors only | |
--color-status-error-text | --color-chili-700 | #8E1E18 | 7.80:1 on error-subtle | ||
--color-status-error-subtle | --color-chili-50 | #FCECEA | — |
Macros
Protein, fat and carbs. A magenta, gold and blue triad that stays separable under protanopia, deuteranopia and tritanopia (ΔE ≥ 29). Always shown with the letter P, F or C and a value.
| Swatch | Token | Points to | Value | Contrast | Use |
|---|---|---|---|---|---|
--color-macro-protein | --color-beetroot-600 | #9B2F63 | 6.88:1 on surface | ||
--color-macro-protein-text | --color-beetroot-700 | #7C2550 | 8.00:1 on protein-subtle | ||
--color-macro-protein-subtle | --color-beetroot-50 | #F8E9F0 | — | ||
--color-macro-fat | --color-mustard-600 | #A77A0B | 3.78:1 on surface | ||
--color-macro-fat-text | --color-mustard-800 | #674B08 | 7.14:1 on fat-subtle | ||
--color-macro-fat-subtle | --color-mustard-50 | #F8F0DA | — | ||
--color-macro-carbs | --color-blueberry-600 | #3B6CC0 | 5.01:1 on surface | ||
--color-macro-carbs-text | --color-blueberry-800 | #2B4E8B | 7.15:1 on carbs-subtle | ||
--color-macro-carbs-subtle | --color-blueberry-50 | #EAF0FA | — | ||
--color-macro-track | --color-persimmon-100 | #FCE3CC | — | Empty part of the calorie ring | |
--color-macro-kcal | --color-persimmon-600 | #C4431A | — | Calorie ring value (brand persimmon, graphic ≥ 3:1 against the track) |
FoundationsTypography
Three Google Fonts (SIL OFL). Young Serif is for headlines only (20 px and up). Hanken Grotesk is for all UI text. Azeret Mono is for every number, with tabular figures. Sizes are in rem, so they follow Dynamic Type; the pixel values are at the default text size.
--type-display--type-h1--type-h2--type-title--type-body--type-body-strong--type-callout--type-label--type-caption--type-overline--type-num-xl--type-num-l--type-num-m--type-num-sFoundationsSpacing
A 4 pt grid (bars drawn at 4× scale). Use multiples of 8 for layout, and 4 or 12 only for fine steps. Screen side margin: --space-screen-margin (20).
FoundationsRadius & borders
Badges
Chips inside cards, thumbnails
Buttons, inputs, list rows
Cards
Bottom sheets
Border widths
Inputs, chips, steppers
Focus ring thickness (WCAG 2.4.7 / 1.4.11)
Heavy rule in the nutrition facts table
Gap between a control and its focus ring
FoundationsElevation
Warm, rye-tinted shadows, never grey. Most surfaces are flat; elevation marks things that float.
FoundationsIcons & sizes
Drawn on a 24 px grid with a 2 px round stroke, coloured with currentColor. SVG files are in 01-branding/assets/icons/. Confidence icons are always paired with a word.
Component sizes and safe areas
| Token | Value | Use |
|---|---|---|
--size-touch-min | 44px | Minimum hit region on iOS: 44×44 pt (Apple HIG, Buttons & Accessibility) |
--size-touch-android | 48px | Minimum touch target on Android: 48×48 dp (Android accessibility guide, Material 3) |
--size-app-bar-android | 64px | Material 3 small top app bar container height (64 dp) |
--size-tab-bar-android | 64px | Material 3 navigation bar container height (64 dp) |
--size-segment-android | 40px | Segment visual height on Android: 40 + 2×4 padding = 48 dp target |
--size-button-l | 52px | |
--size-button-m | 44px | |
--size-input | 52px | |
--size-app-bar | 56px | Top app bar on iOS (content row below the status bar) |
--size-tab-bar | 49px | Tab bar content height on iOS, plus safe-bottom below it |
--size-tab-indicator-width | 56px | Pressed / active pill inside a tab (Material 3 active indicator 56×32) |
--size-tab-indicator-height | 32px | |
--size-tab-label-max | 14px | Tab labels stop growing at 14 px. Bar items don't scale with Dynamic Type; iOS shows the Large Content Viewer on long-press instead |
--size-scan-button | 56px | |
--size-safe-top | 47px | Status bar (iPhone 14/15) |
--size-safe-bottom | 34px | Home indicator |
--size-screen-width | 390px | |
--size-screen-height | 844px | |
--size-icon-stroke | 2px | Every icon uses a 2 px rounded stroke |
--size-chip | 36px | Visual height; the hit area is extended to 44 px |
--size-thumb | 48px | Product thumbnails |
--size-segment | 36px | Segment visual height on iOS. Track padding (4) makes the hit region 44 |
--size-badge | 24px | |
--size-col-min | 5rem | Minimum width of equal columns (segments, macro columns, macro tiles). rem-based, so at large text sizes columns stack instead of clipping |
--size-empty-icon | 72px | |
--size-shutter | 72px | Camera shutter button (larger than a normal target: the main action on the camera screen) |
--size-plate-guide | 280px | Plate outline in the camera viewfinder |
--size-recipe-thumb | 80px | Image size (1:1) in compact recipe cards. 80 leaves 226 px for the text column at 390 pt: the P / F / C row needs 200 px (225 with 3-digit values), so it stays one row |
--size-icon-inset | 10px | (touch-min 44 − icon 24) / 2: the space between a hit area and its glyph. Bars subtract it so the glyph, not the hit box, sits on the grid line |
--size-macro-chip | 24px | Compact macro chip height (P / F / C). Three chips with 3-digit values fit one row in a 320 pt card |
--size-step-number | 28px | Recipe step number disc |
--size-measure | 40rem | Maximum width of reading text such as recipe steps: 40rem ≈ 75 characters of 16 px body text (WCAG 1.4.8: ≤ 80 characters). In rem, so it scales with text size |
--size-day-ring | 32px | Mini calorie ring around the date in the week strip (27). 7 days × 44 pt fit 308 pt; the ring leaves room for the date inside |
FoundationsPlatform compliance
Every size checked against current Apple and Google documentation (sources in README.md). Android values apply inside data-platform="android".
| Element | Ripe | Requirement | Status |
|---|---|---|---|
| Hit region: every interactive element | 44×44 pt · 48×48 dp | iOS ≥ 44×44 pt · Android ≥ 48×48 dp | fixed (stepper ± was 41 pt, segments 36 pt) |
| UI icons | 24 px glyph on a 24 grid, 2 px stroke; 16 / 20 / 32 for badges, meta and empty states | Material 3: 24 dp · Apple: consistent size and stroke weight | ok |
| Tab bar icons | 24 px in a 44 pt (48 dp) tab; active icon gets a tinted fill | M3 nav bar icon 24 dp, indicator 56×32 · HIG: prefer filled symbols in tab bars | fixed (filled active state) |
| Tab bar height | 49 pt + 34 pt safe area · Android 64 dp | M3 navigation bar container 64 dp · iOS system bar + home indicator | fixed (Android 64 dp added) |
| Top app bar | 56 pt, icons 24 in 44 pt buttons · Android 64 dp | M3 small top app bar 64 dp, icon 24 dp | fixed (Android 64 dp added) |
| Body / minimum text | 16 body · 12 caption minimum · rem-based | iOS min 11 pt, default 17 pt · M3 body large 16 sp, body small 12 sp · support 200% text | ok |
| iOS app icon | 1024×1024, square, opaque PNG + SVG source | 1024×1024 square layers, no pre-masked corners, opaque background | fixed (opaque PNG exported) |
| Google Play icon | 512×512, 32-bit PNG, square, opaque | 512×512 32-bit PNG, sRGB, ≤ 1024 KB, full square | fixed (PNG exported) |
| Android adaptive icon | 108 dp layers, artwork ≈ 60 dp inside the 66 dp safe zone | Layers 108×108 dp; logo ≥ 48 dp and ≤ 66 dp | fixed (logo was 39 dp) |
Components24 components, every state
States are shown with .is-pressed, .is-disabled, .is-focus and .is-error. In the app they come from :active, :disabled, :focus-visible and aria-invalid. All numbers are real USDA values. Every state keeps its visuals inside its parent, and text is never clipped.
Buttons
.btn--primary · --secondary · --ghost · --destructive · .icon-btn- One primary button per screen. It does the main job, like "Add to Lunch".
- Label = verb + object. Add the kcal total when it helps the user decide.
- Heights: 52 (L) and 44 (M; 48 on Android). Icon buttons are 44×44 (48×48 on Android) around a 24 glyph.
- Don't use destructive styling for "over goal". It's only for deleting data, and it always opens a confirm sheet that lists what will be deleted and offers 30 days to undo.
Text field
.field · .field__control · .field__suffix · .field__help- Always a visible label. The placeholder is only an example.
- Units live in the suffix (g, ml, kcal) and are set in Azeret Mono.
- Errors say how to fix it, not just that something is wrong.
- The border is an inset ring, so focus and error never change the size.
Search bar
.search- Recent foods show first, before any typing (research insight 4).
- Barcode lives inside the bar, so there's one place to find food.
- No results → offer help: check spelling, add a custom food, or scan.
No results. Did you mean Greek yogurt?
Chips
.chip · .is-selected · .chip--fresh · .chip--locked- For filters and quick choices, such as recipe filters and "Is it olive oil or teriyaki?".
- Selected = apricot fill + persimmon ring + check icon, so it's not colour alone.
- Visual height is 36; the hit region is extended to 44 (48 on Android).
- Allergy filters are locked: a lock icon and the word "Allergy", never colour alone. They can only be changed in Profile, so a tap can't show unsafe recipes.
Segmented control
.segmented · .segmented__opt- Switches the unit for a portion: grams, millilitres or portions.
- Disable options that don't apply (for example ml for rice) rather than hiding them.
- Focus = 2 px surface gap + 2 px rye ring outside the segment. It fits exactly into the 4 px track padding and the 8 px gap between segments.
Weight stepper
.stepper · .stepper__btn · .stepper__value- Tap ± for 10 g steps; tap the value to type an exact weight.
- The kcal next to it updates live. This is how users fix photo estimates.
- The border is an inset ring, so each ± button is a full 44×44 hit region.
Product list item
.list · .product · .product--detected · .is-check · .product__thumb--icon- One canonical entry per food. The source (USDA) is named on the detail screen, not in the list.
- kcal and P · F · C are per 100 g, rounded to whole grams in lists. Decimals appear on the detail screen.
- Identical geometry in every state: rows are inset 4 px in the list, have a 16 px radius, and draw outlines as a 2 px inset ring. Dividers hide next to a highlighted row.
- Search rows (
.product--chips): the product photo (the same.product__thumbas the Meals rows:size-thumb,radius-sm, cover;alt=""beside the name) with the name to its right, top-aligned, wrapping, never clipped. Under the photo, from the card-inset line, the P / F / C chips (.product__macros),space-2below the photo. kcal per 100 g and the Add button (44, "Add <food>", focus ring with a gap) sit on the right edge, centred on the whole row. - Settings rows (
.product--nav, Profile): an icon tile, the name and its value, and a plain chevron button (44, named "Edit allergies"). Its glyph sits on the inset line, like every plain icon button at a row end. - Meal rows: a logged meal shows its dish photo. An empty meal shows a passive meal-type icon in the same 48 slot (not a button: no hit area, no states) and exactly one Add button on the right, so rows never shift.
- Detected item (photo result): the photo marker number, the name, a confidence label, a weight stepper and kcal. Check portion tints the row so the user confirms the weight. A thumbnail is a real photo (
<img class="product__thumb">) or, when there's none, an icon tile.






Recipe card
.recipe-card · .recipe-card--compact · .recipe-card__fit- Leads with "fits your …" when the recipe fits the kcal left (research insight 3).
- kcal per portion is the biggest number; macros sit in the meta row.
- No fixed height, no clipping. Long titles wrap and the card grows, including at 200% text size.
- Macros are chips, the same macro-tile component as the detail screen: letter + value in the macro colours, no dot separators. Gap letter → value =
--space-inline-icon(4, the clock → time gap); gap between chips =--space-chip-gap(8). - "View recipe" is a real button (secondary, 12.8:1, 44 tall) named "View recipe: <dish>"; Enter and Space open the dish.
- Compact card for ranked lists: an image column
--size-recipe-thumb(80) wide that runs from the top to the bottom padding of the card and grows with the text (gridalign-items: stretch, object-fit cover, its own radius on all four corners; the card never clips) and one left-aligned text column (min-width: 0): title → time → reason → P / F / C → "Fits: 532 of 879 kcal" → kcal. Every row is--space-2(8) apart; padding =--space-card-inset, image → text =--space-3. An optional "Best fit" badge sits above the title. The fit line is text, not colour alone. - Clickable image (
.recipe-card__img--link): on the full card the image is a pointer shortcut to the recipe (pointer cursor, pressed overlay). It isaria-hiddenand not focusable, so "View recipe" stays the single keyboard and screen-reader path.
Pan-seared salmon with white rice, steamed broccoli and a light soy-ginger glaze
Roasted cauliflower, radish and broccoli tray with lemon-tahini dressing

Shrimp & broccoli stir-fry with rice
Nutrition summary
.nutri · .nutri__ring · .macros · .macro--p/f/c- Leads with kcal left, not kcal eaten. It answers "can I have dinner?".
- Macros are always ordered P → F → C, each with a letter, a value, a bar and the goal.
- Over goal: the ring and number turn Turmeric, and the copy stays neutral.
- Never red, never "warning", never an exclamation mark for going over.
Baked cod, potatoes & broccoli · 1 portion
.macro-tile--chip in .macro-tiles--inlineNutrition facts table
.facts- Two columns: per 100 g and per portion, so users can compare foods and see their meal.
- Energy row first, between heavy rules; sub-rows ("of which…") are indented.
- The source is always shown under the title.
Salmon, cooked
| Nutrient | Per 100 g | Per 140 g |
|---|---|---|
| Energy | 206 kcal | 288 kcal |
| Protein | 22.1 g | 30.9 g |
| Fat | 12.4 g | 17.4 g |
| of which saturated | 2.5 g | 3.5 g |
| Carbohydrates | 0 g | 0 g |
| of which sugars | 0 g | 0 g |
| Fibre | 0 g | 0 g |
| Salt | 0.15 g | 0.21 g |
Top app bar
.app-bar · .app-bar--large · .is-scrolled- Sits below the 47 pt status bar. 56 pt on iOS, 64 dp on Android (Material 3).
- Icons are 24 inside 44 pt (48 dp) buttons.
- Titles wrap instead of truncating. When content scrolls under the bar, it switches to the surface colour with a hairline.
Tab bar
.tab-bar · .tab · .tab--scan- 5 items: Today · Diary · Scan · Recipes · Profile. Scan is a persimmon pill inside the bar.
- Every indicator stays inside the bar: tabs are 44 pt tall with padding above and below, the pressed pill is inset 4 px, and the focus ring is inset.
- Active = persimmon, bold label and filled icon, not colour alone. The 34 pt home-indicator area sits below.
Bottom sheet
.scrim · .sheet · .macro-tiles- For quick edits without leaving the screen, such as a portion or a meal choice.
- Always has a handle and a close button. As a modal it traps focus, closes with Esc and returns focus to the control that opened it.
- Macro tiles: three equal grid columns, the same padding, shared baselines and tabular numbers, all inside the sheet margins.
Opens a modal <dialog>: focus moves in, Tab stays inside, Esc or ✕ closes it and focus returns to this button.
Toast
.toast · --success · --warning · --error- Confirms an action and offers Undo. It stays until the user dismisses it or does something else: no timer, and a ✕ to close.
- One line, plus an optional action. Shown above the tab bar.
- The red error toast is only for real failures, such as no connection.
Empty state
.empty · .empty--error- Say what's missing and give the next step: one primary action and one alternative.
- Friendly, never blaming. Photo failures always offer manual search.
Nothing for dinner yet
Snap your plate when you eat. You have 879 kcal left today.
We couldn't spot any food
Try again with the whole plate in frame and good light, or search for it.
Badges and confidence
.badge · .confidence · .marker- Confidence = icon + word, on every detected food in a photo.
- "Not sure" rows get a honey marker and a tint, and always offer alternatives.
- Macro badges repeat the letter (P / F / C), so they're readable without colour.
Inline banner
.banner · .banner--offline- Explains a situation on the screen itself: offline, or why some results are hidden. Icon + text, an optional action.
- Never auto-hides. It goes away when the situation changes (WCAG 2.2.3 / 2.2.4).
- Don't use it for confirmations. Those are toasts with Undo.
Skeleton
.skeleton · --title · --short · --thumb · --block- Shows the shape of what's loading, so the layout doesn't jump. Cached data shows at once instead when there is any.
- Screen readers hear "Loading…": the container has
aria-busy="true"and a visually hidden label. - Reduced motion: the shimmer stops and the blocks stay static.
Camera viewfinder
.viewfinder · .viewfinder__plate · .viewfinder__tip · .viewfinder__hint · .shutter · .icon-btn--inverse- A plate guide, three tips and a live hint ("Plate found. Hold still"), matching the capture guidance in the brand brief.
- Text never sits on the photo itself: tips are on solid dark pills and the hint is on a light pill, so contrast doesn't depend on the picture.
- The shutter is 72, the main action. Library and Search are always next to it, so the camera is never the only way in.
- Focus rings turn light on the dark camera surface.
- Whole plate in frame
- Good light
- From above, about 45°
Screen layout
.screen · .status-bar · .screen__body · .screen__foot · .screen__toast · .section-head · .card · .row-between · .photo- The 390 × 844 frame for every screen in
03-screens/: status bar (47), app bar, a scrolling body with 20 side margins, then a sticky footer or the tab bar (49 + 34). - The footer holds the one primary action, so it's always reachable and never covered by the keyboard or content.
- Photo markers are positioned with data values (
style="--x: 42%; --y: 55%"), with a light ring so they read on any photo. - The toast takes its own row above the tab bar (12 padding), so it never covers content, the primary button or a focused control (WCAG 2.4.11).
12344 foods found
Check them, then addPhoto estimates can be off by 10–20%. Not medical advice.
Inverse status bar
Used over the camera. The default status bar is dark text on Oat milk.
Date button
.date-btn- Calendar glyph + day + kcal left, under the Today title. The glyph starts on the screen-margin line, like every heading.
- Icon and text are centred on one line; 44 tall with no side padding, so nothing shifts off the grid. Opens a date picker.
Title row and name editing
.title-row · .name-edit · .field__top · .field__count- Edit sits next to the name; its glyph ends on the right margin line, the hit area stays 44.
- Inline edit: a labelled field ("Dish name"), a live character count, Save and Cancel. The
h1stays for screen readers while editing. - Errors say how to fix it: "Enter a name, for example …" and "Use 60 characters or fewer. It's 73 now." Long names wrap, never clip.
Baked cod, potatoes & broccoli
Up to 60 characters. Shown in Recipes and your diary.
Enter a name, for example “Baked cod with broccoli”.
Up to 60 characters. Shown in Recipes and your diary.
Ingredient rows
.ingredients · .ingredient · .ingredient--edit · .ingredient__fields · .is-error- Looks like the Nutrition Facts card: the list sits inside a
.factscard (title, source line, an uppercase header row on the heavy rule, hairline row rules, 14 px names, mono values right-aligned). The facts table and.ingredients--factsshare the same CSS rules. - View is compact: the name on the left, the amount + unit right-aligned on one edge, two lines per ingredient: name | amount, then the P / F / C macro chips ("P 34 g", the same
.macro-tile--chipcomponent as everywhere) | "158 kcal". Amount and kcal end on one right edge (tabular mono, the facts value style); names and chips start on one x; 8 between the lines (--space-2). Rows are 8 apart vertically; text starts on the card-inset line. - Servings (
.ingredients__servings): a label + the weight stepper (06, all its states) scales the ingredient amounts, row kcal and chips (1–8 portions; "−" disabled at 1, "+" at 8; typed values snap into range; the change is announced viarole="status"). The dish summary and "Log 1 portion" stay per portion. In edit mode the row hides and amounts are edited per portion. - Edit: every value is a labelled field (Ingredient with suggestions, Amount, Unit g / ml / portion) plus a Delete button (44, inset focus ring). Totals, the calorie ring and macro bars recalculate as you type, announced via
role="status". - Delete moves focus to the next row and shows an Undo toast (no timer). Leaving with changes asks "Discard changes?" with "Keep editing" first.
Ingredients
- Cod fillet, baked150 gProtein34 gFat1 gCarbs0 g158 kcal
- Potatoes, new, boiled in their skin and crushed with extra-virgin olive oil and rosemary200 gProtein4 gFat0 gCarbs40 g174 kcal
- Olive oil10 mlProtein0 gFat9 gCarbs0 g80 kcal
158 kcal · 150.0 g
80 kcal · 9.1 g (ml uses the food's density)
Pick a food from the list, for example “Broccoli, boiled”.
Recipe steps (Method)
.recipe-steps · .recipe-step · .recipe-step--edit · .recipe-step__actions · .is-error- An ordered list (
ol/li) after Ingredients. Each step: a number disc on the card-inset line and the text in one column (16 / 1.5, ≤ 80 characters, never justified). Long steps wrap. - Edit: a labelled field per step ("Step 2"), Move up / Move down / Delete buttons (44, named "Move step 2 up"). No dragging needed (WCAG 2.5.7); focus stays on the moved step. The first "up" and last "down" are disabled, not hidden.
- Empty steps block Save with a hint. Deleting shows an Undo toast. A recipe without steps shows an empty state with "Add steps".
Step 1: Heat the oven to 200 °C (180 °C fan). Boil the potatoes in their skins for 15–20 minutes, until a knife slides in easily.
Step 2: Bake the cod for 12–15 minutes, until it flakes easily, or 63 °C in the thickest part.
One action per step. Include times and temperatures.
Write what to do in this step, or delete it.
No steps yet
Add how to cook this dish, one action per step, so you can make it again.
Step indicator
.progress · .progress__text · .progress__bar · .progress__seg · .is-done · .is-current- Onboarding progress on screens 02–06. The text "Step 2 of 5" is always shown; the bar only repeats it (
aria-hidden), so progress is never dots or colour alone. - Segments: upcoming (track) · done and current (accent).
--space-1tall,--space-1apart, full width of the content. - Not interactive: no pressed, focus or disabled state and no tab stop; going back is the app bar's Back button. No error state: progress can't be wrong.
Step 1 of 5
Step 3 of 5
Step 5 of 5
Option card
.option-group · .option-card · .option-card--check · .option-card__mark · .is-error- A native radio or checkbox inside a
label: the whole card is the hit area (≥ 44 tall), arrows move between radios, Space toggles checkboxes. Grouped in afieldsetwith a visiblelegend. - Title + description (body strong + callout, wraps, never clipped). The mark on the right is a ring (radio) or a rounded square (checkbox); when selected it fills and shows a check, so selection is never colour alone.
- States: default · pressed · selected · focus (ring with a gap) · disabled · error (group). The error names the fix under the group.
Week strip
.week · .week__day · .week__ring · .week__note · .is-over · .is-empty- Seven day buttons (Mon–Sun) in a group: the weekday, the date inside a mini calorie ring (
--size-day-ring, 32) and a short note. One day is selected (aria-pressed="true"); today hasaria-current="date"and the note "Today". - Every day button is ≥ 44 × 44. 7 × 44 = 308 pt doesn't fit the 280 pt content of a 320 pt screen, so there the strip uses up to 16 pt of each margin (14 at 320); targets never shrink. If even that doesn't fit, the days wrap.
- Never colour alone: a day over the goal fills its ring in Turmeric and shows "+45" (its name says "45 over"). An empty day has a dashed track; days before sign-up and future days are disabled.
- States: default · pressed · selected · focus · today · over · empty · disabled. No error state: days come from the phone; offline uses the inline banner (17).
ChecksAccessibility
tools/contrast.py.npm run check.