Files
ScarriffleandClaude Opus 5 6b7317b4e0 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>
2026-09-14 11:56:59 +02:00

9.6 KiB
Raw Permalink Blame History

Design contract (Web / iOS / Android)

For the user-configurable colours, see ../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?