Files
HealthLog/docs/ui-guidelines.md
T
Marc-André Bombeck 8d2ba7cd4b docs: list every integration and channel, and correct two stale rules
- README: the integrations line now names Strava, Garmin and the Health
  Connect import, the reminder channels include email and the webhook,
  and the last two feature labels say what they cover.
- UI guidelines: the password form was said to carry a hidden username
  field it does not have; the line now states when one helps.
- CONTRIBUTING: strings go into every locale file, not only English and
  German.
2026-11-12 15:17:32 +01:00

374 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HealthLog UI Guidelines
> Interaction patterns for the HealthLog web app. Card anatomy, spacing,
> text colour, colour tokens, typography and the choice of primitive are
> owned by [`contributing/ui-standards.md`](./contributing/ui-standards.md);
> this page covers what those rules leave open. Where the two disagree, the
> standards win. If a pattern below doesn't fit your case, propose an
> addition in the same PR.
---
## 1. Foundations
Theme, spacing, typography, radius and colour tokens are defined in the
standards (§1 to §5 there). Two things to add:
- Dark is the default theme, and the light theme remaps the same semantic
tokens. Check every component in both before shipping it.
- Elevation stays light: `shadow-xs` or `shadow-sm` in chrome, never the
heavy `shadow-lg` and up.
---
## 2. Components
This section is **prescriptive, not exhaustive**. Each entry names the
primitive for a case and the case where another one wins.
### 2.1 Buttons
Every button comes from `@/components/ui/button`. Pick by purpose:
| Purpose | Variant | Size |
| ----------------------------------------------------- | ------------- | ----------------- |
| Primary action on a page or dialog | `default` | `default` |
| Cancel, dismiss, secondary action next to primary | `outline` | `default` |
| Tertiary in-row action (edit, copy, snooze) | `ghost` | `sm` |
| Destructive (delete, revoke, disconnect) | `destructive` | `default` or `sm` |
| Icon-only action (toolbar, chart toggles, table rows) | `ghost` | `icon-sm` |
| Inline link to external help docs | `link` | `sm` |
| Toolbar / table row action when space is tight | `outline` | `xs` |
Where buttons sit in a card (one action row, last in the card, primary on
the right) is standards §12. Two additions:
- A Test button (Telegram test, AI ping and the like) sits in the same row
as the Save or Apply control it belongs to.
- An irreversible action (delete account, revoke an API token, wipe data)
asks in its own confirm dialog, with a type-to-confirm field where the loss
is large.
**Disabled state.** A disabled button always comes with helper text that
says why. A tooltip is not enough: it never opens on a phone.
### 2.2 Inputs and forms
Forms keep a controlled draft in component state and validate it with the
Zod schemas from `src/lib/validations/`; the server's schema is the authority.
Every input has a visible `<Label>`, plus `aria-describedby` for helper text.
- **Text / number / email**: `<Input>`. Always set `autoComplete`. See §4.4.
- **Multi-line**: `<Textarea>`.
- **Single choice, up to four options**: `Segmented` (standards §6) or a
radio group. A select for a tiny enum costs extra taps.
- **Single choice, more options**: `NativeSelect` or `Select`; a searchable
popover once the list is long enough to need type-ahead.
- **Multi choice**: a `<Checkbox>` group.
- **Date and time**: `DateField`, `TimeField`, `DateTimeField` (standards §6).
- **Numeric range**: `SliderField` for a bounded scale with anchors (a 0 to
10 self-rating). Thresholds and counts take a number input: a slider gives
up precision there.
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
patterns are legal (see §2.2.1); pick one per card, never mix.
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
measurements to export) and for action parameters that are not
persisted at all (export options).
#### 2.2.1 Settings persistence patterns
Every control under `/settings` follows exactly one of two save
patterns. The pattern is a property of the **card**, not of the
individual control:
1. **Immediate persist**: a standalone boolean that is the card's only
mutable state. The `<Switch>` fires the mutation in
`onCheckedChange`, disables itself while pending, updates
optimistically with rollback, and toasts on success or error. No Save
button anywhere on the card. Examples: hide-Coach, ntfy enable,
cycle tracking, research mode, mood reminder, Coach nudges.
2. **Draft + Save**: the card edits a multi-field payload (or a list
the server PUTs whole). All controls, `<Switch>` rows included,
change a local draft only; a visible Save button sends the draft
in one request and the card shows its dirty state. Examples:
Coach preferences, Telegram (enable + bot token), provider fallback
chain, dashboard layout, threshold editor.
A `<Switch>` inside a draft+Save card is legal **only** when the card
shows the Save button; a card with both an auto-persisting switch and
a Save-gated field is a bug. Every setting presented under `/settings`
persists server-side. `localStorage` is reserved for device-local UI
state that is not a setting (sidebar collapse, last-update-check
timestamp, tour progress): label such keys with a comment at the
declaration site and never render them with Save/Saved affordances.
**Validation.** Inline messages below the field. The field gets
`aria-invalid="true"` and a destructive border. A failed save shows inline
`role="alert"` text in the form. Never show a toast for a field-level error.
### 2.3 Dialog vs sheet
Pick by intent, not by hand-feel. Standards §6 maps the primitives.
| Use | Primitive | Why |
| ----------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------- |
| Confirm a destructive or irreversible action | `AlertDialog` | Blocks an accidental dismiss; the user has to read it. |
| Inspect or edit one item, on any surface a phone reaches | `ResponsiveSheet` | A bottom sheet on a phone, a centred dialog on wider ones. |
| Sub-action from the bottom edge on a phone (filter, picker) | `SheetContent side="bottom"` or `ResponsiveSheet` | Grip, swipe to close, thumb reach. |
| Admin or desktop-only surface | `Dialog` | No phone layout to serve. |
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
Never nest dialogs. If a sheet needs a confirmation, chain an
`AlertDialog` on top of it.
### 2.4 Cards and lists
- **Card**: anatomy, headers and density are standards §1.
- **Tile**: a _small_ card showing a single metric on the dashboard.
Tiles carry the current value, its unit, an optional sparkline, and one
tap target.
- **List row**: icon, primary text and meta on the left, trailing controls
on the right. Never put a primary action in the trailing overflow menu,
only secondary or destructive ones.
- **Empty state**: every list and tile that can be empty has one. See §4.2.
### 2.5 Tables
`<Table>` for tabular data only: the measurement list, the audit log, admin
views. Default to cards and list rows for everything else; tables collapse
poorly on a phone. When a table must exist on a phone, switch to a vertical
list below `sm`.
### 2.6 Filter bars over data lists
Every list surface with filters (measurements, mood, admin lists) uses the
shared `<FilterBar>` rail from `src/components/ui/filter-bar.tsx`, never a
per-page row of large labelled selects and date inputs.
- **Grammar**: `[icon] [date range] [type] [source] [extras…] [reset] … [count]`.
The canonical dimension order is date range, type, source;
surface-specific dimensions follow.
- **Pill = trigger + chip**: an inactive pill shows only the dimension
name; an active one shows `Name: value`, takes the primary accent and
grows a remove button. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (below `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge bleed); the result count wraps to its own line.
- The component is presentation only: filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
### 2.7 Card headers
Standards §1 (header patterns and the icon colour rule) and §13 (Settings
and Admin card rhythm). The old `pl-7` body gutter in Settings is gone: the
body starts at the card edge.
### 2.8 Status indicators
One taxonomy across the app, on the semantic status tokens (standards §3):
| State | Token | Examples |
| --------- | -------------------- | ---------------------------------------------- |
| Good | `--success` | Connected, in target, healthy |
| Info | `--info` | Up to date, neutral signal |
| Caution | `--warning` | Mildly out of range, expiring token, slow sync |
| Alert | `--destructive` | Out of range, sync failed, critical |
| Pending | `--muted-foreground` | Loading, indeterminate |
| Highlight | `--primary` | New or featured |
Show a coloured dot **plus** a label. Never rely on colour alone.
---
## 3. Layouts
### 3.1 Page shell
`AuthShell` owns the page frame, the scroll container and its bottom
clearance (standards §2 and §9). Pages never re-implement it.
### 3.2 Settings: `/settings/[section]`
Every section is a route under `/settings/[section]` with its component
under `src/components/settings/`. The registry in
`src/components/settings/settings-shell.tsx` is authoritative for which
sections exist and how the navigation groups them. An address that moved
keeps working through a permanent redirect in `next.config.ts`.
### 3.3 Admin: `/admin/[section]`
Admin uses the Settings anatomy verbatim (standards §13). Status badges use
the §2.8 taxonomy.
---
## 4. Patterns
### 4.1 Loading states
- **Page-level**: a `<Skeleton>` matching the final layout, mounted inside
the same shell the loaded page renders into. Never a full-screen spinner.
- **Card-level**: a loading state paints the card and its header
(standards §13).
- **Inline**: a button in flight shows a spinner and a label change
("Saving…", "Testing…"). Disable the other form controls while pending.
- **Long-running**: background work (export, backup) reports through a
toast or a status line instead of blocking the UI.
Skeletons respect `prefers-reduced-motion: reduce`.
### 4.2 Empty states
The shared `<EmptyState>`:
```tsx
<EmptyState
icon={<Plus className="size-6" />}
title={t("empty.measurements.title")}
description={t("empty.measurements.description")}
action={<Button>{t("empty.measurements.add")}</Button>}
/>
```
`variant="plain" size="compact"` is the in-card and in-field form, and
`ChartEmptyState` covers a chart body (standards §6). Every list, chart and
dashboard tile that can be empty has an explicit, translated empty state. A
failed read never falls through to "no data yet".
### 4.3 Error states
- **Form errors**: inline below the offending field, plus inline
`role="alert"` text for a failed save.
- **Failed reads**: `QueryErrorCard`, `QueryErrorRow` or `ChartErrorState`
by surface, with the query's `error` (standards §6).
- **Page errors**: Next.js `error.tsx` boundaries with a recovery action
("Retry", "Go home"). The error goes to GlitchTip through the existing
client.
### 4.4 Forms and password managers
- Measurement, medication and mood fields set `autoComplete="off"` on every
input that browsers keep guessing wrong.
- API-token and AI-provider key inputs use `autoComplete="new-password"`
so the browser does not fill in the account password.
- A password form with no visible username field can carry a hidden
`<input type="text" name="username" autoComplete="username" readOnly tabIndex={-1}>`
so a password manager files the new password under the right account.
The password-change form in Settings has none.
### 4.5 Test buttons (every integration)
Each configurable integration ships a Test button next to its Save
control.
- **Trigger**: a one-off call to the integration with a small,
recognisable payload ("HealthLog test ping").
- **State**: the button shows a spinner with a translated "Testing…"
label. The result lands inline directly below: the relevant detail on
success (last sync, message id, response status), or the error, sanitised
so it never names the upstream URL or the key.
### 4.6 Mutations
Mutations go through TanStack Query, never a raw fetch in a component. A
mutation invalidates every query it changes and toasts on success and on
error (translated). An optimistic cache update always carries a rollback.
### 4.7 Inline help
A small `?` `InfoPopover` next to the label is the default for a
non-trivial field (e.g. "Why does this default to ESH 2023?"). It opens on a
tap or a click and from the keyboard, so it works on a phone, where a hover
tooltip never opens. Its text is translated.
For a long explanation, link out to `https://docs.healthlog.dev/...`.
Never embed multi-paragraph help inline.
---
## 5. Accessibility
We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Colour contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. `@axe-core/playwright` checks it in CI.
- Touch targets ≥ 44×44 CSS px on the bottom navigation and primary action
buttons (WCAG 2.5.5). Two idioms implement the floor, and they are
deliberate:
- **Form controls** (`Input`, `NativeSelect`, `SelectTrigger` default
size): `h-11 sm:h-10` is built into the primitives: 44 px on a phone,
40 px on desktop. Never add `min-h-11` on top; it forces 44 px on
desktop and breaks the row rhythm.
- **Buttons on dense touch surfaces** (medication detail, dashboard
quick actions): `min-h-11 sm:min-h-9`, a 44 px floor on a phone and the
compact `sm` tier on desktop. Plain `min-h-11` without the `sm:` reset
is legacy spelling; don't add new ones, and match a button to its
direct neighbours when editing one.
- Focus is always visible: the primitives' `focus-visible:ring` stays on
every customised component.
- Every interactive element has an accessible name. Icon-only buttons get
an `aria-label`.
- `prefers-reduced-motion: reduce` disables animations beyond a 200 ms
fade.
- Charts carry a text summary (`aria-describedby` on a `sr-only`
paragraph) and expose their data through the visible legend or a table
view.
CI fails on any `serious` or `critical` axe-core violation.
---
## 6. Sliders
A slider gives up precision, so it is the control only where the value is
a bounded scale or the sweep is the point: a self-rating with anchors
(`SliderField`, standards §6), scrubbing through history, a chart zoom
range. Thresholds, counts and times take a number, select or time field.
---
## 7. Internationalization
All user-facing strings come from `messages/en.json` (the default) plus the
`de`, `es`, `fr`, `it`, `pl`, `ko`, `nl`, `pt-BR` and `ru` bundles beside
it. Guard tests (`i18n-call-site-coverage`, `i18n-locale-integrity`) enforce
that every `t()` key resolves in English and exists, with the same
placeholders, in all ten locales. Use `useTranslations()` on the client and
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
server. Numbers, dates and units always go through `useFormatters()`; never
hand-roll `Intl.NumberFormat(...)` with a fixed locale.
Server-rendered content (PDFs, notifications, server-issued category
labels) uses the locale of the user it is for, not the locale of the
request that triggered the job.
---
## 8. Motion
Default transitions: `transition-all duration-150 ease-out`. Heavier
animations (dialog enter and exit) use the primitives' defaults. Reduced
motion always wins.
Loading skeletons pulse via Tailwind `animate-pulse`. Don't add custom
keyframes without a `prefers-reduced-motion` fallback.
---
## 9. Component checklist (for new features)
Before opening a PR for a new component or page, walk through the PR
checklist in the standards (§7) and:
- [ ] Buttons follow §2.1.
- [ ] Inputs follow §2.2; correct `autoComplete`.
- [ ] Dialog or sheet per §2.3.
- [ ] Loading, empty and error states from §4.1 to §4.3.
- [ ] All strings via `t()`; numbers via the formatters.
- [ ] Tested at phone width (390 px) and on desktop.
- [ ] Light and dark theme checked.
- [ ] Keyboard: Tab order, Enter to submit, Esc to dismiss.
- [ ] axe-core clean (no serious or critical findings).
- [ ] A screenshot test added if the UI is non-trivial.