emealia docs · internal 2026-08-06

Design system

The revised corporate identity, the band system, and the copy standards. Tokens here mirror the shipped app exactly — emealia-app/frontend/src/design/tokens.ts is the single source of truth, a build lint fails on any hard-coded colour in a component, and a drift guard fails on any mismatch between the token module and the generated CSS.

Palette revised 2026 to "Deep Oceanic Slate" — this document set was corrected to match on 2026-08-06

Palette change — what moved

The original forest-green ground (#1A201C / #232B26) was replaced with a cool blue-grey ground (#11161B / #1C242C). The peach accent #F0956A is retained and is now the anchor the whole palette is built around. The reason is functional, not aesthetic: the darker, cooler ground raises band-fill contrast, which matters because the band fill is the primary information carrier on the main view. emealia-mgmt/3-design.md still documents the old palette and needs updating.

Design non-negotiables

#Rule
1No red in nutrition feedback — ever. Warm amber is the maximum urgency signal.
2No numbers on the main daily view. Bands and qualitative labels only. Numbers one tap away.
3No state may look like a failure. Technical errors may be transparent; nutrition states may not.
4Dark mode only. No light mode planned. A light token set exists behind the same semantic variables so components consume meaning rather than dark assumptions — it is an architectural hedge, not a shipped theme.
5No loading spinners on the main view. Optimistic UI.
6Logging is single-tap accessible from the main view. Never buried, never covering content.

Colour

Ground and surfaces

--bg
#11161B
--surface
#1C242C
--surface-2
#26313B
--border
#33414D

Text

--text
#EBF1F6
--text-muted
#93A6BB
--band-marker
#607790

--text-muted is a lighter mix of the band marker toward the text colour, chosen to hold AA contrast on the dark ground. --band-marker draws the target-zone boundary on a band — a mid blue-grey that reads against both the dark track and the bright category fills, painted above the fill so the target is never occluded.

Accent and brand

--accent
#F0956A
--brand-meal
#F3B89E
--on-fill
#11161B

Semantic

--success
#8FD3B0
--info
#93C3E6
--caution
#DDB85C
The one absolute rule

There is no red token in the palette. --caution is the maximum urgency signal in nutrition feedback, and even that is used sparingly. This is not a style preference — it is regression-locked by story 2.4, whose test suite sweeps the whole band-state space and asserts a category colour is used in every state, never a semantic one.

Nutrient category colours

A band's fill identifies which nutrient it is. It never changes with status. This is the mechanism that makes "no red" structural rather than merely careful — a band cannot signal failure because its colour carries no status information at all.

Energy
#F0956A
Protein
#93C3E6
Carbs
#DDB85C
Fat
#C4A882
Fibre
#8FD3B0
Vitamins
#C49FC8
Minerals
#B9C77E
Other
#C4A882

The band system

The band is emealia's core UI primitive — it replaces a calorie number with a qualitative sense of the day. Colour and label only, no numbers by default.

Anatomy

Day-level labels

LevelENDETone
very_lightVery lightSehr leichtNeutral
lightLightLeichtNeutral-positive
balancedBalancedAusgewogenPositive — the target zone
heartyHeartyHerzhaftNeutral — a filling day
richRichReichhaltigWarm, never shame

An unlogged day returns none, never "very light" — absence is not a verdict.

Band states (per nutrient, on Today)

Each nutrient line carries a text label from a closed set of five calm backend states: empty · getting_started · room_to_round_off · on_track · treat_today. The backend maps percentages of the target range to a state and sends the opaque key — never a translation key, never a percentage. The frontend owns the copy.

Why this indirection matters

It is the same pattern as the tip library: the mechanism knows the rule shape and never what a key means. Meaning is decoupled from mechanism, so the whole state space can be swept in tests — all 2⁴ geometries classify to a calm state, the classifier never escalates, above_zone is the ceiling no matter how far over, and an EN + DE copy deny-list proves no state reads as a failure. 23 backend and 23 frontend assertions.

Bands describe a day; numbers describe a meal

This is the sharpest rule in the design system and it was a real decision, not an inherited convention. No day-budget or contribution band ever appears on a meal screen — that framing reads as "calories to spend down", which is the food-as-currency mechanic the product exists to reject. Meal screens show the meal's own honest number ranges (~700–860 kcal, muted, marked "estimate"). The daily view stays bands-only.

The contribution panel obeys the same rule from the other direction: it shows shares as bar widths, never as percentage text, so expanding a band on Today does not put a number on the main view.

Personalisation

Today starts with five default bands. Any of the 32 tracked nutrients, vitamins or minerals can be starred onto Today from All-details, up to a cap of 10. Starred values pin first within their section and re-order live. Everything else stays one tap away in All-details.

Typography

UseFamilyWeightSizeTracking
Page title / h1Hanken Grotesk70032–40px−0.03em
Section heading / h2Hanken Grotesk70012–13px+0.06em, all caps
Card titleHanken Grotesk70016–20px−0.02em
BodyHanken Grotesk40014–15pxnormal
Muted / labelHanken Grotesk400–50012–13pxnormal
Band labelHanken Grotesk60014pxnormal
emealia's own voiceNewsreader italic400 italic17–20pxnormal
Mono / codeSF Mono, Menlo40012–13pxnormal

Newsreader italic is reserved for emealia speaking as itself — the welcome line, the assumptions ("I estimated assuming…"), the tagline. It is never used for interface chrome. Fonts are self-hosted as woff2; the Google Fonts CDN is a development convenience only and must not ship to production for GDPR reasons.

Logo and wordmark

emealia

Component patterns

ComponentPattern
Cardbg --surface · border --border · radius 14–16px · padding 18–24px
Nested surfacebg --surface-2 (nested cards, inputs)
Primary buttonbg --accent · text --on-fill · weight 600 · radius 12–14px
Secondary buttonborder --border · text --text · transparent bg
Inputbg --surface-2 · border --border · focus border --accent · radius 10px
Tag / pillbg --surface-2 · border --border · radius 999px · text --text-muted
Success state--success as text or border accent — never as a fill
Caution state--caution — maximum urgency, never red
Navigational linkText plus a trailing arrow, everywhere and identically: "All details →", "Choose from your favorites →", "all assumptions ▾"
Disabled buttonA defined visual state, used when re-estimate is spent (1× cap)
Tap target≥44px, always

Motion

ElementDurationEasing
Card hover / focus150–180msease-out
Band fill on load~380–400msspring — cubic-bezier(.22, 1, .36, 1)
Modal / sheet enter240mscubic-bezier(.4, 0, .2, 1)
Processing state (meal log)~1s loopgentle pulse in --success
Tip appear300ms fadeease-in

All animation respects prefers-reduced-motion: reduce.

Voice and microcopy

ContextENDE
Balanced dayLooks like a balanced day so far.Sieht bisher nach einem ausgewogenen Tag aus.
Low proteinA little more protein could make tomorrow feel steadier.Etwas mehr Protein könnte den morgigen Tag stabiler machen.
Rich dayLooks like a hearty day. Tomorrow can simply be lighter.Sieht nach einem herzhaften Tag aus. Morgen darf einfach leichter sein.
Medical contextThis is not medical advice; use it as a tracking note for your next appointment.Das ist keine medizinische Beratung; nutze es als Notiz für deinen nächsten Termin.
Trial endedYour trial has ended. Your history stays available.Dein Testzeitraum ist abgelaufen. Dein Verlauf bleibt verfügbar.
Contribution, missing valuesSome meals have no value for this nutrient yet.Für einige Mahlzeiten gibt es dazu noch keinen Wert.
Contribution, emptyNothing logged for this yet.Dazu ist noch nichts erfasst.

Never: "you failed" · "cheat meal" · "bad food" · "you must" · "doctor-approved" · an exact calorie verdict on Today. The one-tip rule: each day produces at most one forward-looking suggestion. Not a list. Not a score. One nudge, warmly phrased.

DE is adaptation, not translation

Bilingual parity means the same warmth, not the same words. German copy is written to sound native — the register that makes "Morgen darf einfach leichter sein" land is not reachable by translating "Tomorrow can simply be lighter" literally.

Copy standards by surface

Onboarding

InputPattern
GoalsMulti-select chips plus free text — the only required step
SexRadio
HeightCompact input, minimal placeholder copy
BuildRadio chips without the word "build" — light / medium / sturdy. Can be given alongside an exact weight.
Daily movementRadio: little / some / lots
SportRadio: none / occasionally / weekly / almost daily, with a sport-type multi-select beneath
CookingRadio: almost never / getting interested / regularly / love it
Nutrition knowledgeRadio: none / basics / solid / professional

Log a meal and the pre-save preview

Medical / general-wellness disclaimer

Draft — pending legal counsel (H10). A calm reassurance, never a warning label: no red, no alarm, no legalese wall on the happy path.

PlacementCopy
Registration
beside the ToS acceptance
"emealia is a wellness companion, not a medical service. It doesn't give medical advice, diagnoses, or treatment — for anything health-related, your doctor decides."

"emealia ist ein Begleiter für dein Wohlbefinden, kein medizinischer Dienst. Es gibt keine medizinische Beratung, Diagnose oder Behandlung — bei allem Gesundheitlichen entscheidet deine Ärztin oder dein Arzt."
Free-text context field
onboarding and profile, inline
"You can tell emealia anything that helps it understand how you eat — including something your doctor suggested. It keeps it as gentle context and won't act on it as medical advice, and will never override your doctor."

"Du kannst emealia alles erzählen, was hilft zu verstehen, wie du isst — auch etwas, das deine Ärztin oder dein Arzt dir geraten hat. Es behält es als behutsamen Kontext, handelt nicht danach wie eine medizinische Anweisung und stellt sich nie über den Rat deiner Ärztin oder deines Arztes."

Rules: never make a disease-treatment or prevention claim anywhere (this is what keeps emealia outside EU MDR and German HWG); never say "clinically proven", "accurate calories" or "doctor-approved"; a medical note is framed as something to mention at your next appointment, never as an instruction. The long-form disclaimer lives on Settings → Legal and in the ToS. AI tips carry no per-tip banner — safety lives in a versioned medical_safety prompt snippet.

Spacing and radii

TokenValueUse
--sp-xs4pxIcon gap, tight inline
--sp-sm8pxList item gap
--sp-md / --sp-lg12 / 16pxCard padding, section gap
--sp-xl24pxPage horizontal padding
--sp-2xl / --sp-3xl32 / 48pxSection-to-section, hero margin
--r-lg / --r-xl14 / 16pxCards
--r-md / --r-lg12 / 14pxButtons
--r-pill999pxTags, chips, small labels
--maxw-screen640pxApp content width

Token governance

How the system stays honest
  1. src/design/tokens.ts is the only place a raw colour or size value may live.
  2. scripts/generate-tokens.mjs generates src/assets/tokens.css from it.
  3. Components consume CSS custom properties exclusively — semantic names (--surface, --text, --accent), never literals.
  4. scripts/check-tokens.mjs fails the build on any hard-coded colour or size in a component and on any drift between the token module and the generated CSS.

This documentation set's docs.css mirrors those values by hand. When the app palette changes, update it here too.