Club Hub — Design guide (visual language)
Contents
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 truth | Where |
|---|---|
| Token values | packages/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 this | UX-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
- Calm. Neutral surfaces, one accent, few borders. Colour means something (action, state, status); it is never decoration.
- 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.
- One accent. The accent (
--color-accent) marks what you can do next and where you are. Not headings, not status, not large areas. - Content over chrome. Navigation and frames stay quiet (neutral surfaces, muted text) so names, dates, belts and numbers stand out.
- 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. - 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.
- 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:
| Identity | Where it shows | Owned by |
|---|---|---|
| Platform (Club Hub) | Login and identity screens, operator console, "powered by" footer, product emails' frame, the default accent | Platform |
| 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 colours | Club 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
| Item | How | Validation |
|---|---|---|
| Logo | SVG or PNG, shown at a fixed height in the sidebar, top bar and club switcher; square mark for the avatar slot | Must 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-accent | Must 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-text | Required whenever the light accent is set; checked against the dark surfaces (see §11) |
| Grading colours | Override --belt-* and --stripe-* if the club's system differs | --belt-edge must stay visible; names are always shown (§9.7) |
| Club name, short name | Text | Short 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-accenton--brand-accentand on--brand-accent-dark≥ 4.5:1 (button labels).--brand-on-accenton--brand-accent-hoverand on--brand-accent-dark-hover≥ 4.5:1.--brand-accent-texton the light--color-bg,--color-surfaceand--color-surface-2≥ 4.5:1;--brand-accent-dark-texton the dark ones ≥ 4.5:1 (links, active nav text).--brand-accent/--brand-accent-darkagainst 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
| Use | Token |
|---|---|
| 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
| Tier | Prefix | Used by | Rule |
|---|---|---|---|
| Primitives | --p-* (--p-slate-500, --p-violet-700, --p-ink-900 …) | tokens.css only | Never referenced by components or pages. |
| Brand | --brand-* (light) and --brand-*-dark (dark) | Semantic accent tokens only | The only colours a club overrides (besides grading colours). |
| Semantic | --color-*, --belt-*, --stripe-* | Components, pages | Named 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
| Token | Role | Rules |
|---|---|---|
--color-bg | Page background behind surfaces | Only on body / the app canvas. |
--color-surface | Cards, lists, tables, sidebar, top bar, inputs | The default "thing" colour. |
--color-surface-2 | Recessed areas: table header, segmented-control track, skeleton, progress track, hover on rows, code blocks | Don't put --color-text-subtle on it (see 3.4). |
--color-surface-raised | Menus, dialogs, sheets, popovers | Always paired with a shadow. |
--color-inverse-bg / --color-inverse-text | Toasts | Inverted surface so transient messages stand out from both themes: dark slate in light mode, light slate in dark mode. |
--color-border | Decorative dividers: card outlines, list separators, table rules | Not for form controls: too faint (fine, it's decorative). |
--color-border-strong | Input, select, checkbox, radio, switch outlines; secondary button outline | ≥ 3:1 on surfaces (WCAG 1.4.11). |
--color-text | Body text, headings, values | Default. |
--color-text-muted | Secondary text: hints, descriptions, labels in key-value lists, inactive nav | ≥ 4.5:1 on bg, surface and surface-2. |
--color-text-subtle | Tertiary metadata: timestamps, counts, captions | Only 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-disabled | Disabled labels and values | Exempt from contrast (WCAG 1.4.3); disabled state must also be conveyed by the native attribute. |
--color-overlay | Scrim behind dialogs and sheets (::backdrop) | — |
--color-accent* | See §2.3 | — |
--color-focus | Focus ring | Platform-owned, never the brand accent. |
3.3 Status colours
Four statuses, each with the same variant set:
| Status | Fill (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-text | Paid, present, passed, saved |
| Warning | --color-warning | --color-warning-subtle | --color-warning-text | Due soon, incomplete, needs review |
| Danger | --color-danger (hover --color-danger-hover) | --color-danger-subtle | --color-danger-text | Error, overdue, destructive action, failed |
| Info | --color-info | --color-info-subtle | --color-info-text | Neutral 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
| Foreground | Background | Ratio | Use |
|---|---|---|---|
--color-text #0f172a | --color-bg #f8fafc | 17.1 | Body |
--color-text #0f172a | --color-surface #ffffff | 17.9 | Body on cards |
--color-text-muted #475569 | --color-bg #f8fafc | 7.2 | Secondary |
--color-text-muted #475569 | --color-surface #ffffff | 7.6 | Secondary |
--color-text-muted #475569 | --color-surface-2 #f1f5f9 | 6.9 | Table headers |
--color-text-subtle #64748b | --color-surface #ffffff | 4.8 | Metadata |
--color-text-subtle #64748b | --color-bg #f8fafc | 4.55 | Metadata (just passes) |
--color-text-subtle #64748b | --color-surface-2 #f1f5f9 | 4.34 — fails | Forbidden pair (rule, not a bug to fix) |
--color-on-accent #fff | --color-accent #6d28d9 | 7.1 | Primary button |
--color-on-accent #fff | --color-accent-hover #5b21b6 | 9.0 | Primary hover |
--color-accent-text #6d28d9 | --color-bg / --color-surface | 6.8 / 7.1 | Links, active nav |
--color-accent-subtle-text #5b21b6 | --color-accent-subtle #f5f3ff | 8.2 | Selected chip/row |
--color-on-status #fff | --color-success #15803d | 5.0 | Fill |
--color-on-status #fff | --color-warning #b45309 | 5.0 | Fill |
--color-on-status #fff | --color-danger #b91c1c | 6.5 | Danger button |
--color-on-status #fff | --color-danger-hover #7f1d1d | 10.0 | Danger hover |
--color-on-status #fff | --color-info #1d4ed8 | 6.7 | Fill |
--color-*-text | matching --color-*-subtle | 8.7–9.5 | Badges, banners |
--color-border-strong #64748b | --color-surface #ffffff | 4.8 (≥ 3 non-text) | Input outlines |
--color-focus #7c3aed | --color-surface #ffffff | 5.7 (≥ 3 non-text) | Focus ring |
--color-inverse-text #f8fafc | --color-inverse-bg #0f172a | 17.1 | Toasts |
--color-text-disabled #94a3b8 | --color-surface #ffffff | 2.6 | Exempt (disabled only) |
Dark theme
| Foreground | Background | Ratio | Use |
|---|---|---|---|
--color-text #f1f5f9 | --color-bg #0b1220 | 17.1 | Body |
--color-text #f1f5f9 | --color-surface #131b2e | 15.7 | Body on cards |
--color-text-muted #94a3b8 | --color-bg #0b1220 | 7.3 | Secondary |
--color-text-muted #94a3b8 | --color-surface #131b2e | 6.7 | Secondary |
--color-text-muted #94a3b8 | --color-surface-2 #1a2340 | 6.0 | Table headers |
--color-on-accent #fff | --color-accent #7c3aed | 5.7 | Primary button |
--color-on-accent #fff | --color-accent-hover #6d28d9 | 7.1 | Primary hover |
--color-accent-text #c4b5fd | --color-bg / --color-surface / --color-surface-2 | 10.1 / 9.3 / 8.4 | Links, active nav |
--color-accent-subtle-text #c4b5fd | --color-accent-subtle #2e1065 | 8.3 | Selected chip/row |
--color-success-text #86efac | --color-success-subtle #052e16 | 10.6 | Badges |
--color-warning-text #fcd34d | --color-warning-subtle #451a03 | 10.4 | Badges |
--color-danger-text #fca5a5 | --color-danger-subtle #450a0a | 8.5 | Badges |
--color-info-text #93c5fd | --color-info-subtle #172554 | 8.2 | Badges |
--color-on-status #fff | status fills | 5.0–6.7 | Same as light |
--color-on-status #fff | --color-danger-hover #dc2626 | 4.8 | Danger hover |
--color-border-strong #64748b | --color-surface #131b2e | 3.6 (≥ 3 non-text) | Input outlines |
--color-focus #c4b5fd | --color-surface #131b2e | 9.3 | Focus ring |
--color-inverse-text #0f172a | --color-inverse-bg #f1f5f9 | 16.3 | Toasts |
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.jsin the same PR. - Text on images (event photos, video thumbnails) always sits on a solid
--color-surfaceband 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 whenlang="fa"(Persian). It's the only web font. Apply it with:lang(fa)inbase.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
| Role | Size token | Weight | Line height | Use |
|---|---|---|---|---|
| Display | --text-display (fluid 36→48px) | --weight-bold | --leading-tight | Hero 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-tight | One per page, in the page header. |
| Section title (h2) | --text-xl (20) | --weight-semibold | --leading-snug | Card groups, page sections. |
| Card / subsection title (h3) | --text-lg (18) | --weight-semibold | --leading-snug | Card headers, dialog titles. |
| Body | --text-md (16) | --weight-regular | --leading-body | Default text, inputs (16px also prevents iOS zoom on focus). |
| Small | --text-sm (14) | --weight-regular / --weight-medium | --leading-snug | Hints, table cells in dense mode, button labels in sm size, badges, metadata. |
| Caption | --text-xs (12) | --weight-medium | --leading-snug | Tab bar labels, chart axis labels, overline labels. Never for sentences or instructions. |
| Stat value | --text-3xl / --text-4xl | --weight-bold | --leading-tight | Stat 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-mdon mobile. - Uppercase overlines ("WEITERE PLÄNE") are
--text-xs,--weight-semibold,--color-text-muted,letter-spacing: var(--tracking-wide).--tracking-wideis 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-numsfor 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-numsso 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
langcorrectly on<html>(and on any element in another language) and usehyphens: autoon text blocks, card titles and table cells.overflow-wrap: anywhereas 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).
| Relationship | Token |
|---|---|
| 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
| Mode | Control height | Where |
|---|---|---|
| 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:
| Name | Min width | Typical change |
|---|---|---|
| (base) | 0 | Single column, tab bar, full-width buttons in forms, bottom sheets |
sm | 480px | Two-up stat tiles, inline button groups |
md | 768px | Two-column forms where sensible, dialogs instead of sheets for short tasks |
lg | 1024px | Sidebar replaces tab bar; dense tables allowed; three-column grids |
xl | 1280px | Wider 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-4belowmd,--space-6atmd,--space-8fromlg. - 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-2by default..grid: auto-fit responsive grid (--grid-minsets 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-borderon the inline-end edge. Club switcher at the top, nav groups, user/account at the bottom. Active item:--color-accent-subtlebackground,--color-accent-textlabel and icon, 3px--color-accentindicator on the inline-start edge. - Top bar:
--topbar-h(56px) +--safe-toppadding,--color-surface,--color-borderbottom. Holds: back link or menu button, page context, search, notifications, account. Sticky (--z-nav). - Tab bar (<
lg):--tabbar-h(64px) +--safe-bottompadding, 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
dvhunits for full-height layouts (keeps the Trainer App's good behaviour). - TV / dojang screens use no shell,
--text-displayheadings and the dark theme.
6. Shape and elevation
6.1 Radii
| Token | Use |
|---|---|
--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-full | Avatars, 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-bgwith a--border-width--color-borderoutline 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
| Level | Token | Used for |
|---|---|---|
| 0 | none | Page, cards, lists, tables, stat tiles, sidebar |
| 1 | --shadow-sm | Sticky top bar / table header while scrolled; draggable item at rest; pressed-state lift is not used |
| 2 | --shadow-md | Menus, popovers, combobox listbox, date picker, toasts, dragged item |
| 3 | --shadow-lg | Dialogs, 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 viadata-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-labelor visually hidden text) and a tooltip on pointer devices. Decorative icons getaria-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:
| Group | Icons |
|---|---|
| Actions | check, x, plus, minus, edit, trash, upload, download, printer, refresh, search, filter, external-link, log-out |
| Navigation | chevron-left, chevron-right, chevron-up, chevron-down, arrow-left, menu, more, home |
| Objects | calendar, clock, user, users, settings, bell, mail, star, flame |
| Status | info, alert-triangle, alert-circle, check-circle, wifi-off |
| Theme | sun, moon |
Status mapping: success → check-circle, warning → alert-triangle, danger → alert-circle, info → info, offline → wifi-off.
8. Motion
| Token | Value | Use |
|---|---|---|
--duration-fast | 120ms | Hover/press colour changes, checkbox tick, switch thumb, focus ring appear |
--duration-base | 200ms | Menus, popovers, toasts in, tab indicator, accordion |
--duration-slow | 320ms | Bottom sheet, dialog, side panel, progress fill changes |
--ease-standard | cubic-bezier(0.2, 0, 0, 1) | Entering and moving |
--ease-exit | cubic-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):
| State | Selector | Visual |
|---|---|---|
| Hover | :hover inside @media (hover: hover) | Background one step: accent → --color-accent-hover; neutral → --color-surface-2 |
| Active / pressed | :active | Same as hover plus 1px translate or no change; never shrink |
| Focus | :focus-visible | outline: 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
| Variant | Fill | Label | Border | Use |
|---|---|---|---|---|
| primary | --color-accent (hover --color-accent-hover) | --color-on-accent | none | The one main action of a view, dialog or row |
| secondary | --color-surface (hover --color-surface-2) | --color-text | --color-border-strong | Other actions, "Cancel" |
| ghost | transparent (hover --color-surface-2) | --color-accent-text or --color-text | none | Low-emphasis, toolbars, inline "Edit" |
| danger | --color-danger (hover --color-danger-hover) | --color-on-status | none | Destructive 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-lgand 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-edgeoutline, 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-tipis 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-edgeoutline) + label. - Earned:
--color-surfacewith--color-border-strongborder,checkicon,--color-text. Not earned: dashed--color-borderoutline,--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-accentvs neutral (--color-text-subtleor--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-2tracks,--color-bordergridlines, 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-accenton--color-surfacepasses 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-strongstays--p-slate-500for 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-bgturns 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-surfaceplate; 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
| Topic | Rule | Example |
|---|---|---|
| Component class | Plain name | .button, .field, .card, .belt |
| Parts | __ | .field__hint, .card__header, .dialog__footer |
| Variants | data-variant | <button class="button" data-variant="primary"> |
| Sizes | data-size | data-size="sm" |
| Density | data-density | <table class="table" data-density="dense"> |
| State | Native / ARIA attributes, not classes | :disabled, [aria-invalid="true"], [aria-busy="true"], [aria-current="page"], [aria-expanded], [aria-pressed], :checked |
| Behaviour hook | data-behavior (JS only, never styled) | data-behavior="dialog" |
| Layout primitives | .stack, .cluster, .grid, .center | — |
| Utilities | u- prefix, single purpose | .u-visually-hidden, .u-tabular |
| Colours | Tokens only. Raw hex/rgb/hsl outside tokens.css fails lint | color: var(--color-text-muted) |
| Inline style | Forbidden, except setting a custom property | style="--belt-color: var(--belt-green)", style="--progress: 0.33" |
| Direction | Logical 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 | — |
| Hover | Inside @media (hover: hover) | — |
| Specificity | One class + attributes; no IDs, no element chains, no !important (except u-visually-hidden) | — |
| DOM | Light DOM only; custom elements have no shadow root, so tokens and forms work normally | — |
| Units | rem for type and spacing (via tokens), px only for borders and hairlines, dvh for full-height | — |
12.3 Adding a component: checklist
- Need: no existing component or variant covers it (check the style guide). Name it plainly.
- Markup first: semantic HTML that works without JS and without CSS (real
<button>,<a>,<label for>,<fieldset>). - Tokens only: no raw values; if a value is missing, propose a token (don't inline it).
- All states: default, hover, active, focus-visible, disabled, loading (
aria-busy), invalid (aria-invalid), selected/current, empty, long content (German compound, 3× length), RTL. - Both themes: light and dark checked in the style guide.
- RTL: logical properties; directional icons flip; test with
dir="rtl" lang="fa". - Touch: every target ≥
--tap-min(44px); dense variant only for desktop tables. - Contrast: any new foreground/background pair added to
tests/contrast.test.js; non-text parts ≥ 3:1. - Motion: duration tokens only; works with reduced motion.
- Component module with data contract:
packages/ui/components/<id>.jsexportingspec(description, JSON-Schemadatawith a description for every field, examples, markup/parts/states, CSS variables, behaviour API and events, accessibility, do/don't) and a purerender(data). Seepackages/ui/components/SPEC-FORMAT.md.node tools/build-reference.mjsgenerates its reference page (live examples, data table, markup, events),components.jsonandllms.txt. The contract tests check the schema, examples and escaping. - Tests:
npm test(contract tests) passes; axe passes on the reference page; visual regression snapshots in both themes. - 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):
- 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. - 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-900on--belt-yellow≈ 11.7:1; white on--belt-greenis only 3.3:1). Today belts are swatches next to text, so this isn't needed yet. - Data-viz categorical palette for charts with more than two series (not covered by accent + neutral + status).
- Disabled fill: disabled controls use
--color-surface-2; confirm or add--color-disabled-bg. - Skeleton/pulse timing and toast auto-dismiss durations (behaviour; may belong in the UX guide).
- Dark on-accent: there is no
--brand-on-accent-dark;--brand-on-accentserves both themes. Fine for white, but a club with a very light dark-mode accent would need dark label text in dark mode only. - 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). - 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.