docs: Design-Vertrag fuer Web, iOS und Android
Unterhalb der Farbebene war nie etwas einmal entschieden worden: Android hat 40 verschiedene Abstandswerte, 9 Schriftgroessen, 9 Radien und vier verschiedene Seitenraender; das Web streut aehnlich. Deshalb springt der Textrand beim Screenwechsel und dieselbe Termin-Pille hat vier Radien. Der Vertrag legt Abstandsleiter, Radien nach Rolle, Schriftskala, Trefferflaechen, Komponenten-Inventar, Zustaende, Termin-Darstellung und den gemeinsamen Widget-Zuschnitt fest -- nach dem Vorbild von SETTINGS_SYNC.md, das denselben Zweck fuer die Einstellungen erfuellt. Bewusst nicht vereinheitlicht: Navigation, Systemschrift, Bedienelemente und Gesten. Gleiche Struktur und gleiche Masse, nicht gleiche Pixel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cd6a4698aa
commit
6b7317b4e0
+233
@@ -0,0 +1,233 @@
|
|||||||
|
# Design contract (Web / iOS / Android)
|
||||||
|
|
||||||
|
> For the user-configurable **colours**, see [../THEME.md](../THEME.md). Colours come from
|
||||||
|
> the server and are already consistent across clients — this document covers everything
|
||||||
|
> below the colour level, which is not.
|
||||||
|
|
||||||
|
The three clients should read as one product. That means the same structure and the same
|
||||||
|
measurements, **not** the same pixels: each app keeps its platform's navigation, system font
|
||||||
|
and controls. A Material button on iOS would feel wrong, and a swipe-back gesture on Android
|
||||||
|
would feel broken.
|
||||||
|
|
||||||
|
This document is the reference the clients are checked against. When a value here and a value
|
||||||
|
in the code disagree, the code is wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Nothing below the colour level was ever decided once. Counted across the clients today:
|
||||||
|
|
||||||
|
| | Web | Android | iOS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Distinct spacing values | 8 | **40** | not audited |
|
||||||
|
| Distinct font sizes | 8 | **9** | system styles |
|
||||||
|
| Distinct corner radii | 8 | **9** | mixed |
|
||||||
|
| Screen gutters | — | **4** (12/16/20/28) | — |
|
||||||
|
|
||||||
|
The result is that the left text edge visibly jumps between screens, the same event pill has
|
||||||
|
four different radii, and nine list rows were each built separately with five different
|
||||||
|
paddings. That is what makes an app look hand-made.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
A four-step ladder. Every margin, padding and gap is one of these — no other value is allowed
|
||||||
|
without a comment saying why.
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `xs` | 4 | Inside a control; gap between an icon and its label |
|
||||||
|
| `sm` | 8 | Between related items in a list; vertical padding of a compact row |
|
||||||
|
| `md` | 12 | Vertical padding of a standard row |
|
||||||
|
| `lg` | **16** | **Screen gutter**, section spacing, dialog padding |
|
||||||
|
| `xl` | 24 | Between major sections |
|
||||||
|
| `xxl` | 32 | Above a primary action, around empty states |
|
||||||
|
|
||||||
|
**One screen gutter: `lg` (16).** Every screen, every sheet, every dialog. This is the single
|
||||||
|
change with the most visible effect — it is what stops the text edge from jumping.
|
||||||
|
|
||||||
|
| Platform | How |
|
||||||
|
|---|---|
|
||||||
|
| Web | `--space-xs … --space-xxl` in `:root`; existing `6px`/`10px` paddings collapse to `xs`/`sm` |
|
||||||
|
| Android | `ui/theme/Spacing.kt`, reached as `MaterialTheme.spacing.lg` |
|
||||||
|
| iOS | `enum Spacing { static let lg: CGFloat = 16 }` in the shared design file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Corner radii
|
||||||
|
|
||||||
|
Defined by role, not by number, so the same object cannot drift apart between views.
|
||||||
|
|
||||||
|
| Token | Value | Applies to |
|
||||||
|
|---|---|---|
|
||||||
|
| `event` | 4 | Event pills in month, week, day, agenda, all-day strip — **all of them** |
|
||||||
|
| `field` | 8 | Text fields, list rows, small cards, popovers |
|
||||||
|
| `sheet` | 20 | Modals, bottom sheets, day preview |
|
||||||
|
| `full` | pill | Buttons, chips, FAB |
|
||||||
|
|
||||||
|
Today the event pill is 3 in month view, 4 in week view, 3 in the all-day strip and 10 in the
|
||||||
|
agenda on Android; the web uses 3. All become `event`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type scale
|
||||||
|
|
||||||
|
Six steps. **Nothing below 11.**
|
||||||
|
|
||||||
|
| Token | Size | Use | Material | iOS |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `caption` | 11 | Timestamps, metadata, hints | `labelSmall` | `.caption2` |
|
||||||
|
| `label` | 12 | Field labels, chips, tab labels | `labelMedium` | `.caption` |
|
||||||
|
| `body` | 13 | Default reading size | `bodySmall` | `.footnote` |
|
||||||
|
| `bodyLarge` | 14 | Primary list text, input text | `bodyMedium` | `.subheadline` |
|
||||||
|
| `title` | 18 | Screen and section titles | `titleMedium` | `.headline` |
|
||||||
|
| `headline` | 22 | Page headline, month name | `headlineSmall` | `.title2` |
|
||||||
|
|
||||||
|
**Android must configure `Typography()`** — today it is the raw Material default
|
||||||
|
(`ui/theme/Theme.kt:68`), which is why every text size in the app is an ad-hoc `fontSize = N.sp`
|
||||||
|
override at the call site.
|
||||||
|
|
||||||
|
**The one documented exception:** the month grid's overflow counter (`+N`) and week-number
|
||||||
|
label may use **10**. They sit in a dense grid where 11 does not fit at the current row height.
|
||||||
|
The 8 and 9 values used today (five places) are removed outright. If the month grid is ever
|
||||||
|
made height-adaptive, this exception goes away too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hit targets
|
||||||
|
|
||||||
|
Minimum **48 × 48** for anything tappable, on every platform. A smaller icon is fine; the
|
||||||
|
touch area around it is not.
|
||||||
|
|
||||||
|
The top app bar is **64** tall with 48 targets. Android currently uses 50 with 40 targets,
|
||||||
|
which is the first thing a user sees and reads as cramped.
|
||||||
|
|
||||||
|
Every actionable icon has an accessibility label. Decorative icons — and only those — get
|
||||||
|
none. Android has 30 `contentDescription = null` today, several of them on buttons
|
||||||
|
(month back/forward, calendar visibility toggle, remove-reminder, password reveal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component inventory
|
||||||
|
|
||||||
|
Every client implements these, named the same way, with the same anatomy. If a screen needs
|
||||||
|
something that is not on this list, it goes on the list first.
|
||||||
|
|
||||||
|
### `ListRow`
|
||||||
|
`[leading icon or colour dot] [title, one line, ellipsised] [optional subtitle] [trailing control]`
|
||||||
|
Vertical padding `md`, horizontal `lg`, full-width tap area, `field` radius when it sits on a
|
||||||
|
surface. Android should build this on `ListItem`; today the app has **zero** `Card` and **zero**
|
||||||
|
`ListItem` — nine separate hand-rolled variants instead.
|
||||||
|
|
||||||
|
### `SettingsRow`
|
||||||
|
A `ListRow` whose trailing control is a switch, a dropdown or a colour swatch. One
|
||||||
|
implementation, not four.
|
||||||
|
|
||||||
|
### `EmptyState`
|
||||||
|
`[title] [one sentence of body] [optional action]`, centred, `xxl` above. Every list has one.
|
||||||
|
Today only the agenda does it properly (`AgendaView.kt:49-62`) — copy that.
|
||||||
|
|
||||||
|
### `LoadingState`
|
||||||
|
One spinner, centred, same size everywhere. Never a different shape per screen. The keys
|
||||||
|
`settings.loading`, `profile.loading` and `accounts.loading` already exist in the translation
|
||||||
|
tables and are never rendered.
|
||||||
|
|
||||||
|
### `Feedback`
|
||||||
|
**Success and failure appear in one place per platform** — a snackbar on Android, a toast on
|
||||||
|
web, a transient banner on iOS. Never as loose text in the page flow, and never as the last
|
||||||
|
item of a scrolling list, where the user who caused the error cannot see it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
Every list and every screen defines all four. A missing state is a bug, not an omission.
|
||||||
|
|
||||||
|
| State | Rule |
|
||||||
|
|---|---|
|
||||||
|
| **Loading** | Shown from the first frame, never a blank screen |
|
||||||
|
| **Empty** | Title plus one sentence; an action when there is a sensible one |
|
||||||
|
| **Error** | In the feedback slot above, with what failed and what to do |
|
||||||
|
| **Partial** | Stale data stays visible. Never replace loaded content with an empty list because one source failed — see `CalendarViewModel.kt:377-416` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event rendering
|
||||||
|
|
||||||
|
The one object all three clients draw, and the one most likely to drift.
|
||||||
|
|
||||||
|
| | Rule |
|
||||||
|
|---|---|
|
||||||
|
| Radius | `event` (4) everywhere |
|
||||||
|
| All-day | Own strip above the time grid, packed into lanes |
|
||||||
|
| Timed, overlapping | **Side by side, never stacked.** Android's week and day views currently draw every event at full column width, so one of two 10:00 meetings is completely invisible (`TimeGridView.kt:156-169`) |
|
||||||
|
| Overflow | `+N` when the lane count is exceeded, with a way to see the rest |
|
||||||
|
| Colour | Per-event colour, else calendar colour; the group view's server-resolved colour wins |
|
||||||
|
| Title | The server-decorated title (birthday age, group prefix) wins over the raw one |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
Home-screen widgets are **always native**: iOS requires WidgetKit with SwiftUI views, Android
|
||||||
|
requires Glance or RemoteViews. No cross-platform framework changes this. What is shared is
|
||||||
|
the **selection and the content**, defined here once.
|
||||||
|
|
||||||
|
| Widget | Shows | iOS | Android |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `today` | Today's events | small | 2×2 |
|
||||||
|
| `upNext` | The next few events | medium | 4×2 |
|
||||||
|
| `month` | Month grid with event dots | medium | 4×4 |
|
||||||
|
|
||||||
|
iOS ships 13 widgets today; these three are the ones Android mirrors. The rest stay
|
||||||
|
iOS-only — that is fine, as long as the three above look and behave the same.
|
||||||
|
|
||||||
|
**Data path, both platforms:** the app writes a snapshot into shared storage; the widget reads
|
||||||
|
it and performs **no networking of its own**. The snapshot format is defined by `CalendarrCore`
|
||||||
|
on iOS and is the reference for Android's equivalent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wording
|
||||||
|
|
||||||
|
The translation keys in `android/…/ui/L10n.kt` and `ios/…/Models/Localization.swift` are
|
||||||
|
deliberately kept identical — Android even converts `%@` to `%s` so the same table works. That
|
||||||
|
is a contract: **a key means the same thing on every platform**, and a new key is added to all
|
||||||
|
of them.
|
||||||
|
|
||||||
|
Nothing user-visible is hard-coded. Current violations, all of which show a German string to
|
||||||
|
an English user:
|
||||||
|
|
||||||
|
- `ColorPickerDialog.kt` — `"Abbrechen"`, `"OK"`, `"Hex"`
|
||||||
|
- `NotificationScheduler.kt` / `.swift` — every notification body
|
||||||
|
- The nine watch complication names on iOS
|
||||||
|
- Date patterns built without a `Locale`, and `is24HourView = true` forced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliberately not unified
|
||||||
|
|
||||||
|
| | Why |
|
||||||
|
|---|---|
|
||||||
|
| Navigation | Bottom sheets and a drawer on Android, navigation stack and sheets on iOS |
|
||||||
|
| System font | Roboto / SF — using one font on both would look wrong on one of them |
|
||||||
|
| Controls | Switches, pickers, date entry stay platform-native |
|
||||||
|
| Gestures | Swipe-back on iOS, system back on Android |
|
||||||
|
| Widget families | Each platform has its own sizes; only the three above must correspond |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to check
|
||||||
|
|
||||||
|
There is no automated check — both apps have **zero tests**. So it is done by eye, and these
|
||||||
|
are the questions that catch real drift:
|
||||||
|
|
||||||
|
1. Open every screen in turn. Does the **left text edge** stay put?
|
||||||
|
2. Put the two apps side by side on the same screen. Same order, same words, same rows?
|
||||||
|
3. System font size to maximum. Does anything clip? Do icons grow with their text?
|
||||||
|
4. Device language to English. Any German left? Trigger a reminder and read it.
|
||||||
|
5. Two events at the same time, week view. Are **both** visible?
|
||||||
|
6. Turn the server off and save something. Does the message appear in the feedback slot?
|
||||||
|
7. Every list: force it empty. Is there an empty state, or just nothing?
|
||||||
Reference in New Issue
Block a user