Club Hub — UX guide
Contents
- 1. Principles
- 2. Users and contexts
- 3. Navigation and information architecture
- 4. Screen patterns
- 5. Actions and risk
- 6. Forms
- 7. Feedback and states
- 8. Mobile and touch
- 9. Content and language
- 10. Accessibility
- 11. AI-assisted UX
- 12. Data display
- 13. Gamification and motivation
- 14. Review checklist and anti-patterns
Version 0.1, 2026-10-02. Owner: E1 Design System with E2 Content & Localization (see ARCHITECTURE.md §10.1 (../../ARCHITECTURE.md)).
This guide covers how Club Hub behaves: structure, flows, interaction, feedback, content and accessibility. It applies to every app (Admin, Trainer, Member, Public, Screens, Operator console) and every feature module.
- Visual language (colour, type, spacing, icons, component visuals) lives in the design guide. This guide never sets a colour or a size except where it is a behaviour rule (e.g. 44px touch targets).
- Components live in
packages/ui, with every state shown in the living style guide atstyleguide/index.html. Component names in this guide (incodestyle, e.g.confirm dialog) are the names used there. - Tokens live in
packages/ui/css/tokens.css(../packages/ui/css/tokens.css). - Evidence: every rule here traces back to the UX audit of the 15 current apps in UX-ANALYSIS.md (
../../UX-ANALYSIS.md) (cited as Audit §n or Audit #n for the ranked problems) and to the target architecture in ARCHITECTURE.md (../../ARCHITECTURE.md) (Arch §n). Screenshots referenced are inux/shots/(../../ux/shots/).
How to read the rules:
| Marker | Meaning |
|---|---|
| Must | Required. A PR that breaks it does not merge. |
| Should | Default. Deviate only with a reason in the PR description. |
| ✓ Do / ✗ Don't | Examples. German UI copy is shown in quotes, because DE is the source language. |
1. Principles
Seven principles. When two rules collide, the earlier principle wins.
P1 — One product, not fifteen
Audit §1, #1, #7: five visual families, four navigation models, attendance built four times, stripes three times.
One shell, one design system, one vocabulary, one way to do each thing. A concept (attendance, stripes, helpers, a belt) has one component and one screen, reused wherever it appears. Modules are told apart by icon and name, never by their own colour or layout.
- ✓ The stripe editor in the Trainer app and on the exam-day screen is the same
stripe chipscomponent. - ✗ A module with its own sidebar, its own logout, its own toast style.
P2 — Phone first where the work happens
Audit #3, §3: admin UIs break on phones; the Trainer App and athlete "Heute" work because they are phone-native.
Trainers work in the Dojang with one hand, parents on the sofa, athletes in the gym. Trainer, Member and Public are designed at 390px first and must be fully usable there. Admin is desktop-first but every screen must still work on a phone (read, approve, answer) without horizontal scrolling.
P3 — One obvious next action
Audit #11: belt-test detail with 11 controls + 5 icon buttons per row; events toolbar with 13 controls.
Every screen has at most one primary button. Every list row has at most one primary action plus an overflow menu (⋯). Dashboards show "what needs me now", not counters.
P4 — Undo over confirm; confirm what can't be undone
Audit #9 and §3: Trainer App undo works; invoice "Alle Entwürfe buchen" books everything without asking.
Reversible actions happen immediately and offer undo. Irreversible or external actions get a confirm dialog that states the consequence, or a dry-run preview. Never a confirm for something that can be undone; never no confirm for something that can't (§5).
P5 — Never lose input
Audit #4: attendance, stripes, ratings, exam results and athlete ticks are lost on flaky Wi-Fi.
Anything a person typed or tapped survives a bad connection, a reload, a back button and an app switch. Writes go through the offline outbox (packages/offline) and show a save status. Long forms autosave drafts.
P6 — Say it in the user's language
Audit #5, #6: hub hard-coded English, apps mixing DE/EN/FR on one screen, three belt vocabularies.
Every string comes from the catalogue in the user's locale (DE source; FR/IT/EN; FA for athletes). One term per concept from the glossary. No developer text, no raw codes, no half-translated screens.
P7 — AI proposes, people decide
Audit #10; Arch §7.2: irreversible actions are propose_only.
AI drafts, suggests and prepares. A human sees what will happen and approves. AI output is always marked as such, and nothing AI-generated is sent, booked, printed or written to the CRM without a person pressing the button.
2. Users and contexts
| User | App | Device and situation | Design implications |
|---|---|---|---|
| Trainer in the Dojang | Trainer | Phone, one hand (the other holds a pad or a child), standing, bad Wi-Fi, 2–5 minute windows between drills, sometimes gloves/sweaty fingers | Two taps from opening the app to the class; large targets (≥ 44px, prefer 56px for attendance rows); offline outbox; no typing where a tap will do; voice notes; undo instead of confirm |
| Admin at a desk | Admin | Laptop, mouse + keyboard, longer sessions, batch work (approvals, billing queue, newsletters) | Tables with sorting/filters, keyboard shortcuts for frequent actions, bulk actions on the selection, dry-run previews before external writes |
| Board member | Admin (reduced role) | Phone or laptop, occasional, mostly reading and approving | "Needs me" dashboard, approve/reject on a phone, summaries over raw data |
| Comms person | Admin (comms studio, inquiries), Member (share/upload) | Laptop for writing; phone for photos at events and quick replies | AI drafts with accept/reject per block; autosave; preview per language before sending; phone reply flow (list → detail → du/ihr/Sie → send) |
| Parent | Member, Public | Phone, in a hurry, often not a native German speaker, may have several children | "Meine Familie" as home; context up front (date, fee, what happens next); confirmation emails; language switch on every public page; plain language |
| Athlete | Member (athlete section) | Phone in the gym, between sets, may be a minor | "Heute" two taps away; swipe-to-tick with a tap alternative; same-day undo; motivating, never shaming; strict data minimisation for minors |
| Anyone (public) | Public | Phone from a link or QR code, no account | No login; one task per form; works without JavaScript-heavy features where possible; no personal data echoed back |
| Dojang screen | Screens | Wall TV, read from 3–8 metres, nobody touches it | One message, huge type, survives network blips without turning red (Audit #4) |
Rule: before designing a screen, write down which row of this table it serves. If it serves two very different rows (e.g. a trainer on a phone and an admin at a desk), it is probably two screens in two apps over the same API.
3. Navigation and information architecture
3.1 App shell
All apps use the app shell. Modules plug into it through the module-slot contract (Arch §10.1 E3): they register routes, nav items, required capabilities and settings; the shell does auth, layout, language, offline and errors.
| Desktop (≥ 1024px) | Mobile (< 1024px) | |
|---|---|---|
| Primary navigation | Sidebar (collapsible rail) | Tab bar at the bottom, max 5 items (4 areas + "Mehr") |
| Context | top bar with page title, global search, notifications, user menu | top bar with page title and back; search behind an icon button |
| Club | club switcher at the top of the sidebar | club switcher in the user menu / "Mehr" |
Rules:
- Must: one navigation layer. No module draws its own sidebar, header, logout or "Back to Hub" bar (Audit #1, #2).
- Must: no iframes. Every module is a route of the app.
- Must: at most 5 primary items per audience on mobile, at most 7 top-level sidebar groups on desktop. Volunteer admin's 16 nav items are the anti-example (Audit #11).
- Should: order primary items by frequency for that role, not by module ownership. Trainer: "Heute", "Klassen", "Pläne", "Prüfung", "Mehr".
- Must: the active item is marked visually and with
aria-current="page". - Must: one user menu holding language, theme (hell/dunkel/System), club switcher, help and "Abmelden".
3.2 Page hierarchy
Keep it shallow: area → list → detail → (sub-tab). Never deeper than three levels below the area.
/members Mitglieder (list)
/members/4711 Lea Muster (detail)
/members/4711/belts Lea Muster · Gurte (tab in the detail)
/belt-tests/2026-11/applicants Gurtprüfung Nov. 2026 · Anmeldungen
- Every page has a
page header: title, optional one-line description, at most one primary action, optional overflowmenu. - Sub-views of one object use
tabs, and each tab has its own URL. - Detail pages on mobile show a
back linkin the top bar to their parent list (not to "wherever you came from" if that was outside the app).
3.3 URLs, deep links and back
Audit #14: hub URL was only #/app/<id>; media-hub had no routing.
- Must: every screen, tab, filter set and open dialog that a person might share or return to has a real URL (History API, no hashes). Filters live in the query string:
/members?status=pending&group=tigers. - Must: reload restores the same screen. Browser back closes an open dialog or
bottom sheetbefore leaving the page. - Must: links from emails, push notifications and the notification centre deep-link to the object, after sign-in if needed (return to the target URL, not the dashboard).
- Must: the document title follows the route:
{Page} · {Area} · {Club}— e.g. "Anmeldungen · Gurtprüfung · TKD Bern". - Must: on route change, move focus to the page
h1and announce the new title (screen readers otherwise hear nothing). - Should: scroll position of a list is restored when coming back from a detail.
3.4 Club switcher
One identity can belong to several clubs (Arch §8.1).
- Shown only when the person has more than one club membership. Never show a switcher with one entry.
- Switching club reloads navigation and data and lands on that club's home, never on a page of the previous club.
- The active club name is always visible (sidebar header / top bar), so a trainer of two clubs never records attendance in the wrong one.
- Deep link to a club the person is not a member of: "Du hast keinen Zugang zu diesem Verein." plus a link to their own clubs.
3.5 Entitlements, settings, flags and permissions
A feature is usable only if it is entitled ∧ enabled ∧ flagged on ∧ permitted (Arch §5). Navigation is built from the session's capabilities object.
| Situation | Navigation | Page / action |
|---|---|---|
| Club didn't buy the feature | Hidden. No teaser, no lock icon, no greyed-out item in working UI. | Direct URL → "Diese Seite gibt es in deinem Verein nicht." with a link home. |
| Bought but switched off by the club | Hidden for everyone except admins, who find it in Einstellungen. | Same as above for non-admins. |
| Flag off | Hidden. | Same as above. |
| User lacks permission | Hidden. | Direct URL → "Dafür fehlen dir die Rechte. Frag den Vorstand." (no 403 codes). |
| User may read but not change | Visible. | Read-only view; edit controls absent (not disabled). Explain only if someone would expect to edit: "Nur Admins können Gurte ändern." |
- Must: never show disabled upsell teasers in working UI. Where a club can buy more is the operator's business, not the trainer's.
- Should: disabled controls are rare. If a control is disabled for a temporary reason, say why next to it ("Erst nach dem Abschluss der Prüfung möglich").
4. Screen patterns
4.1 List → detail
The default for any collection (members, applicants, inquiries, events, documents).
- Lists use
list+list row: title, one line of metadata, optionalstatus badge, one primary action, overflowmenu(⋯) for the rest. - On desktop, a list may open the detail in a side panel; on mobile the detail is its own route. Either way the URL changes.
- Data-heavy admin collections use
table, which collapses to cards on mobile (§12). - Search is server-side and searches everything, not the loaded page (Audit #12: document-hub searched only 5 docs). Search field keeps focus while results update.
- Search is tolerant: case, umlauts (
Müller=Mueller=muller), and leading/trailing spaces.
✓ Row: "Lea Muster · 9. Kup · Tigers" — primary button "Genehmigen", ⋯ → "Bearbeiten", "Ablehnen", "Mit Schnupperer zusammenführen". ✗ Row with five emoji icon buttons and no labels.
4.2 Dashboards = "what needs me today"
Audit §6 New screens: coach "needs attention today" home, parent "Meine Familie" view.
- Must: a home screen lists actionable items, each linking to where it is resolved: "3 Anmeldungen warten auf Genehmigung", "Training Kinder 7–10 heute 17:30 — Präsenz offen".
- Counters (
stat tile) are allowed only if they lead to an action or answer a question the role actually asks. "Mitglieder: 312" on its own is decoration. - When nothing needs attention, say so with an
empty state: "Alles erledigt. Nächstes Training: Mo 17:30." - Order: overdue → today → this week. Never more than ~7 items before "Alle anzeigen".
| Role | Home shows |
|---|---|
| Trainer | Today's classes (claim, attendance), students ready for a test, unsynced items |
| Admin | Pending approvals, billing queue, pending AI proposals, failed jobs |
| Parent | Each child: next training, belt and next test, open sign-ups, open invoices |
| Athlete | "Heute": today's session, wellness check, streak |
4.3 Wizards and steppers
Use a stepper when a task has 3–5 dependent steps or more than ~8 fields. Good precedents: invoice CSV wizard (Audit §3), exam-day flow "Vorbereiten › Prüfungstag › Abschluss › Abrechnung".
- Steps are named by what the person does ("Datei wählen", "Spalten zuordnen", "Prüfen"), max 5.
- Each step has its own URL; back goes to the previous step with data intact.
- The last step is a review that shows exactly what will happen, then the commit button with a verb ("3 Rechnungen erstellen").
- Progress is saved per step; leaving and returning resumes ("Du hast einen Entwurf von gestern. Weitermachen?").
4.4 Settings generated from schema
Club settings UIs are generated from each feature's settingsSchema (Arch §5).
- Schemas carry localized
titleanddescription; the generator renders them asfieldlabel and hint. A schema without translations fails the build. - Group settings by task ("Anmeldung", "Gebühren", "E-Mails"), not by technical key. Never show a key like
grading.exam-day.autoClose. - Booleans →
switchsaved immediately withsave status. Groups of related values → a form with one "Speichern" button. - Each setting says its effect: "Eltern erhalten eine Erinnerung 2 Tage vor dem Training."
4.5 Public forms
See §6.6 for the full public-form kit rules. Pattern: one task per page, club identity at the top, language switch, stepper if more than one screen, confirmation page + email at the end.
4.6 Screens (Dojang TV)
Audit §3: belt-test Dojang screen "JETZT AUSRÜSTUNG ANLEGEN" works.
- One message at a time, readable from 8 m. No navigation, no cursor, no hover.
- Shows the next relevant thing: current group, who is next, countdown.
- Must: tolerate network failures. Keep showing the last known state; after 60 s without data, show a small, calm "Verbindung wird wiederhergestellt …" in a corner. Never turn the whole screen red for one failed poll (Audit #4).
- No personal data beyond what the room already knows (first name + initial, group).
5. Actions and risk
5.1 Risk classes → UI patterns
The same risk classes are used by the API and the AI policy (Arch §7.2). Every action declares its class in the agent guide; the UI follows it.
| Class | Examples | UI pattern |
|---|---|---|
| Safe | Tick attendance, add a note, edit a draft, change a filter, toggle a setting | Just do it. Optimistic update + save status ("gespeichert"). No toast needed unless the effect isn't visible. |
| Reversible | Remove a stripe, reject an applicant, archive an item, unclaim a class | Do it, then toast with "Rückgängig" (≥ 5 s, longer if the user is on a keyboard/screen reader; pauses on hover/focus). |
| Irreversible | Delete permanently, close a belt test, book invoices | confirm dialog: title as a question, consequence in plain words, verb label on the button. |
| External | Send email/campaign, write to the CRM, print, push to members | confirm dialog with a dry-run preview of the effects (recipients, writes, amounts). Never on a single click. |
✓ Confirm for closing a belt test:
Prüfung abschliessen? 23 neue Gurte werden in Webling eingetragen. 4 Personen haben nicht bestanden. 23 Prüfungsgebühren kommen in die Abrechnung. Das kann nicht rückgängig gemacht werden. [Abbrechen] [23 Gurte eintragen]
✗ "Sind Sie sicher?" [OK] [Abbrechen] ✗ Browser confirm() — banned by lint (Audit #15: ~120 native alert/confirm/prompt calls).
5.2 Dry-run previews
Generalises the belt-test close dialog (Audit §3) via ?dryRun=true (Arch §6).
- Show the planned effects as a list grouped by kind ("In Webling: 23 Gurte", "Rechnungen: 23 × CHF 40.00", "E-Mails: 23").
- Each group can expand to the individual items.
- If the dry-run finds problems, show them before the button: "2 Personen haben keine E-Mail-Adresse — sie erhalten keine Bestätigung."
- The commit button repeats the main number: "23 Gurte eintragen". If the numbers changed between preview and commit (ETag conflict), re-run the preview.
5.3 Bulk actions
Audit #9: "Alle Entwürfe buchen" ignored the selection.
- Must: bulk actions act on the selection only. The button names the count: "3 Entwürfe buchen". With nothing selected, the bulk bar is not shown.
- "Select all" selects the visible/filtered rows and says so: "Alle 18 gefilterten auswählen". Selecting across pages is explicit: "Alle 212 auswählen".
- A bulk action keeps the class of its riskiest item. Bulk external actions always get a dry-run.
- After a bulk action, report the outcome per item when some failed: "16 gebucht, 2 fehlgeschlagen — Details".
5.4 Primary action placement
| Context | Placement |
|---|---|
| Page | page header, right on desktop; on mobile a full-width button at the bottom of the content or a sticky bottom bar above the tab bar |
| Form | At the end of the form, left-aligned with the fields; primary first, then secondary ("Speichern", "Abbrechen") |
| Dialog | Bottom right on desktop; full-width stacked on mobile with the primary at the bottom (thumb) |
| List row | Right end of the row; one primary, rest in ⋯ |
| Bottom sheet | Sticky at the bottom of the sheet |
- Must: one primary (filled) button per view. Everything else is secondary or a quiet button.
- Must: never place a destructive action next to the primary without separation (e.g. "Alle bestanden" next to "Abschliessen", Audit #11).
5.5 Destructive styling
- Danger styling (see design guide) only for actions that destroy or remove something of value: "Löschen", "Mitglied austreten lassen".
- Danger buttons are never the default focus in a dialog; focus starts on the safe option ("Abbrechen").
- Do not use danger styling for "Abbrechen", "Schliessen" or "Ablehnen" of a reversible thing.
- Truly destructive and high-impact (delete a club, delete all data): type-to-confirm with the object name.
6. Forms
6.1 Labels, hints, required
- Must: every input has a visible
<label>bound withfor(Audit §5: belt 0 of 53, events 0 of 64 labels). Placeholders are never labels. - Hints go under the label, before the input, and are linked via
aria-describedby: "So wie in deinem Pass". - Mark the minority. If most fields are required, mark optional ones "(optional)". If most are optional, mark required ones "(Pflicht)". Never rely on a red asterisk alone.
- What is marked required is enforced, client and server (Audit #8: guardian fields marked "Pflicht" but not enforced).
6.2 Validation timing
| When | What |
|---|---|
| While typing | Nothing (except input masks and character counters) |
| On leaving a field (blur) | Format errors only, and only once the field has been touched |
| On submit | Everything. Show the error summary at the top, move focus to it, each entry links to its field |
| After an error is shown | Re-validate the field live as the user fixes it, so the error disappears as soon as it's right |
- Error messages say what to do: ✓ "Gib ein Geburtsdatum im Format TT.MM.JJJJ ein." ✗ "Ungültige Eingabe." ✗ "invalid_date".
- Server field errors (problem+json
errors[], Arch §6) map onto the samefielderror slots. - On mobile, scroll the first error into view below the sticky top bar.
6.3 Input types and keyboards
| Data | Markup |
|---|---|
type="email" autocomplete="email" | |
| Phone | type="tel" autocomplete="tel" |
| Login code (OTP) | inputmode="numeric" autocomplete="one-time-code" (as in the athlete app) |
| Name | autocomplete="given-name" / "family-name"; no format validation on names |
| Date of birth | three fields (Tag/Monat/Jahr) or a text field with format hint; not a scrolling date picker for birthdays |
| Amount (CHF) | inputmode="decimal", accept . and ,, format on blur |
| Address | autocomplete="street-address" / "postal-code" / "address-level2" |
| Choice of ≤ 5 | checkbox/radio or segmented control — not a select |
| Choice of > 7 | select, or a searchable combobox for long lists (members, exercises) |
| Rating 1–5 / RPE | scale input |
6.4 Conditional fields
- Show a field only when it applies, directly after the field that triggers it, and announce the change (
aria-live="polite"on the region). - Guardian under 16: once the date of birth makes the person younger than 16, the guardian block appears and becomes required. If the date changes to 16+, the block hides and its values are not submitted.
- Hidden fields are never validated and never sent.
6.5 Long forms and never losing data
- More than ~8 fields or more than one topic → split into steps (§4.3).
- Must: forms with more than a few fields autosave a draft locally (
packages/storage) and, where the API supports drafts, on the server. Thesave statusshows "Entwurf gespeichert". - Must: leaving a form with unsaved changes asks first, in-app (not
beforeunloadtext): "Änderungen verwerfen?" [Weiter bearbeiten] [Verwerfen] — as in the email-hub phone app (Audit §3). - On a version conflict (ETag), don't overwrite silently and don't throw away the user's input: show both and let them choose.
- A failed submit keeps every field filled in.
6.6 Public-form kit
Audit #8, §6: public forms lacked date, fee and group; no confirmation email; family check leaked members at an address.
| Rule | Detail |
|---|---|
| Context up front | Before the first field: what this is, date/time, place, fee, who it is for, and what happens next. "Gurtprüfung Sa 14. Nov. 2026, 10:00, Dojang Bern · CHF 40.– · Du erhältst bis 7. Nov. eine Bestätigung mit der Startzeit." |
| Language | Language switch at the top (DE/FR/IT/EN); choice is kept for the confirmation email. |
| One task | One form, one purpose. No login required. Max ~3 steps. |
| Pickers, not free text | Belt via a select from the club's grading system — never free text (Audit #6: "9.Kup" lost on approval). |
| Conditional rules | Guardian under 16 (§6.4). Consent checkboxes are separate and never pre-ticked. |
| Errors | error summary + scroll to the first error (§6.2). |
| No data echo | Never show anonymous users any personal data from the system — no "these members already live at this address", no "this email belongs to …". Family matching happens on the admin side. |
| Duplicates | If the email is already known, accept the submission and handle it by email ("Wir haben dir eine E-Mail geschickt") — don't reveal membership on screen. |
| Confirmation page | Summary of what was submitted (the user's own data only), what happens next and when, how to change or cancel. |
| Confirmation email | Always, via the transactional stream (Arch §8.3): same summary, .ics file for dated things, edit/cancel link. |
| Bots | Spam protection that doesn't require solving puzzles. |
7. Feedback and states
Every view designs all its states: loading, empty, partial, error, offline, success. The style guide shows each one per component.
7.1 Loading
- Under ~300 ms: show nothing new (avoid flashes).
- Over ~300 ms:
skeletonin the shape of the content, per module region — never a full-page blank or a spinner over the whole app. - Over ~1 s for an action: the button shows a
spinnerand its label changes ("Wird gespeichert …"); it stays disabled to prevent double submits. - Over ~10 s, or anything that may outlive the page: hand it to the job centre (§7.5).
7.2 Empty states
An empty state explains why it's empty and offers the next step.
| Kind | Example |
|---|---|
| First use | "Noch keine Lektionspläne. Erstelle deinen ersten Plan oder übernimm einen Vorschlag." [Plan erstellen] |
| No results | "Keine Mitglieder für «Mueler». Prüfe die Schreibweise oder entferne Filter." [Filter zurücksetzen] |
| All done | "Keine offenen Anmeldungen. Gut gemacht." |
| No access/feature | See §3.5 — explain, link home. |
7.3 Errors
Audit #14: unreachable app showed the browser's raw error page.
- Must: human message, what happened, what to do. ✓ "Die Präsenz konnte nicht gespeichert werden. Sie wird automatisch erneut gesendet, sobald du wieder online bist."
- Must: never show raw HTTP codes, stack traces, JSON, "Webling error 422" or provider messages. The problem+json
titleis already localized (Arch §6); use it. - Must: show the
traceIdsmall, selectable, under the message ("Fehler-ID: 7f3a…"), so support can find it. Offer "Fehler-ID kopieren". - Module error boundaries: one failing module shows an inline error with "Erneut versuchen"; the shell and other modules keep working.
- Field-level problems go into the form (§6.2), not a toast.
- Policy refusals are explained: "Das darf nur der Vorstand freigeben. Dein Vorschlag wurde zur Freigabe gesendet."
7.4 Success
- If the result is visible (row ticked, item moved), that is the feedback — no toast.
- If it isn't visible (sent, booked, queued), a
toast: "Bestätigung an 23 Personen gesendet." - After completing a multi-step task, a success screen with the next step ("Zurück zur Prüfung", "Nächste Prüfung planen").
7.5 Long-running jobs (job centre)
Mail campaigns, AI drafts, uploads, print jobs, CRM commits run through jobs (Arch §4).
- Starting a job gives immediate feedback ("Versand gestartet") and the user can leave the page.
- The job centre (in the top bar on desktop, under "Mehr" on mobile) lists running and recent jobs with a
progress bar, status and a link to the result. - Finished or failed jobs notify in-app; failures link to details and "Erneut versuchen".
- Cancel/pause where the backend supports it (campaign sending can pause/resume, Arch §8.3).
7.6 Offline and sync
The Trainer and Member apps queue writes in the IndexedDB outbox (packages/offline).
| State | save status copy | Behaviour |
|---|---|---|
| Saved on server | "gespeichert" | Quiet; may fade after 2 s |
| Queued | "wartet" + count, e.g. "3 Änderungen warten" | Retries automatically with backoff; user can keep working |
| Failed (needs a human) | "Fehler" + action | Tap opens the list of failed items with "Erneut senden" / "Verwerfen" and the reason |
- Must: offline is never an error dialog. A calm
alert/banner: "Offline — Änderungen werden gespeichert und später gesendet." - Must: the outbox survives reload, app close and phone restart.
- Must: sign-out with pending items warns: "3 Änderungen sind noch nicht gesendet. Trotzdem abmelden?"
- Data needed in the Dojang (today's classes, rosters, students' belts and stripes) is cached for offline reading.
- Conflicts (someone else changed the same thing) are resolved per field where possible, otherwise shown to the user — never silently overwritten.
7.7 Notifications
| Channel | Use for | Rules |
|---|---|---|
| In-app (notification centre) | Everything that needs the person: approvals, AI proposals, failed jobs, replies | Each item deep-links to the object; marked read when opened |
| Push | Time-sensitive things for phone users: training starts, cancelled training, athlete nudges | Opt-in per topic; quiet hours; never marketing |
| Confirmations, receipts, things with legal weight, digests for infrequent users | Transactional stream; link to the object |
- Must: ask for push permission in context, after showing value, never on first load. ✓ After the first completed session in the athlete app: "Sollen wir dich ans Training erinnern?" [Ja, erinnern] [Später] — only "Ja" triggers the browser prompt.
- Settings per topic and channel live in the user menu ("Benachrichtigungen").
- Don't send the same thing on three channels. Default: in-app + one of push/email.
8. Mobile and touch
- Must: touch targets ≥ 44 × 44 px (
--tap-min), with ≥ 8px between adjacent targets. Audit measured ~28px (Audit §5). Attendance and set-tick rows: 56px+. - Thumb zone: primary actions in the lower half of the screen (bottom bar, sticky action,
bottom sheet). The top bar holds navigation and rarely-used actions. - Bottom sheets for short choices and quick details on mobile (claim tier, exercise detail, row actions). Dialogs on desktop. A sheet closes with the handle, a tap outside, Esc and browser back.
- Swipe is never the only way. Every swipe action is also in the row's ⋯
menu(thelistcomponent refuses to render otherwise), and a frequent one also gets a visible quick action ("Fertig").
8.1 Swipe actions
Both current models are kept. The risk class of the action decides which one applies:
| Model | What the gesture does | Use for | Example |
|---|---|---|---|
| commit | Releasing past the threshold (72 px) performs the action. The layer turns solid when armed; there is a short vibration. | safe and reversible actions only. Reversible ones show an undo toast. | Athlete: right = exercise done, left = one set done |
| reveal | The swipe only uncovers a button; tapping the button performs the action. The row rests open; Esc, a tap elsewhere or scrolling closes it. | Writes to external systems (CRM, invoices), anything a brushing thumb must not trigger. Never irreversible in commit mode (checkScreen rejects it). | Trainer: "War nicht da" / "Verschieben" |
- Directions are logical:
startis uncovered by dragging towards the end side (right in German, left in Persian). The engine mirrors this for RTL. - Vertical scrolling always wins. A swipe never starts on a button or form control. The click that ends a drag never opens the row.
- Two actions per row at most (one per side). Same action, same side, in every list of the app.
- Reduced motion: the row doesn't animate, but the gesture still works.
8.2 Should swipe be the dominant interaction?
Yes, but only where the same quick action is repeated many times in a row on a phone. Lists choose with gestures:
gestures | When | What changes (phones only) |
|---|---|---|
primary | High-frequency, repetitive, same action per row, done one-handed during an activity: ticking exercises and sets (athlete), possibly attendance correction during class | The swipe hint is shown until dismissed ("Nicht mehr anzeigen"). The first open row peeks once per session, so people see that rows move. Quick actions become icon-only to give the gesture room (label kept as accessible name). |
shortcut (default) | Everything else, especially admin lists, mixed actions, and lists people open rarely | Swipes work for those who know them; nothing advertises them; quick actions keep their labels. |
Why not dominant everywhere: swipes are invisible. People who don't know them, or use a mouse, keyboard or switch access, must never depend on them. They also collide with horizontal scrolling and browser back-swipe at the screen edge. In the athlete app the gesture works because the same two actions repeat dozens of times per training and the hint teaches them once.
Decision rule: make swipe primary only if (1) the action is repeated ≥ 5 times per session, (2) it is safe/reversible (commit) or the list is a correction workflow (reveal), and (3) user testing shows people find it after the hint. Otherwise keep shortcut.
- No hover-only information or actions. Tooltips are supplements.
- Safe areas: respect
--safe-top/--safe-bottom; the tab bar and sticky buttons sit above the home indicator. - Viewport: use
dvh, notvh, so the on-screen keyboard and browser chrome don't hide buttons (Trainer App precedent, Audit §3). When the keyboard opens, the focused field and its submit button stay visible. - One-handed: a whole Trainer flow (open → class → attendance → done) must be possible with the thumb of one hand. No long-press as the only path; no pinch.
- Orientation: portrait first; landscape must not break. Tablets on exam day use the Trainer layout scaled up, not the Admin desktop (Audit #3: 957px test detail).
- PWA install: offer installation after the second meaningful visit or from "Mehr › App installieren", with platform-specific steps (iOS: "Teilen › Zum Home-Bildschirm"). Never as an interrupting popup on first visit. Each app has its own manifest scope and name ("Club Hub Trainer").
9. Content and language
E2 owns the catalogues, the glossary and voice-and-tone (Arch §10.1). This section is the summary every author needs.
9.1 Voice and tone
- Friendly, direct, short. Write like a helpful trainer, not like a bank or a developer.
- Address: German "du" by default; a club can switch to "Sie" in its settings. Copy is written for both variants in the catalogue (ICU
selecton the address form). FR: "tu"/"vous" follows the same setting; IT likewise. - Active voice, present tense, the person as subject: ✓ "Du hast 3 neue Anmeldungen." ✗ "Es wurden 3 Anmeldungen registriert."
- Error messages never blame: ✓ "Diese E-Mail-Adresse kennen wir nicht." ✗ "Falsche E-Mail!"
- No exclamation marks except for genuine celebrations (§13). No ALL CAPS except the Dojang screen.
9.2 Labels
- Buttons are verbs describing the outcome: "Präsenz speichern", "Anmeldung senden", "3 Rechnungen buchen". ✗ "OK", "Ja", "Submit", "Weiter" on the final step.
- Dialog buttons repeat the verb of the question: "Prüfung abschliessen?" → [Prüfung abschliessen].
- Navigation and headings are nouns: "Mitglieder", "Gurtprüfungen".
- Swiss spelling: "ss" not "ß" ("abschliessen", "Strasse").
9.3 Glossary — one term per concept
- Must: every domain term comes from the glossary in
packages/i18n. A term is decided once and used everywhere, in all apps, emails and docs. - Open decisions to settle before the first release that uses them (examples from the audit):
| Concept | Candidates | Decision |
|---|---|---|
| Trial training | Probetraining / Schnuppertraining (person: Schnupperer) | to decide (E2) |
| Helper | Helfer / Helfereinsatz / Volunteer | "Helfereinsatz" for the shift model (Audit §6) |
| Belt test | Gurtprüfung / Prüfung / Test | to decide (E2) |
| Attendance | Präsenz / Anwesenheit | to decide (E2) |
- Belt and stripe names come from the club's grading system (Arch §4 grading), rendered by
belt badge/stripe chips. Never hard-code "6. Kup (Grüngurt)" or mix "7 - Advanced Yellow" with "6. Kup" (Audit #6).
9.4 No developer text, no raw values
- Must not appear in UI: phase notes, TODOs, "wrangler …", debug toasts, console messages, internal IDs as primary labels, feature keys, enum codes (
PENDING_APPROVAL,kyorugi), JSON (Audit §6 "Drop": developer text in the UI). - Enum labels come from the API/catalogue with the code stable (Arch §6 i18n). Status values render as a
status badgewith a label. - Debugging belongs in the debug console (Arch §9.2), not in the page.
9.5 Formatting
All formatting through Intl with the user's locale (de-CH default). Never hand-roll.
| Thing | de-CH example | Rule |
|---|---|---|
| Date | "Sa, 14. Nov. 2026" / "14.11.2026" | Long form in headings and emails; short in tables. Relative ("heute", "morgen", "vor 3 Tagen") for recent/near events. |
| Time | "17:30" | 24h |
| Money | "CHF 40.00" / "CHF 1'250.50" | Intl.NumberFormat(locale, {style:'currency', currency:'CHF'}) |
| Numbers | "1'250" | Intl.NumberFormat |
| Plurals | "1 Anmeldung" / "3 Anmeldungen" | Intl.PluralRules via t(); never "Anmeldung(en)" |
| Names | "Lea Muster" | Given name first; sort by family name in admin lists |
9.6 Translations
- Must: a language appears in the switcher only when its catalogue is complete for every screen the user can reach. No half-translated switchers (Audit #5).
- CI fails on missing keys and hard-coded strings (Arch §10.1 E2).
- Allow for text expansion (FR/IT ~30% longer than DE): no fixed-width buttons, no truncated labels.
<html lang>anddirupdate with the locale; FA switches to RTL with mirrored layout (icons with direction mirror; numbers and belts don't).- User-generated content (newsletter text, notes) keeps its own
langattribute when known.
10. Accessibility
WCAG 2.2 AA is the minimum. axe runs in CI on every route and on the style guide (Arch §9.1). Automated checks catch about a third of problems — test the rest by hand.
Checklist
- Keyboard: everything reachable and operable with Tab / Shift+Tab / Enter / Space / Esc / arrow keys where the ARIA pattern says so. No click-only
divs (Audit §5: launcher tiles). - Focus visible on every interactive element (
:focus-visible,--color-focus); not hidden by sticky bars (WCAG 2.4.11). - Focus management: dialogs trap focus, Esc closes, focus returns to the trigger. Route changes focus the
h1. - Labels: every input has a programmatic label; groups use
fieldset+legend. - Icon buttons have an accessible name (
aria-labelfrom the catalogue) — required by theicon buttoncomponent (Audit §5: critical on every hub page). - Contrast: use semantic tokens only; they are verified (text 4.5:1, UI and borders 3:1) in
tests/contrast.test.js. No raw hex (lint). - Not colour alone: status has text or an icon with a label. Stripes and belts have text labels (visible or accessible):
stripe chipsshow "Form", "Kyorugi", … and belts their grading-system name. - Live regions:
toast,save statusand async results announce viaaria-live="polite"; errors that block userole="alert". One toast region per app. - Reduced motion: respect
prefers-reduced-motion(tokens set durations to 0); no essential info only in animation; celebrations become static. - Zoom and reflow: usable at 200% zoom and at 320 CSS px width without horizontal scrolling (except data tables, which collapse to cards).
- Language: correct
langanddiron<html>and on foreign-language passages (Audit §5:langwrong almost everywhere). - Target size: ≥ 24px absolute minimum (WCAG 2.5.8), our standard 44px.
- Dragging always has a non-drag alternative (WCAG 2.5.7) — e.g. exam live mode assigns groups by buttons/menus, not only drag-and-drop (Audit §4).
- Timeouts: undo toasts and session expiry give enough time and can be extended; toasts pause on hover/focus.
- Headings: one
h1per page, logical order; landmarks (header,nav,main) from theapp shell. - Forms: errors identified in text, linked to fields, and in the
error summary(WCAG 3.3.1/3.3.3); no re-entering data already given (3.3.7). - Authentication: login by email code with
autocomplete="one-time-code", paste allowed; no cognitive puzzles (3.3.8). - Screen-reader smoke test on VoiceOver (iOS) for phone flows and NVDA for Admin, for each new flow.
11. AI-assisted UX
AI is a server-side service with per-club policy, budgets and audit (Arch §7). These rules make it trustworthy in the UI.
11.1 Drafts
- Must: AI-generated content is marked as a draft from the moment it appears: a
chip"KI-Entwurf" on the block, plus a subtle surface. The mark stays until a person edits or accepts it. - Accept / reject per block (paragraph, article, reply section, plan item), not all-or-nothing. Plus "Neu formulieren" with an optional instruction.
- Show sources where the draft is based on data: "Basierend auf: Trainingsplan Nov., 3 E-Mails von Familie Muster". Each source links to the object.
- Generated text uses the club's tone settings (du/Sie, glossary) and the target language; flag low-confidence parts instead of guessing names, dates or amounts.
- Never auto-send. A draft reply, newsletter or message only goes out when a person presses the send button, through the normal external-action confirm (§5.1).
- Copy-a-prompt-into-Claude loops are gone (Audit #10). "Prompt exportieren" may exist only as a fallback in the overflow menu.
11.2 Propose → approve
Irreversible actions requested by an AI (or a tenant agent) become pending actions (Arch §7.2). They appear in the notification centre and on the approver's home ("Vorschläge zur Freigabe"), and as a card:
┌───────────────────────────────────────────────────────────┐
│ Vorschlag · Gurtprüfung Nov. 2026 │
│ Prüfungsgruppen einteilen (4 Gruppen, 38 Personen) │
│ Angefragt von: KI-Assistent für Sandra Keller · vor 5 Min. │
│ │
│ Was passiert: │
│ • 38 Anmeldungen werden 4 Gruppen zugeordnet [Details] │
│ • Startzeiten 10:00 / 11:00 / 13:00 / 14:00 │
│ │
│ [Ablehnen] [Bearbeiten] [Freigeben] │
└───────────────────────────────────────────────────────────┘
- Must: the card states what will happen (from the dry-run), who requested it (agent and the person it acts for), when, and offers approve / reject; "Bearbeiten" where the proposal can be adjusted first.
- Rejecting asks for an optional reason (fed back to the agent).
- Approving runs the action through the same confirm/dry-run rules as a manual action of that class.
- Proposals expire with a visible date; stale proposals (data changed since) must be re-validated before approval.
- Everything is in the audit log, visible to the club admin.
11.3 Limits and policy messaging
- Show remaining budget only where it matters (before starting a large job): "Diese Übersetzung braucht etwa 40 von 320 verbleibenden KI-Credits diesen Monat."
- When the budget is exhausted: "Das KI-Budget deines Vereins für Oktober ist aufgebraucht. Du kannst den Text selbst schreiben; ab 1. Nov. ist die KI wieder verfügbar." — and the manual path still works.
- When the policy forbids something (e.g. AI on minors' data, off by default): explain without jargon: "Für Daten von Kindern ist die KI in eurem Verein ausgeschaltet."
- AI features are hidden, not disabled, when the club has AI off (§3.5).
- Members can read "Was die KI mit deinen Daten darf" (generated policy summary, Arch §7.2) from their profile.
12. Data display
12.1 Table or list?
Use a table when | Use a list when |
|---|---|
| Admin compares many items across several attributes | The person acts on one item at a time |
| Sorting/filtering by column is the task | The item is best recognised by name + one line of context |
| Desktop is the main context | Phone is the main context |
- Tables collapse to cards on mobile: the first column becomes the card title, 2–3 key columns as a
key-value list, the row action stays. - Max ~7 columns by default; more via "Spalten anpassen" (remembered per user).
- Row height: default
--control-h; dense mode (--control-h-sm) only on desktop admin tables, as a user choice.
12.2 Sorting and filtering defaults
- Every list has a sensible default sort shown in the UI: tasks by due date, members by family name, inquiries newest first, events by date ascending from today.
- Filters show as
chips above the list with a clear "Filter zurücksetzen". Active filters are in the URL. - The result count is always visible: "18 von 312 Mitgliedern".
- Pagination: cursor-based "Mehr laden" on mobile; page or infinite scroll with a stable position on desktop. Never silently cap results.
12.3 Belts and stripes
- Always rendered by
belt badgeandstripe chips, driven by the club's grading system frompackages/domain. No module draws its own belts (Audit #7: stripes in 3 visual styles). - A belt shows colour and name ("6. Kup · Grün"); stripes show colour and discipline label (at least as accessible name, visibly in editors).
- The stripe editor is the same component everywhere (Trainer student panel, exam day). Toggling a stripe is a safe/reversible action with
save status, offline-capable. - Readiness for the next test comes from grading, one computation, shown the same way in Trainer and Admin (Audit §4: two readiness computations).
12.4 Personal data minimisation
Contracts classify fields as personal, sensitive, minor (Arch §6). The UI shows only what the task needs.
- Attendance shows name, photo/avatar, belt — not birthdate, address, phone.
- Phone numbers and addresses appear on demand (tap to reveal / in the detail), and only for roles that need them.
- Minors: first name + initial on shared screens and TV; health and wellness data visible only to the athlete, their guardians and assigned coaches.
- Exports and print views are actions with their own permission, and say what they contain.
- Never show other families' data to a parent; "Meine Familie" shows only linked persons.
avatarfalls back to initials; never show a photo without consent.
13. Gamification and motivation
From the athlete app, which is the most-used app today (Audit §3; Arch §4 recognition is optional).
- Points never decrease when spending. Earned points (status, level) and spendable balance are separate numbers. Redeeming a reward reduces the balance, never the earned total (Audit §6 Recognition).
- Celebrate the athlete, not the admin. Celebrations (badge, streak milestone, belt passed) appear to the person who earned it, at the moment of the achievement, briefly, and respect reduced motion. Admin screens stay calm.
- Streaks (
streak strip) count planned sessions done, not calendar days, so rest days and holidays don't break them. A missed day shows as a gap, not a red failure. Offer a "Pause" for injury or holidays. - Respectful nudges: max one push per day, at a time the athlete chose, never during school hours for minors, easy to switch off. ✓ "Heute steht Kraft auf dem Plan — 25 Minuten." ✗ "Du hast schon 3 Tage nichts gemacht!"
- No public ranking of minors by default; leaderboards are opt-in per squad and show first names only.
- Progress visuals (
progress ring,progress segments) show progress towards the athlete's own goals, not comparisons with others. - Wellness checks are short (≤ 4 questions,
scale input), skippable, and the athlete sees who can read them.
14. Review checklist and anti-patterns
14.1 UX definition of done (copy into the PR description)
### UX definition of done
- [ ] Serves a named user/context from ux-guide §2; tested at 390px and 1440px
- [ ] Built only from packages/ui components; no raw hex, no inline style, no custom nav
- [ ] Real URL for every screen/tab/dialog; reload and back work; page title follows the route
- [ ] Hidden (not disabled) when not entitled / not enabled / flag off / no permission
- [ ] One primary action per view; list rows: one primary + ⋯ menu
- [ ] Each action has a risk class: safe → save status, reversible → undo toast,
irreversible → confirm with consequence + verb, external → dry-run preview
- [ ] Bulk actions act on the selection only and name the count
- [ ] All states designed: loading (skeleton), empty (explain + action), error (human text + traceId), offline, success
- [ ] Writes that happen on a phone go through the offline outbox and show save status
- [ ] Forms: visible labels, hints, error summary + focus to first error, correct type/inputmode/autocomplete,
autosave/unsaved-changes guard, input kept on failure
- [ ] All strings in the catalogue (DE + FR/IT/EN complete, FA where applicable); glossary terms; du/Sie variants
- [ ] Dates, numbers, CHF via Intl; no raw enums, IDs, codes or developer text
- [ ] Keyboard-only run-through done; focus visible; icon buttons named; not colour-only; axe clean
- [ ] Touch targets ≥ 44px; swipe/drag has a tap alternative; safe areas respected
- [ ] Shows only the personal data the task needs; minors' rules applied
- [ ] AI output marked as draft, accept/reject per block, sources shown; irreversible AI actions go through propose → approve
- [ ] Screenshots (mobile + desktop, light + dark) attached
14.2 Anti-patterns (seen in the audit — do not reintroduce)
| Anti-pattern | Where we saw it | Instead |
|---|---|---|
| Double navigation ("← Back to Hub" bar above the app's own sidebar) | club-hub app frame (Audit #1) | One app shell (§3.1) |
| Navigation hidden on phones | media/document/video embedded (#2) | Tab bar + "Mehr" |
| iframes, hash URLs, no back button | hub #/app/<id> (#14) | Real routes and deep links (§3.3) |
| Second login inside the app | invoice-hub (#13) | One session cookie |
Native alert / confirm / prompt | ~120 calls (§5) | toast, confirm dialog, dialog (lint-banned) |
Click-only divs | hub launcher tiles (§5) | button / link |
| Emoji as the only icon meaning | most apps; two apps sharing 🥋 (§5) | SVG icon + text label or accessible name |
| Toolbars with 10+ buttons | events (13), Newsletter-Tool (12) (#11) | One primary + ⋯ menu; move rare actions into detail views |
| Bulk action ignoring the selection | "Alle Entwürfe buchen" (#9) | Acts on selection, names the count (§5.3) |
| Irreversible action on one click | member ✅ creates Webling member; email send; instant print (#9) | Confirm + dry-run (§5.1) |
| Destructive next to primary | "Alle bestanden" next to "Abschliessen" (#11) | Separate; danger never default |
| Live writes lost on bad Wi-Fi | attendance, stripes, exam results (#4) | Offline outbox + save status |
| Whole screen turns red on one failed poll | Dojang TV (#4) | Keep last state, quiet reconnect note |
| Mixed languages on one screen; half-translated switcher | hub EN + apps DE (#5) | Complete catalogues before release |
| Free-text belts, multiple belt vocabularies | "9.Kup", "7 - Advanced Yellow" (#6) | Grading-system picker, belt badge |
| Same concept built several times | attendance ×4, stripes ×3 (#7) | One component, one module |
| Public form leaking member data | family check (#8) | No data echo (§6.6) |
| "Pflicht" not enforced; unlabeled inputs | public registration (#8, §5) | Enforced rules, visible labels |
| Search over the loaded page only; search box losing focus | document-hub, video-hub (#12) | Server-side search, stable focus |
| Copy-this-prompt-into-Claude loops | media-hub, Newsletter-Tool (#10) | Server AI with drafts (§11) |
| Debug text, phase notes, wrangler instructions in UI | several apps (§6) | Debug console (Arch §9.2) |
| Raw errors / browser error page | unreachable app (#14) | Human error + traceId (§7.3) |
| Module-specific primary colours | 7 accents, several failing contrast (§5) | One brand accent; modules differ by icon and name |
| Disabled upsell teasers | — (rule from the entitlement model) | Hide what the club didn't buy (§3.5) |
| Shaming nudges / spending reduces earned points | volunteer coins (§6) | §13 |
Changes to this guide go through a PR reviewed by E1 and E2. When a rule changes, update the living style guide example and, if it is lintable, the lint rule in the same PR.