Design guide

Club Hub — Design guide (visual language)

Contents
  1. 1. Principles
  2. 2. Brand and multi-club theming
  3. 3. Colour
  4. 4. Typography
  5. 5. Spacing and layout
  6. 6. Shape and elevation
  7. 7. Iconography
  8. 8. Motion
  9. 9. Component visual specs
  10. 10. Data visualisation basics
  11. 11. Theming and dark mode
  12. 12. Writing CSS for the library
  13. 13. Open questions

Status: draft v0.1 · Owner: E1 (UI platform) · Applies to: every Club Hub app (Member, Trainer, Admin, Operator, TV screens)

This guide defines how Club Hub looks: colour, type, space, shape, icons, motion and the visual spec of each component. How Club Hub behaves (flows, forms, actions, content, accessibility process) is in the UX guide. Don't repeat it here; link to it.

Source of truthWhere
Token valuespackages/ui/css/tokens.css (../packages/ui/css/tokens.css). If this guide and the file disagree, the file wins and this guide gets fixed.
Components (HTML + CSS + behaviour modules)packages/ui
Living style guide (every pattern and state, copyable markup)styleguide/index.html
Contrast pairs (automated)tests/contrast.test.js
Why we are doing thisUX-ANALYSIS.md §5 (5 visual families, 22–196 hex values per app, ~15 breakpoints, emoji icons, failing contrast)

Every value in this guide is a token name. If you need a value that has no token, don't hard-code it: raise it (see §13).


1. Principles

  1. Calm. Neutral surfaces, one accent, few borders. Colour means something (action, state, status); it is never decoration.
  2. Clear hierarchy. One page title, one primary action per view or row, numbers big when the number is the point (points, attendance, CHF). The athlete app's "Heute" screen is the model: a big stat tile, a clear "Start" action, everything else quieter.
  3. One accent. The accent (--color-accent) marks what you can do next and where you are. Not headings, not status, not large areas.
  4. Content over chrome. Navigation and frames stay quiet (neutral surfaces, muted text) so names, dates, belts and numbers stand out.
  5. Touch-first. Trainers use phones in a dojang; parents use phones on the sofa. Controls are --control-h (44px) by default; dense layouts are a desktop-only opt-in.
  6. Accessible by default. Contrast is built into the tokens and tested; focus is always visible; colour is never the only signal; components are real HTML.
  7. Club-brandable within limits. A club can make the product feel like its club (logo, accent) without breaking contrast, status meaning or layout.

What we're replacing: 5 visual families (slate/blue rail, white sidebar, Inter + indigo/green, chocolate #d2691e, violet), 3 accent colours inside one trainer's week, emoji as the icon system, muted text at 2.56:1. The violet family (class-hub, athlete-hub, Trainer App) was the most accessible and is the basis for these tokens; the dark theme comes from the athlete mobile app (--p-ink-*).


2. Brand and multi-club theming

Club Hub is one platform that many clubs use (tenancy: ARCHITECTURE.md §5). Two identities are on screen at once:

IdentityWhere it showsOwned by
Platform (Club Hub)Login and identity screens, operator console, "powered by" footer, product emails' frame, the default accentPlatform
Club (e.g. Taekwondo Bern)Club logo and name in the shell and club switcher, the accent colour, club name in page titles and emails, the club's grading coloursClub admin

A person can belong to several clubs (one login). The club switcher changes the logo, name and accent together, so the active club is always obvious.

2.1 What a club may customise

ItemHowValidation
LogoSVG or PNG, shown at a fixed height in the sidebar, top bar and club switcher; square mark for the avatar slotMust work on both --color-surface light and dark (upload a dark variant or a transparent mark)
Accent (light)Overrides --brand-accent, --brand-accent-hover, --brand-accent-text, --brand-on-accentMust pass the same checks as the defaults (see below). The operator console runs tests/contrast.test.js against the club's values and rejects failures.
Accent (dark)Overrides --brand-accent-dark, --brand-accent-dark-hover, --brand-accent-dark-textRequired whenever the light accent is set; checked against the dark surfaces (see §11)
Grading coloursOverride --belt-* and --stripe-* if the club's system differs--belt-edge must stay visible; names are always shown (§9.7)
Club name, short nameTextShort name ≤ 16 characters for the tab bar / switcher

A club sets both brand sets: the light tokens and their -dark counterparts. The theme blocks map --color-accent, --color-accent-hover and --color-accent-text to --brand-accent* in light mode and to --brand-accent-dark* in dark mode, so one club stylesheet on :root covers both themes and nothing overwrites it. --brand-on-accent is shared by both themes.

Accent checks (the operator console runs them for light and dark; all must pass):

  • --brand-on-accent on --brand-accent and on --brand-accent-dark ≥ 4.5:1 (button labels).
  • --brand-on-accent on --brand-accent-hover and on --brand-accent-dark-hover ≥ 4.5:1.
  • --brand-accent-text on the light --color-bg, --color-surface and --color-surface-2 ≥ 4.5:1; --brand-accent-dark-text on the dark ones ≥ 4.5:1 (links, active nav text).
  • --brand-accent / --brand-accent-dark against the matching --color-surface ≥ 3:1 (non-text: selected indicator, progress fill).
  • The accent must be distinguishable from the status hues (not a green/amber/red that could read as a status). The check rejects hues within the status ranges; borderline cases go to the operator.

2.2 What a club may not customise

  • Neutrals, surfaces, text colours, borders (--color-bg, --color-surface*, --color-text*, --color-border*).
  • Status colours (--color-success*, --color-warning*, --color-danger*, --color-info*). Green means success in every club.
  • Focus colour (--color-focus), type, spacing, radii, shadows, motion, layout.
  • Fonts. No web-font uploads (zero downloads; see §4).

2.3 Where the accent is used

UseToken
Primary button fill / hover--color-accent / --color-accent-hover, label --color-on-accent
Links, active nav item text and icon, selected tab text--color-accent-text
Active nav indicator (bar), selected tab underline, selected segment, checked checkbox/radio/switch, progress fill, current stepper step--color-accent
Selected row / selected chip background, "you" row in a ranking--color-accent-subtle with --color-accent-subtle-text

2.4 Where the accent is NOT used

  • Status. Never use the accent to say "ok", "warning" or "error". Use status tokens.
  • Large backgrounds. No accent-filled headers, hero banners, page backgrounds or full-bleed cards. The largest accent area is a primary button or a selected-state tint (--color-accent-subtle).
  • Headings and body text. Headings use --color-text.
  • Focus rings. Focus uses --color-focus (platform-owned), so a club accent can't make focus invisible.
  • Decoration. No accent borders on every card, no accent icons for their own sake. One accent moment per region.

3. Colour

3.1 Token tiers

TierPrefixUsed byRule
Primitives--p-* (--p-slate-500, --p-violet-700, --p-ink-900 …)tokens.css onlyNever referenced by components or pages.
Brand--brand-* (light) and --brand-*-dark (dark)Semantic accent tokens onlyThe only colours a club overrides (besides grading colours).
Semantic--color-*, --belt-*, --stripe-*Components, pagesNamed by role, not hue. Themes remap these.

Raw hex/rgb outside tokens.css fails lint. --belt-* and --stripe-* are domain tokens with literal values because they represent physical colours.

3.2 Semantic roles

TokenRoleRules
--color-bgPage background behind surfacesOnly on body / the app canvas.
--color-surfaceCards, lists, tables, sidebar, top bar, inputsThe default "thing" colour.
--color-surface-2Recessed areas: table header, segmented-control track, skeleton, progress track, hover on rows, code blocksDon't put --color-text-subtle on it (see 3.4).
--color-surface-raisedMenus, dialogs, sheets, popoversAlways paired with a shadow.
--color-inverse-bg / --color-inverse-textToastsInverted surface so transient messages stand out from both themes: dark slate in light mode, light slate in dark mode.
--color-borderDecorative dividers: card outlines, list separators, table rulesNot for form controls: too faint (fine, it's decorative).
--color-border-strongInput, select, checkbox, radio, switch outlines; secondary button outline≥ 3:1 on surfaces (WCAG 1.4.11).
--color-textBody text, headings, valuesDefault.
--color-text-mutedSecondary text: hints, descriptions, labels in key-value lists, inactive nav≥ 4.5:1 on bg, surface and surface-2.
--color-text-subtleTertiary metadata: timestamps, counts, captionsOnly on --color-surface / --color-bg. Forbidden on --color-surface-2 (4.34:1, fails AA; use --color-text-muted there). Never for anything a user must read to complete a task.
--color-text-disabledDisabled labels and valuesExempt from contrast (WCAG 1.4.3); disabled state must also be conveyed by the native attribute.
--color-overlayScrim behind dialogs and sheets (::backdrop)—
--color-accent*See §2.3—
--color-focusFocus ringPlatform-owned, never the brand accent.

3.3 Status colours

Four statuses, each with the same variant set:

StatusFill (solid bg, white text via --color-on-status)Subtle (tinted bg)Text (on subtle, or as coloured text)Typical use
Success--color-success--color-success-subtle--color-success-textPaid, present, passed, saved
Warning--color-warning--color-warning-subtle--color-warning-textDue soon, incomplete, needs review
Danger--color-danger (hover --color-danger-hover)--color-danger-subtle--color-danger-textError, overdue, destructive action, failed
Info--color-info--color-info-subtle--color-info-textNeutral notice, new, scheduled

Which variant:

  • Subtle + text is the default for badges, banners, inline messages and table cells. Calm, and readable in both themes.
  • Fill is for small, high-attention marks only: the danger button, a count dot, a toast's icon area, a progress bar that is over limit. Never a full card or row.
  • Text only (status text token on a surface) for an inline value like "CHF 120 overdue". Always with an icon or a word, never colour alone.

Neutral status (draft, archived, inactive) uses --color-surface-2 with --color-text-muted.

3.4 Contrast: verified pairs

Ratios computed from the token hex values (WCAG 2.x relative luminance). Thresholds: 4.5:1 normal text, 3:1 large text (≥ 24px, or ≥ 18.66px bold) and non-text UI. These pairs are asserted in tests/contrast.test.js.

Light theme

ForegroundBackgroundRatioUse
--color-text #0f172a--color-bg #f8fafc17.1Body
--color-text #0f172a--color-surface #ffffff17.9Body on cards
--color-text-muted #475569--color-bg #f8fafc7.2Secondary
--color-text-muted #475569--color-surface #ffffff7.6Secondary
--color-text-muted #475569--color-surface-2 #f1f5f96.9Table headers
--color-text-subtle #64748b--color-surface #ffffff4.8Metadata
--color-text-subtle #64748b--color-bg #f8fafc4.55Metadata (just passes)
--color-text-subtle #64748b--color-surface-2 #f1f5f94.34 — failsForbidden pair (rule, not a bug to fix)
--color-on-accent #fff--color-accent #6d28d97.1Primary button
--color-on-accent #fff--color-accent-hover #5b21b69.0Primary hover
--color-accent-text #6d28d9--color-bg / --color-surface6.8 / 7.1Links, active nav
--color-accent-subtle-text #5b21b6--color-accent-subtle #f5f3ff8.2Selected chip/row
--color-on-status #fff--color-success #15803d5.0Fill
--color-on-status #fff--color-warning #b453095.0Fill
--color-on-status #fff--color-danger #b91c1c6.5Danger button
--color-on-status #fff--color-danger-hover #7f1d1d10.0Danger hover
--color-on-status #fff--color-info #1d4ed86.7Fill
--color-*-textmatching --color-*-subtle8.7–9.5Badges, banners
--color-border-strong #64748b--color-surface #ffffff4.8 (≥ 3 non-text)Input outlines
--color-focus #7c3aed--color-surface #ffffff5.7 (≥ 3 non-text)Focus ring
--color-inverse-text #f8fafc--color-inverse-bg #0f172a17.1Toasts
--color-text-disabled #94a3b8--color-surface #ffffff2.6Exempt (disabled only)

Dark theme

ForegroundBackgroundRatioUse
--color-text #f1f5f9--color-bg #0b122017.1Body
--color-text #f1f5f9--color-surface #131b2e15.7Body on cards
--color-text-muted #94a3b8--color-bg #0b12207.3Secondary
--color-text-muted #94a3b8--color-surface #131b2e6.7Secondary
--color-text-muted #94a3b8--color-surface-2 #1a23406.0Table headers
--color-on-accent #fff--color-accent #7c3aed5.7Primary button
--color-on-accent #fff--color-accent-hover #6d28d97.1Primary hover
--color-accent-text #c4b5fd--color-bg / --color-surface / --color-surface-210.1 / 9.3 / 8.4Links, active nav
--color-accent-subtle-text #c4b5fd--color-accent-subtle #2e10658.3Selected chip/row
--color-success-text #86efac--color-success-subtle #052e1610.6Badges
--color-warning-text #fcd34d--color-warning-subtle #451a0310.4Badges
--color-danger-text #fca5a5--color-danger-subtle #450a0a8.5Badges
--color-info-text #93c5fd--color-info-subtle #1725548.2Badges
--color-on-status #fffstatus fills5.0–6.7Same as light
--color-on-status #fff--color-danger-hover #dc26264.8Danger hover
--color-border-strong #64748b--color-surface #131b2e3.6 (≥ 3 non-text)Input outlines
--color-focus #c4b5fd--color-surface #131b2e9.3Focus ring
--color-inverse-text #0f172a--color-inverse-bg #f1f5f916.3Toasts

The dark accent rows show the platform defaults (--brand-accent-dark*); a club's dark values must meet the same thresholds.

Rules:

  • New foreground/background pairs go into tests/contrast.test.js in the same PR.
  • Text on images (event photos, video thumbnails) always sits on a solid --color-surface band or a scrim using --color-overlay, never directly on the photo.
  • Placeholder text is not a label and uses --color-text-subtle (passes on --color-surface, which is the input background).

3.5 Never colour alone

Every colour-coded meaning has a second channel: a word, an icon, a shape or a position.

  • Status badge: icon + text ("Bezahlt", "Überfällig").
  • Belts: colour swatch + belt name ("Grüngurt", "6. Kup").
  • Stripes: labelled chips (§9.7), not dots.
  • Charts: direct value labels and pattern/position, not legend colour alone.
  • Form errors: --color-danger-text + alert-circle icon + message text + aria-invalid.
  • Streak/attendance: filled vs outlined + check icon, not green vs grey.

4. Typography

4.1 Fonts

  • --font-sans: the system stack (system-ui, Segoe UI, Roboto, …, plus emoji fonts for content emoji). Zero font downloads: fast first paint on club Wi-Fi, native rendering of umlauts, accents and ß, and familiar on every device.
  • --font-rtl: Vazirmatn, loaded only when lang="fa" (Persian). It's the only web font. Apply it with :lang(fa) in base.css.
  • --font-mono: code, IDs, IBAN/reference numbers where character confusion matters.

No other fonts. Club branding does not include fonts.

4.2 Type scale

RoleSize tokenWeightLine heightUse
Display--text-display (fluid 36→48px)--weight-bold--leading-tightHero stat ("145" points), TV/dojang screens, success screens. Max one per view.
Page title (h1)--text-2xl (24) mobile, --text-3xl (30) from lg--weight-bold--leading-tightOne per page, in the page header.
Section title (h2)--text-xl (20)--weight-semibold--leading-snugCard groups, page sections.
Card / subsection title (h3)--text-lg (18)--weight-semibold--leading-snugCard headers, dialog titles.
Body--text-md (16)--weight-regular--leading-bodyDefault text, inputs (16px also prevents iOS zoom on focus).
Small--text-sm (14)--weight-regular / --weight-medium--leading-snugHints, table cells in dense mode, button labels in sm size, badges, metadata.
Caption--text-xs (12)--weight-medium--leading-snugTab bar labels, chart axis labels, overline labels. Never for sentences or instructions.
Stat value--text-3xl / --text-4xl--weight-bold--leading-tightStat tile numbers.

Rules:

  • Headings follow document order (h1 → h2 → h3); size follows role, not the other way round. Use a class (e.g. .u-text-lg) to change size without breaking the outline.
  • Inputs and body are never smaller than --text-md on mobile.
  • Uppercase overlines ("WEITERE PLÄNE") are --text-xs, --weight-semibold, --color-text-muted, letter-spacing: var(--tracking-wide). --tracking-wide is for uppercase labels only, never mixed-case text. Use sparingly; German uppercase is long.

4.3 Weights

Four weights only: --weight-regular (text), --weight-medium (labels, nav, buttons), --weight-semibold (headings, emphasis in tables), --weight-bold (page titles, numbers). Don't use bold for emphasis in running text; use <strong> which maps to --weight-semibold.

4.4 Numerals

  • font-variant-numeric: tabular-nums for anything compared or aligned: scores, points, rankings, CHF amounts, times, dates in tables, counters, timers.
  • Money is right-aligned in tables, formatted with Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }). Never hand-format.
  • Stat values use tabular-nums so they don't jitter when they count up.

4.5 Line length and German

  • Running text is capped at --readable-max (40rem ≈ 65–75 characters). Forms, explanations, emails and empty states use it; tables and dashboards don't.
  • German has long compounds ("Mitgliederbeitragsrechnung", "Gürtelprüfungsanmeldung"). Set lang correctly on <html> (and on any element in another language) and use hyphens: auto on text blocks, card titles and table cells. overflow-wrap: anywhere as a last resort on narrow containers (chips, tab labels, buttons).
  • Never truncate a button label or a belt name. Truncate (with full text in a title/accessible name) only names in dense lists.
  • Lay out for German first: it's ~30% longer than English. French is also long; Persian is RTL.

5. Spacing and layout

5.1 Spacing scale

4px base: --space-1 (4) · --space-2 (8) · --space-3 (12) · --space-4 (16) · --space-5 (20) · --space-6 (24) · --space-8 (32) · --space-10 (40) · --space-12 (48) · --space-16 (64).

RelationshipToken
Icon to its label; badge padding inline--space-1 / --space-2
Label to input; items inside a cluster; chip gaps--space-2
Fields in a form; list row padding (block)--space-3 / --space-4
Card padding (mobile / desktop)--space-4 / --space-6
Between cards; page gutter (mobile)--space-4
Between page sections; page gutter (desktop)--space-8
Page top/bottom breathing room, empty states--space-12 / --space-16

Rule of proximity: space inside a group is smaller than space between groups. If two things are related and separated by more than --space-4, the layout is wrong.

5.2 Density

ModeControl heightWhere
Touch (default)--control-h (44px); minimum target --tap-min (44px)Everywhere, all breakpoints
Dense--control-h-sm (36px)Desktop data tables and toolbars only, from lg up, opt-in via data-density="dense" on the table/region. Never on mobile, never for primary actions.

Even in dense mode, the hit area of icon buttons stays ≥ 24px (WCAG 2.5.8) and spacing between targets prevents mis-taps.

5.3 Breakpoints and mobile-first

Write base styles for the smallest screen, then add min-width queries:

NameMin widthTypical change
(base)0Single column, tab bar, full-width buttons in forms, bottom sheets
sm480pxTwo-up stat tiles, inline button groups
md768pxTwo-column forms where sensible, dialogs instead of sheets for short tasks
lg1024pxSidebar replaces tab bar; dense tables allowed; three-column grids
xl1280pxWider content area, side panels (detail beside list)

Only these four. Prefer container queries for components (a card that changes layout based on its own width) over viewport queries. Custom properties can't be used inside media queries, so the four values are written literally in layout.css only; lint flags other breakpoint values.

5.4 Containers and grid

  • Page content is centred and capped at --content-max (72rem). Gutters: --space-4 below md, --space-6 at md, --space-8 from lg.
  • Text-heavy content (forms, settings, articles) is capped at --readable-max.
  • Layout primitives (in layout.css):
    • .stack: vertical flow with a gap (--stack-gap, default --space-4).
    • .cluster: horizontal, wrapping group (buttons, chips, meta). Gap --space-2 by default.
    • .grid: auto-fit responsive grid (--grid-min sets the minimum column width).
    • .center: max-width container with gutters. These take their gap/min through custom properties: style="--stack-gap: var(--space-6)".
  • Use gap, not margins between siblings. Components have no outer margin; the parent layout spaces them.

5.5 App shell anatomy

Mobile (< lg)                          Desktop (≥ lg)
┌──────────────────────────┐           ┌──────────┬──────────────────────────────┐
│ safe-top                 │           │ Club     │ Top bar (--topbar-h)         │
│ Top bar (--topbar-h)     │           │ switcher ├──────────────────────────────┤
├──────────────────────────┤           │          │ Page header                  │
│ Page header              │           │ Sidebar  │                              │
│ Content (scrolls)        │           │ nav      │ Content (max --content-max)  │
│                          │           │ (--side- │                              │
├──────────────────────────┤           │ bar-w)   │                              │
│ Tab bar (--tabbar-h)     │           │          │                              │
│ safe-bottom              │           │ User     │                              │
└──────────────────────────┘           └──────────┴──────────────────────────────┘
  • Sidebar (lg+): --sidebar-w (248px), --color-surface, --color-border on the inline-end edge. Club switcher at the top, nav groups, user/account at the bottom. Active item: --color-accent-subtle background, --color-accent-text label and icon, 3px --color-accent indicator on the inline-start edge.
  • Top bar: --topbar-h (56px) + --safe-top padding, --color-surface, --color-border bottom. Holds: back link or menu button, page context, search, notifications, account. Sticky (--z-nav).
  • Tab bar (< lg): --tabbar-h (64px) + --safe-bottom padding, max 5 items, icon (24) over caption label (--text-xs, --weight-medium). Active: --color-accent-text + indicator. Inactive: --color-text-muted, never "disabled grey". Labels are always shown.
  • Content gets padding-block-end: calc(var(--tabbar-h) + var(--safe-bottom) + var(--space-4)) on mobile so nothing hides under the tab bar (the athlete reference screen currently overlaps a card; don't repeat that).
  • Use dvh units for full-height layouts (keeps the Trainer App's good behaviour).
  • TV / dojang screens use no shell, --text-display headings and the dark theme.

6. Shape and elevation

6.1 Radii

TokenUse
--radius-sm (6)Checkboxes, badges, chips (rectangular), small tags, table cell highlights, tooltip
--radius-md (10)Buttons, inputs, selects, segmented control, list rows inside cards, menus, toasts, alerts
--radius-lg (16)Cards, stat tiles, dialogs, bottom sheet top corners, empty-state panels
--radius-fullAvatars, switches, pills (status badge, filter chips), progress bars, streak dots, count badges

Nested radii: an inner element's radius is the outer radius minus the padding, or --radius-md inside --radius-lg. Never a larger radius inside a smaller one.

6.2 Borders vs shadows

  • In-flow content is flat: cards, tiles, lists, tables sit on --color-bg with a --border-width --color-border outline and no shadow. Hierarchy comes from spacing and type, not depth.
  • Floating content is elevated: anything above the page (menus, popovers, dialogs, sheets) uses --color-surface-raised + a shadow, and may drop the border in light mode. Toasts are the exception: --color-inverse-bg + a shadow.
  • Interactive outlines (inputs, secondary buttons) use --color-border-strong.

6.3 Elevation levels

LevelTokenUsed for
0nonePage, cards, lists, tables, stat tiles, sidebar
1--shadow-smSticky top bar / table header while scrolled; draggable item at rest; pressed-state lift is not used
2--shadow-mdMenus, popovers, combobox listbox, date picker, toasts, dragged item
3--shadow-lgDialogs, bottom sheets, side panels over content

Stacking: --z-sticky (sticky headers), --z-nav (top bar, tab bar, sidebar), --z-popover (non-top-layer popovers), --z-toast (toast region). Dialogs and popover elements use the native top layer, so they need no z-index.


7. Iconography

  • One SVG sprite (packages/ui/icons/sprite.svg), symbols referenced with <svg class="icon"><use href="…#check"/></svg>. Icons are copied in as SVG (Lucide-style), not a library.
  • Style: 24×24 grid, 2px stroke, round caps and joins, no fill, stroke="currentColor" so icons take the text colour. Optical size consistent across the set; new icons must match.
  • Sizes: --icon-sm (16: inline with --text-sm, badges, dense tables), --icon-md (20: buttons, inputs, list rows; the default), --icon-lg (24: tab bar, top bar, icon buttons; empty states use it inside a larger tinted circle). Set via data-size="sm|md|lg" on .icon. Sizes in this guide written as "icon 16/20/24" mean these tokens.
  • Pairing: an icon is always paired with visible text, except an icon-only button, which must have an accessible name (aria-label or visually hidden text) and a tooltip on pointer devices. Decorative icons get aria-hidden="true".
  • Colour: inherits currentColor. Status icons use the status text token. Don't colour icons with the accent unless they are part of an active/selected control.
  • Direction: chevrons and arrows that mean "back/forward" flip in RTL (:dir(rtl) .icon[data-flip] { scale: -1 1 }). Icons that depict objects (clock, upload) don't flip.
  • Emoji: allowed only as content: wellness scale anchors (😴 → ⭐), reactions, user-written messages, notification text. Never as UI icons (nav, buttons, section headers, status). The current "🏠 Heute / 📋 Pläne / 🥋" usage is replaced by sprite icons.

Initial icon set:

GroupIcons
Actionscheck, x, plus, minus, edit, trash, upload, download, printer, refresh, search, filter, external-link, log-out
Navigationchevron-left, chevron-right, chevron-up, chevron-down, arrow-left, menu, more, home
Objectscalendar, clock, user, users, settings, bell, mail, star, flame
Statusinfo, alert-triangle, alert-circle, check-circle, wifi-off
Themesun, moon

Status mapping: success → check-circle, warning → alert-triangle, danger → alert-circle, info → info, offline → wifi-off.


8. Motion

TokenValueUse
--duration-fast120msHover/press colour changes, checkbox tick, switch thumb, focus ring appear
--duration-base200msMenus, popovers, toasts in, tab indicator, accordion
--duration-slow320msBottom sheet, dialog, side panel, progress fill changes
--ease-standardcubic-bezier(0.2, 0, 0, 1)Entering and moving
--ease-exitcubic-bezier(0.4, 0, 1, 1)Leaving (use a shorter duration than the enter)

What animates:

  • Feedback: press states, tick/untick, save status, optimistic row updates (fade the change in), undo toast.
  • Overlays: sheets slide up from the bottom; dialogs fade + scale from 0.98; menus fade + 4px translate; toasts slide in from the edge.
  • Progress: bar/ring fill transitions to the new value; stat value may count up once on first view.

What doesn't:

  • Page navigation (no route transitions), layout shifts, background parallax, looping/decorative animation, skeleton shimmer beyond a subtle opacity pulse.
  • Nothing blocks input while animating; no animation over --duration-slow.

Reduced motion: tokens.css sets all durations to 0ms under prefers-reduced-motion: reduce. Always use the duration tokens (never literal ms) so this works automatically. Swipe gestures still work; their release animation is instant. Spinners keep spinning (they convey state) but slower is not required.


9. Component visual specs

Shared state rules (apply to every interactive component):

StateSelectorVisual
Hover:hover inside @media (hover: hover)Background one step: accent → --color-accent-hover; neutral → --color-surface-2
Active / pressed:activeSame as hover plus 1px translate or no change; never shrink
Focus:focus-visibleoutline: var(--focus-width) solid var(--color-focus) (3px) with outline-offset: var(--focus-offset) (2px). Never removed. On filled controls the offset keeps a gap of the surface colour.
Disabled:disabled, [aria-disabled="true"]--color-text-disabled label, --color-surface-2 fill, no shadow, cursor: not-allowed. Prefer keeping actions enabled and explaining (see UX guide).
Loading[aria-busy="true"]Spinner replaces the leading icon (label stays, width doesn't change), pointer events off
Invalid[aria-invalid="true"]--color-danger border, error text in --color-danger-text with alert-circle
Selected / current[aria-selected="true"], [aria-current], :checked, [aria-pressed="true"]Accent per §2.3

All targets ≥ --tap-min. All styling with logical properties (padding-inline, margin-block-start, inset-inline-end) so RTL works.

9.1 Actions

Button .button · variants data-variant="primary|secondary|ghost|danger" · sizes data-size="sm|md|lg" (default md) · icon-only data-icon-only

VariantFillLabelBorderUse
primary--color-accent (hover --color-accent-hover)--color-on-accentnoneThe one main action of a view, dialog or row
secondary--color-surface (hover --color-surface-2)--color-text--color-border-strongOther actions, "Cancel"
ghosttransparent (hover --color-surface-2)--color-accent-text or --color-textnoneLow-emphasis, toolbars, inline "Edit"
danger--color-danger (hover --color-danger-hover)--color-on-statusnoneDestructive confirm only (in a confirm dialog), not as a resting button on a page
  • Anatomy: [icon 20] label [trailing icon 16]. Gap --space-2, padding-inline --space-4, radius --radius-md, --weight-medium, --text-md (sm: --text-sm).
  • Heights: md = --control-h; sm = --control-h-sm (desktop dense regions only); lg = --control-h-lg (52px) with --text-lg and padding-inline --space-6, for one prominent single action per screen (e.g. "Training starten", success screens, TV/dojang screens).
  • Icon-only: square --control-h, icon 24, accessible name required, tooltip on hover.
  • Mobile forms: primary submit is full width at the end of the form (or sticky above the tab bar for long forms).
  • Do: one primary per region; verb labels ("Speichern", "Anwesenheit erfassen"). Don't: two primaries side by side; disabled primary as the only hint something's missing; links styled as buttons for navigation (use a link).

9.2 Forms

Behaviour (validation timing, error summary flow, autosave) is in the UX guide. Visual spec:

Field .field wraps label, control, hint, error: .field__label (--text-sm, --weight-medium, --color-text), .field__hint (--text-sm, --color-text-muted), .field__error (--text-sm, --color-danger-text, alert-circle 16). Gap --space-2. Optional fields say "(optional)" in the label; required fields aren't starred.

Input / select / textarea .input, .select, .textarea: height --control-h, --color-surface fill, --border-width --color-border-strong border, --radius-md, padding-inline --space-3, --text-md. Placeholder --color-text-subtle. Hover: border --color-text-muted. Focus: focus ring (border stays). Invalid: border --color-danger. Disabled: --color-surface-2 fill, --color-text-disabled. Read-only: no border, --color-surface-2 fill. Select uses chevron-down at inline-end. Textarea min 3 rows, resizes vertically.

Checkbox / radio .checkbox, .radio: 20px box (--radius-sm / --radius-full), --color-border-strong; checked: --color-accent fill, --color-on-accent mark. Whole row (box + label) is the 44px target.

Switch .switch: track 44×24 --radius-full, off --color-border-strong track with --color-surface thumb; on --color-accent track. Use only for settings that apply immediately; label states the setting, not "on/off".

Segmented control .segmented: track --color-surface-2, --radius-md, padding --space-1; segments --control-h minus padding; selected segment --color-surface (light) / --color-surface-raised (dark) with --shadow-sm and --color-text, unselected --color-text-muted. 2–4 options, short labels. For view switches and small single choices.

Scale input .scale (wellness check, RPE): a radio group rendered as equal cells in a row; cell = --tap-min high, --radius-md, --color-surface-2 with --color-border; selected --color-accent with --color-on-accent. Anchor labels at both ends (text, plus emoji as content allowed here). Numbers or labels are visible on cells; selection is never colour-only (selected cell also shows a check or the value).

Search field .search: input with leading search icon 20, clear x button when filled, --radius-full allowed in toolbars. type="search", --control-h.

Error summary .error-summary: at top of the form, --color-danger-subtle background, 4px --color-danger border on inline-start, --radius-md, title --weight-semibold --color-danger-text, list of links to fields. Receives focus on submit.

9.3 Data display

Card .card: --color-surface, --border-width --color-border, --radius-lg, padding --space-4 (≥ md: --space-6), no shadow. Parts: .card__header (h3 + optional action), .card__body, .card__footer (actions, --color-border top). A whole-card link uses one real <a> stretched over the card; hover --color-surface-2 border --color-border-strong. Don't nest cards in cards; use list rows or --color-surface-2 panels.

Stat tile .stat: card variant for one number. Anatomy: label (--text-sm, --color-text-muted, optional icon 16) · value (--text-3xl, --weight-bold, tabular-nums) · context line (--text-sm, e.g. "Silber · Platz 2", or a trend with icon + text). Optional progress bar at the bottom. Tiles in a .grid (--grid-min ≈ 10rem). The athlete "Heute" tiles are the reference, but without full-saturation coloured fills: tile background stays --color-surface; colour goes on the icon or a progress fill only. One hero tile per screen may use --color-accent-subtle.

List + list row .list, .list__row: rows on --color-surface separated by --color-border (or a list of separate rounded rows on mobile, --radius-md, gap --space-2). Row min height --tap-min (two-line rows ~64px). Anatomy: leading (avatar/icon/belt badge) · primary text (--weight-medium) + secondary line (--text-sm, --color-text-muted) · trailing (value, badge, chevron-right if navigable, or one action + more menu). Current/you row: --color-accent-subtle + --color-accent border (as in the ranking reference). Swipe actions reveal status-coloured backgrounds with icon + label.

Table .table: --color-surface, header row --color-surface-2 with --text-sm --weight-semibold --color-text-muted, row rule --color-border, cell padding --space-3/--space-4. Numbers right-aligned, tabular-nums. Row hover --color-surface-2. Selected row --color-accent-subtle. Sticky header gets --shadow-sm when scrolled. Dense via data-density="dense" (≥ lg, rows --control-h-sm). Below md, tables become stacked cards or scroll horizontally inside a labelled region with the first column sticky. One primary action per row plus a more menu.

Status badge .badge data-status="success|warning|danger|info|neutral": pill (--radius-full), subtle background + text token, --text-sm --weight-medium, padding --space-1 --space-2, leading status icon 16. Text always present.

Chip .chip: --radius-full, --color-surface-2, --color-text, --text-sm; as filter: aria-pressed, selected = --color-accent-subtle + --color-accent-subtle-text + check icon; removable chips have an x button with a name. Min height 32px visual, 44px hit area via padding/pseudo-element.

Avatar .avatar sizes 24/32/40/64 via data-size: --radius-full, image or initials on --color-surface-2 with --color-text-muted, --weight-semibold. Club avatars (logo) use --radius-md. Decorative unless it's the only identification.

Key-value list .kv (<dl>): key --text-sm --color-text-muted, value --text-md --color-text. Stacked on mobile, two columns (key ~ 1/3) from md. Row gap --space-3.

Empty state .empty: centred in its container, max --readable-max, 24px icon in a 48px --color-surface-2 circle, title (--text-lg --weight-semibold), one sentence (--color-text-muted), one primary or secondary action. No illustrations, no emoji.

Skeleton .skeleton: --color-surface-2 blocks matching final layout (text lines at --text-md height, --radius-sm; avatars round). Opacity pulse at --duration-slow-based timing, off under reduced motion. Container has aria-busy="true". Use only for loads > ~300ms.

9.4 Feedback

Alert / banner .alert data-status: subtle background, 4px status-fill border on inline-start, --radius-md, padding --space-3 --space-4, status icon 20 + title (--weight-semibold, status text token) + body (--color-text) + optional action and dismiss x. Page-level banners (offline, lock, impersonation) sit below the top bar, full width, no radius. Offline banner uses info with wifi-off.

Toast .toast: bottom-centre on mobile (above the tab bar + --safe-bottom), bottom-inline-end on desktop. --color-inverse-bg with --color-inverse-text, --shadow-md, --radius-md, max width ~24rem, status icon + one line + optional action ("Rückgängig") as a text button in --color-inverse-text (underlined, --weight-semibold). Don't use --color-accent-text or status text tokens on the inverse surface; they aren't tested there. --z-toast. Text short; never the only place an error is reported.

Save status .save-status: inline text + icon near the form title: "Gespeichert" (check, --color-text-muted), "Speichert…" (spinner), "Nicht gespeichert" (alert-circle, --color-danger-text), "Offline — wird später gesendet" (wifi-off, --color-warning-text). --text-sm, aria-live="polite".

Spinner .spinner: --icon-sm / --icon-md / --icon-lg, 2px stroke ring in currentColor with a 25% currentColor track, rotates linearly. Always with a label (visible or visually hidden). Prefer skeletons for content, spinners for actions.

Progress bar .progress (<progress> or role="progressbar"): track --color-surface-2, fill --color-accent (or a status fill when it means status, e.g. over budget = --color-danger), height 8px, --radius-full. Value label visible beside or above ("1/3 diese Woche").

9.5 Overlays

Dialog .dialog (native <dialog>): --color-surface-raised, --shadow-lg, --radius-lg, width min(32rem, 100% - 2 × --space-4), padding --space-6. Parts: .dialog__header (h2 at --text-lg + close x icon button), .dialog__body (scrolls), .dialog__footer (actions at inline-end: secondary then primary; full-width stacked on mobile, primary first visually at the bottom). ::backdrop = --color-overlay. Enter: fade + scale 0.98 at --duration-slow.

Confirm dialog: a small dialog: title as a question ("Mitglied löschen?"), one sentence on the consequence, buttons "Abbrechen" (secondary) and the specific verb ("Löschen", danger). Use undo instead where possible (UX guide).

Bottom sheet .sheet (mobile, < md): docked to the bottom, --radius-lg on top corners, --color-surface-raised, --shadow-lg, drag handle 36×4 --radius-full --color-border-strong, padding-bottom includes --safe-bottom, max height 90dvh. Slides up at --duration-slow --ease-standard. From md, the same content opens as a dialog.

Menu .menu (popover): --color-surface-raised, --shadow-md, --radius-md, padding --space-1, min width 12rem. Items --tap-min high (36px in dense desktop contexts), icon 20 + label, hover/focus --color-surface-2; destructive item uses --color-danger-text and its icon, separated by a --color-border divider at the end.

9.6 Navigation

App shell: §5.5.

Page header .page-header: back link (optional) · h1 · optional subtitle (--color-text-muted) · actions cluster (primary on inline-end; on mobile the primary moves to a full-width button below the title or a sticky footer, secondary actions into more). Bottom margin --space-6.

Tabs .tabs (role="tablist"): row of tabs on a --color-border baseline; tab --tap-min high, --weight-medium, inactive --color-text-muted, selected --color-accent-text with a 2px --color-accent underline. Scrolls horizontally on overflow (no wrapping), with fade edges. Max ~6 tabs; use page navigation beyond that.

Back link .back-link: arrow-left (flips in RTL) + parent page name ("Mitglieder"), not "Zurück". --color-accent-text, --text-sm, 44px hit area.

Stepper .stepper: numbered steps in a row (≥ md) or "Schritt 2 von 3" + progress bar (mobile). Done: check in --color-accent circle; current: --color-accent ring + --weight-semibold label, aria-current="step"; upcoming: --color-border-strong ring, --color-text-muted.

Club switcher .club-switcher: in the sidebar top / top bar: club logo (avatar --radius-md, 32) + club name (--weight-semibold) + chevron-down. Opens a menu listing clubs with logos, current marked with check and aria-current. Hidden (logo + name shown statically) when the person has one club.

9.7 Domain components

Belt badge .belt (style="--belt-color: var(--belt-green)"):

  • Anatomy: a horizontal belt swatch (24×8 at md, --radius-sm) + belt name text ("Grüngurt") + optional grade ("6. Kup").
  • Colour and name always together. A swatch alone is never enough.
  • Every swatch gets a 1px --belt-edge outline, drawn by the component as an inset box-shadow (no extra markup, no layout shift). It matters most for --belt-white (1.05:1 on white), --belt-yellow (1.5:1 on white) and --belt-black (1.06:1 on the dark background).
  • Two-colour belts (e.g. yellow with green tip) set a second component variable on the element: style="--belt-color: var(--belt-yellow); --belt-tip: var(--belt-green)". --belt-tip is a component-level variable, not a global token; when it's unset the swatch is one colour.
  • Sizes: sm (list rows), md (cards), lg (profile, exam result, TV screen).

Stripe chips .stripes: one chip per stripe type, labelled with the name: Form, Kyorugi, 5 Tenets, Hosinsul, Theorie.

  • Chip: --radius-sm, --text-sm, a small colour square (--stripe-red, --stripe-blue, --stripe-green, --stripe-yellow, --stripe-black, with --belt-edge outline) + label.
  • Earned: --color-surface with --color-border-strong border, check icon, --color-text. Not earned: dashed --color-border outline, --color-text-muted, no check. State is also in the accessible name ("Form: erhalten").
  • Never unlabelled colour dots (the class-hub problem). In the stripe editor each chip is a toggle button (aria-pressed).

Streak strip .streak: row of day cells (7 per week), each a 32px circle (44px hit area if interactive) with the weekday caption (--text-xs) below. Done: --color-success fill + white check. Planned/today: --color-accent 2px ring. Missed/empty: --color-surface-2. Leading summary: flame icon + count ("2 Wochen") + trailing "1/3". Meaning is carried by icon and text, not colour alone.

Progress segments .segments (sets, weekly sessions): N equal segments, gap --space-1, height 8px, --radius-full; done --color-accent, current --color-accent at reduced opacity or striped, todo --color-surface-2. Text label "2 von 4 Sätzen". Max ~10 segments; beyond that use a progress bar.

Progress ring .ring: SVG circle, stroke 4 (sizes 40/56/80), track --color-surface-2, fill --color-accent (or status), value text centred ("50%", tabular-nums, --weight-semibold). Accessible as role="img" with label or role="progressbar".

Stat tile: §9.3.


10. Data visualisation basics

Club Hub needs small, honest charts: attendance, points, payment status, training load. No chart library at first; SVG and CSS.

  • Forms in order of preference: a single number (stat tile) → progress bar/ring/segments → horizontal bar list (labels on the left, value at the end) → simple column chart (weekly attendance). No pies, 3D, gauges or dual axes.
  • Colour: one series = --color-accent. Comparisons = --color-accent vs neutral (--color-text-subtle or --color-border-strong). Status meaning (paid/overdue) = status fills. More than two categories: use position and labels first; categorical palettes are an open question.
  • Tracks and gridlines: --color-surface-2 tracks, --color-border gridlines, axis labels --text-xs --color-text-muted.
  • Label values directly (on or beside the bar), with tabular-nums. Legends only when direct labels don't fit.
  • Every chart has a text alternative: a caption with the key fact ("8 von 12 Trainings besucht") and, for real data, a table toggle.
  • Bars start at zero. Show units (CHF, %, Min.).
  • Non-text contrast: marks ≥ 3:1 against the surface (--color-accent on --color-surface passes in both themes).

11. Theming and dark mode

  • Set on <html>: data-theme="light", data-theme="dark", or no attribute = follow the OS (prefers-color-scheme). Default for new users: OS. TV/dojang screens force dark. The user's choice is stored in their profile and applied before first paint (inline script that sets the attribute only).
  • Components never test the theme; they use semantic tokens, and themes remap them. If a component needs a theme-specific value, that's a missing semantic token.

Dark theme rules (derived from the athlete app):

  • Surfaces step up in lightness: --color-bg (--p-ink-900) < --color-surface (--p-ink-800) < --color-surface-2 / --color-surface-raised (--p-ink-700). Higher = closer = lighter. No pure black.
  • Borders are translucent white (--color-border = white at 9%), so they work on every surface step. --color-border-strong stays --p-slate-500 for controls (3.6:1 on surface).
  • Shadows are stronger (black at 40–60%) because they're less visible on dark; elevated surfaces also get the lighter --color-surface-raised.
  • Accent comes from the dark brand tokens. The dark blocks map --color-accent → --brand-accent-dark, --color-accent-hover → --brand-accent-dark-hover, --color-accent-text → --brand-accent-dark-text; they never redefine --brand-*. Defaults: the fill is lighter (--p-violet-600) so it doesn't sink into the background, and accent text is much lighter (--p-violet-300) to keep ≥ 4.5:1. A club supplies its own dark values next to its light ones (§2.1). Focus becomes --p-violet-300 (platform-owned).
  • Toasts invert: --color-inverse-bg turns light slate in dark mode.
  • Status subtles are deep tints (*-950) with light text (*-300); status fills stay the same as light.
  • Avoid large saturated areas (the athlete app's orange and teal tiles); in dark mode they glare. Saturation goes on small elements.
  • Images and logos: provide dark variants or put them on a --color-surface plate; don't invert.
  • Check every new screen in both themes in the style guide before merging.

12. Writing CSS for the library

12.1 Files and layers

packages/ui/css/
  index.css        @layer reset, tokens, base, layout, components, utilities;  + @imports in that order
  reset.css        @layer reset        modern reset (box-sizing, margins, media defaults)
  tokens.css       @layer tokens       all custom properties (this guide's values)
  base.css         @layer base         element defaults: body, headings, links, :focus-visible, ::selection, :lang(fa)
  layout.css       @layer layout       .stack .cluster .grid .center, app shell, breakpoints
  components/      @layer components   one file per component: button.css, field.css, card.css, belt.css …
  utilities.css    @layer utilities    u-* helpers (u-visually-hidden, u-text-muted, u-tabular …)

Layer order is fixed: reset, tokens, base, layout, components, utilities. Later layers win regardless of specificity, so utilities beat components without !important.

12.2 Conventions

TopicRuleExample
Component classPlain name.button, .field, .card, .belt
Parts__.field__hint, .card__header, .dialog__footer
Variantsdata-variant<button class="button" data-variant="primary">
Sizesdata-sizedata-size="sm"
Densitydata-density<table class="table" data-density="dense">
StateNative / ARIA attributes, not classes:disabled, [aria-invalid="true"], [aria-busy="true"], [aria-current="page"], [aria-expanded], [aria-pressed], :checked
Behaviour hookdata-behavior (JS only, never styled)data-behavior="dialog"
Layout primitives.stack, .cluster, .grid, .center—
Utilitiesu- prefix, single purpose.u-visually-hidden, .u-tabular
ColoursTokens only. Raw hex/rgb/hsl outside tokens.css fails lintcolor: var(--color-text-muted)
Inline styleForbidden, except setting a custom propertystyle="--belt-color: var(--belt-green)", style="--progress: 0.33"
DirectionLogical properties only (margin-inline-start, inset-inline-end, border-start-start-radius, text-align: start)—
Focus:focus-visible with --color-focus; never outline: none without a replacement—
HoverInside @media (hover: hover)—
SpecificityOne class + attributes; no IDs, no element chains, no !important (except u-visually-hidden)—
DOMLight DOM only; custom elements have no shadow root, so tokens and forms work normally—
Unitsrem for type and spacing (via tokens), px only for borders and hairlines, dvh for full-height—

12.3 Adding a component: checklist

  1. Need: no existing component or variant covers it (check the style guide). Name it plainly.
  2. Markup first: semantic HTML that works without JS and without CSS (real <button>, <a>, <label for>, <fieldset>).
  3. Tokens only: no raw values; if a value is missing, propose a token (don't inline it).
  4. All states: default, hover, active, focus-visible, disabled, loading (aria-busy), invalid (aria-invalid), selected/current, empty, long content (German compound, 3× length), RTL.
  5. Both themes: light and dark checked in the style guide.
  6. RTL: logical properties; directional icons flip; test with dir="rtl" lang="fa".
  7. Touch: every target ≥ --tap-min (44px); dense variant only for desktop tables.
  8. Contrast: any new foreground/background pair added to tests/contrast.test.js; non-text parts ≥ 3:1.
  9. Motion: duration tokens only; works with reduced motion.
  10. Component module with data contract: packages/ui/components/<id>.js exporting spec (description, JSON-Schema data with a description for every field, examples, markup/parts/states, CSS variables, behaviour API and events, accessibility, do/don't) and a pure render(data). See packages/ui/components/SPEC-FORMAT.md. node tools/build-reference.mjs generates its reference page (live examples, data table, markup, events), components.json and llms.txt. The contract tests check the schema, examples and escaping.
  11. Tests: npm test (contract tests) passes; axe passes on the reference page; visual regression snapshots in both themes.
  12. Docs: if it adds a visual rule, update this guide; if it adds a behaviour rule, update the UX guide.

13. Open questions

Missing or questionable tokens (not invented above; until decided, components must not hard-code these values):

  1. Overlay sizes: dialog width (--dialog-w), toast max width, menu min width, sheet max height. The values in §9 (32rem, ~24rem, 12rem, 90dvh) are proposals until tokenised.
  2. Text on a belt colour: if a belt colour is ever used as a fill behind text, it needs an on-colour (yellow needs dark text: --p-slate-900 on --belt-yellow ≈ 11.7:1; white on --belt-green is only 3.3:1). Today belts are swatches next to text, so this isn't needed yet.
  3. Data-viz categorical palette for charts with more than two series (not covered by accent + neutral + status).
  4. Disabled fill: disabled controls use --color-surface-2; confirm or add --color-disabled-bg.
  5. Skeleton/pulse timing and toast auto-dismiss durations (behaviour; may belong in the UX guide).
  6. Dark on-accent: there is no --brand-on-accent-dark; --brand-on-accent serves both themes. Fine for white, but a club with a very light dark-mode accent would need dark label text in dark mode only.
  7. Dark theme block is duplicated (explicit data-theme="dark" and OS media query). Fine for now; any change must be made in both places (a lint check could diff them).
  8. Elevated tile colours in the athlete app (orange "Training", teal "Wellness" tiles) have no token equivalent by design; confirm with product that the rewrite drops them in favour of neutral tiles with coloured icons/progress.