OpenMates Docs Open Chat

Unified Design Token System

Unified Design Token System Single YAML source of truth for all visual tokens, generating CSS custom properties (web), TypeScript constants (Svelte logic), a...

[T:documentation.sender_name]

Unified Design Token System

Single YAML source of truth for all visual tokens, generating CSS custom properties (web), TypeScript constants (Svelte logic), and Swift extensions + Xcode asset catalogs (native Apple app). Ensures web and native platforms stay pixel-identical from a shared definition.

Linear task: OPE-326 (Done)

Why This Exists

OpenMates is adding a native Swift/SwiftUI Apple ecosystem app (iOS, iPadOS, macOS, watchOS). The token system provides a single source of truth for all visual tokens across web and native platforms.

Before (OPE-326): 231 CSS custom properties in theme.css only served the web app, and ~4,300 hardcoded values in Svelte components bypassed the token system entirely.

After: 10 YAML source files generate CSS, TypeScript, and Swift outputs. 307+ Svelte components migrated to use design tokens. Z-index rationalized from chaos (1–99999) to a 15-level named layer system. All font sizes converted from px to rem for accessibility. 202 custom SVG icons packaged as xcassets. 31 Lucide icons mapped to SF Symbol equivalents.

Migration audit (historical reference)

Category Instances migrated
Spacing (gap, padding, margin) ~1,824
Font sizes (px → rem tokens) ~1,165
Border radius ~628
Transitions ~405
Z-index ~148
Colors ~76
Shadows ~34

Architecture

frontend/packages/ui/src/tokens/
  sources/                          # YAML source files (single source of truth)
    colors.yml                      # Grey scale (light/dark), font, semantic, button
    gradients.yml                   # 50+ app gradients, icon, primary, footer
    typography.yml                  # Font families, weights, sizes (rem + pt)
    spacing.yml                     # Spacing scale
    radii.yml                       # Border radius scale
    shadows.yml                     # Shadow presets
    z-index.yml                     # Named layer system
    transitions.yml                 # Duration/easing presets
    icons.yml                       # Icon size scale
    icons-mapping.yml               # Lucide → SF Symbol mapping + icon aliases
    layout.yml                      # Breakpoints, max-widths
  generated/                        # AUTO-GENERATED — never edit
    theme.generated.css             # Replaces :root / [data-theme="dark"] blocks
    tokens.generated.ts             # TypeScript typed constants + icon mapping
    swift/
      ColorTokens.generated.swift   # SwiftUI Color extensions
      TypographyTokens.generated.swift
      SpacingTokens.generated.swift
      GradientTokens.generated.swift
      IconMapping.generated.swift   # SFSymbol enum + Image extensions + aliases
      Tokens.generated.swift        # Umbrella re-export
      Assets.xcassets/              # Color catalog with light/dark pairs
      Icons.xcassets/               # SVG icon catalog (202 custom icons)

This mirrors the i18n pipeline: src/i18n/sources/*.yml -> build-translations.js -> src/i18n/locales/*.json.

Build pipeline

pnpm --filter @repo/ui build:tokens
  |
  Reads src/tokens/sources/*.yml
  |
  Generates -> theme.generated.css     (web — identical format to current theme.css)
            -> tokens.generated.ts     (Svelte logic / programmatic access)
            -> swift/*.generated.swift (iOS/macOS/watchOS)
            -> swift/Assets.xcassets/  (Xcode adaptive color catalog)

Runs as the first step in prepare / prebuild / build chains, before build:translations. A Vite file watcher triggers rebuild during dev when YAML sources change.

YAML Schema

colors.yml — theme-aware colors

# Colors with light/dark theme support.
# Tokens with both light/dark values generate:
#   CSS:   :root { --color-grey-0: #ffffff; }
#          [data-theme="dark"] { --color-grey-0: #171717; }
#   Swift: Color("grey-0") via asset catalog (automatic theme switching)
# Tokens with a single `value` are theme-independent.

grey:
  0:    { light: "#ffffff", dark: "#171717" }
  10:   { light: "#f9f9f9", dark: "#1c1c1c" }
  20:   { light: "#f3f3f3", dark: "#212121" }
  25:   { light: "#e8e8e8", dark: "#252525" }
  30:   { light: "#e3e3e3", dark: "#2c2c2c" }
  40:   { light: "#c4c4c4", dark: "#404040" }
  50:   { light: "#a6a6a6", dark: "#606060" }
  60:   { light: "#888888", dark: "#808080" }
  70:   { light: "#666666", dark: "#a0a0a0" }
  80:   { light: "#444444", dark: "#c0c0c0" }
  90:   { light: "#222222", dark: "#e0e0e0" }
  100:  { light: "#000000", dark: "#ffffff" }
  blue: { light: "#e6eaff", dark: "#2d2f35" }

font:
  primary:           { light: "#000000", dark: "#e6e6e6" }
  secondary:         { light: "#a9a9a9", dark: "#cfcfcf" }
  tertiary:          { light: "#6b6b6b", dark: "#c0c0c0" }
  button:            { value: "#ffffff" }
  field-placeholder: { value: "#9e9e9e" }
  bold:              { light: "#503ba0", dark: "#c9bbff" }

semantic:
  error:        { light: "#e74c3c", dark: "#ff6b6b" }
  error-light:  { light: "rgba(231, 76, 60, 0.1)", dark: "rgba(255, 107, 107, 0.15)" }
  warning:      { light: "#e67e22", dark: "#f0a050" }
  warning-bg:   { light: "rgba(230, 126, 34, 0.1)", dark: "rgba(240, 160, 80, 0.15)" }

button:
  primary:         { value: "#ff553b" }
  primary-hover:   { value: "#ff6b54" }
  primary-pressed: { value: "#ff4422" }
  secondary:       { value: "#808080" }
  secondary-hover: { value: "#909090" }
  secondary-pressed: { value: "#606060" }

gradients.yml — app gradients

# All gradients use the standard 135deg / 9.04%->90.06% stops.
# The angle and stops are NOT repeated per entry — the generator applies them.
# CSS output: --color-app-{name}-start, --color-app-{name}-end, --color-app-{name}
# Swift output: LinearGradient.app{Name}

apps:
  ai:               { start: "#b85a3a", end: "#e8956e" }
  health:           { start: "#fd50a0", end: "#f42c2d" }
  life-coaching:    { start: "#fdb250", end: "#f42c2d" }
  nutrition:        { start: "#fd8450", end: "#f42c2d" }
  finance:          { start: "#0a6e04", end: "#2cb81e" }
  fitness:          { start: "#8a0048", end: "#d63084" }
  legal:            { start: "#239cff", end: "#005ba5" }
  weather:          { start: "#005ba5", end: "#00a7c9" }
  travel:           { start: "#059db3", end: "#13daf5" }
  news:             { start: "#c90820", end: "#f95a6e" }
  jobs:             { start: "#049363", end: "#00c382" }
  # ... all 50+ apps (full list extracted from theme.css)

icons:
  default:  { start_ref: "color-grey-20", end_ref: "color-grey-30" }
  focus:    { start: "#5951d0", end: "#7d74ff" }
  skill:    { start: "#fefefe", end: "#eaeaea" }
  memory:   { start: "#b5008e", end: "#f03ed0" }

primary:    { start: "#4867cd", end: "#5a85eb" }

footer:
  light:    { start: "#4867cd", end: "#5a85eb" }
  dark:     { start: "#293d7f", end: "#263969" }

spacing.yml — spacing scale

# Scale derived from audit of actual usage across 352 components.
# CSS output: --spacing-{key}: {value}px
# Swift output: CGFloat.spacing{key}
# Values in px.

scale:
  0:  0
  1:  2
  2:  4       # 101 uses as gap
  3:  6       # 91 uses
  4:  8       # 184 uses (most common)
  5:  10      # 61 uses
  6:  12      # 113 uses
  8:  16      # 69 uses
  10: 20      # 48 uses
  12: 24      # 45 uses
  16: 32
  20: 40
  24: 48
  32: 64

radii.yml — border radius scale

# CSS output: --radius-{key}: {value}px
# Swift output: CGFloat.radius{key}

scale:
  1:    4     # 70 uses (small UI elements)
  2:    6     # 72 uses (code blocks, blockquotes)
  3:    8     # 230 uses (cards, buttons — most common)
  4:    10    # 48 uses (medium components)
  5:    12    # 119 uses (notifications, modals)
  6:    14    # chat headers, banners
  7:    16
  8:    20    # primary buttons
  full: 9999  # pill shapes

typography.yml — font system

# rem for CSS (accessibility), pt for Swift.
# CSS output: --font-size-{key}: {rem}rem and --font-size-{key}-mobile: {mobile_rem}rem
# Swift output: Font.custom("LexendDeca-Variable", size: {pt})

font-family:
  primary: "Lexend Deca Variable"

font-weight:
  medium:     500
  bold:       700
  extra-bold: 800

font-size:
  h1:    { rem: 3.75,   pt: 60, mobile_rem: 2.25,  mobile_pt: 36 }
  h2:    { rem: 1.875,  pt: 30, mobile_rem: 1.5,   mobile_pt: 24 }
  h3:    { rem: 1.25,   pt: 20, mobile_rem: 1.125, mobile_pt: 18 }
  h4:    { rem: 1,      pt: 16 }
  body:  { rem: 1,      pt: 16 }
  small: { rem: 0.875,  pt: 14 }
  xs:    { rem: 0.8125, pt: 13 }
  xxs:   { rem: 0.75,   pt: 12 }
  tiny:  { rem: 0.6875, pt: 11 }

shadows.yml — shadow presets

# CSS output: --shadow-{key}: {value}
# Swift: not directly applicable (SwiftUI uses .shadow() modifier)

presets:
  xs: "0 2px 4px rgba(0, 0, 0, 0.1)"     # 13 uses
  sm: "0 2px 8px rgba(0, 0, 0, 0.05)"     # 8 uses
  md: "0 4px 12px rgba(0, 0, 0, 0.1)"     # 12 uses
  lg: "0 4px 16px rgba(0, 0, 0, 0.15)"    # 8 uses
  xl: "0 6px 16px rgba(0, 0, 0, 0.15)"    # card hover

z-index.yml — named layer system

# Replaces the current chaos (values from 1 to 99999 with no coordination).
# CSS output: --z-index-{key}: {value}
# Components MUST use var(--z-index-{key}) instead of magic numbers.

layers:
  base:      0
  raised:    1
  dropdown:  100
  sticky:    200
  overlay:   300
  modal:     400
  popover:   500
  toast:     600
  tooltip:   700
  skip-link: 100000

transitions.yml — timing presets

# CSS output: --duration-{key}: {value} and --easing-{key}: {value}

duration:
  fast:   "0.15s"   # 21 uses
  normal: "0.2s"    # 49 uses (most common)
  slow:   "0.3s"

easing:
  default: "ease"
  in-out:  "ease-in-out"

icons.yml — icon size scale

# CSS output: --icon-size-{key}: {value}px
# Swift output: CGFloat.iconSize{Key}

size:
  xs:  16    # 38 uses
  sm:  20    # 62 uses
  md:  24    # 37 uses
  lg:  32    # 36 uses
  xl:  40    # 39 uses
  xxl: 48    # 15 uses

icons-mapping.yml — cross-platform icon mapping

# Maps semantic icon names to platform implementations.
# Web uses Lucide (@lucide/svelte), iOS uses SF Symbols.
# Custom SVGs (static/icons/*.svg) are shared via xcassets.

# Lucide → SF Symbol mapping (31 icons used in web app)
lucide:
  bell:          { sf: "bell.fill" }
  book-open:     { sf: "book.fill" }
  chevron-left:  { sf: "chevron.left" }
  heart:         { sf: "heart.fill" }
  # ... 27 more

# App/feature name → actual SVG filename aliases
aliases:
  health: heart        # health app → heart.svg
  finance: money       # finance app → money.svg
  code: coding         # code app → coding.svg
  # ... 18 more

Generated outputs:

  • TypeScript: LucideToSF mapping + IconAlias mapping (in tokens.generated.ts)
  • Swift: SFSymbol enum, Image extensions for all 202 custom SVGs, IconAlias enum
  • xcassets: Icons.xcassets/ with 202 SVG image sets (Xcode imports directly)

Cross-platform icon strategy:

  • Standard UI icons: web uses Lucide, iOS uses SF Symbols (native look on each platform)
  • Custom/brand icons: shared SVGs via xcassets (identical on both platforms)
  • Icon sizes: tokenized via --icon-size-* / CGFloat.iconSize*

layout.yml — breakpoints and dimensions

# CSS output: --breakpoint-{key}: {value}px, --layout-{key}: {value}px
# Replaces current constants.ts

breakpoints:
  mobile:     730    # current MOBILE_BREAKPOINT
  chats-open: 1440   # current CHATS_DEFAULT_OPEN_BREAKPOINT

dimensions:
  chat-content-max-width: 1000

Generated Output Formats

CSS — replaces theme.css token blocks

/* AUTO-GENERATED by build-tokens.js — DO NOT EDIT */
/* Source: frontend/packages/ui/src/tokens/sources/ */

:root {
  /* Grey scale */
  --color-grey-0: #ffffff;
  --color-grey-10: #f9f9f9;
  /* ... */

  /* Spacing */
  --spacing-0: 0px;
  --spacing-1: 2px;
  --spacing-2: 4px;
  --spacing-4: 8px;
  /* ... */

  /* App gradients */
  --color-app-ai-start: #b85a3a;
  --color-app-ai-end: #e8956e;
  --color-app-ai: linear-gradient(135deg, var(--color-app-ai-start) 9.04%, var(--color-app-ai-end) 90.06%);
  /* ... */
}

[data-theme="dark"] {
  --color-grey-0: #171717;
  --color-grey-10: #1c1c1c;
  /* ... */
}

@media (max-width: 600px) {
  :root {
    --font-size-h1: 2.25rem;
    /* ... */
  }
}

The generated CSS must be byte-identical to the current theme.css :root and [data-theme="dark"] blocks for backwards compatibility. Existing var(--color-grey-20) references in all 352 components continue working unchanged.

TypeScript — replaces constants.ts

// AUTO-GENERATED by build-tokens.js — DO NOT EDIT
export const Color = Object.freeze({
  grey0: 'var(--color-grey-0)',
  grey10: 'var(--color-grey-10)',
  // ...
  fontPrimary: 'var(--color-font-primary)',
  error: 'var(--color-error)',
} as const);

export const Spacing = Object.freeze({
  s0: 0, s1: 2, s2: 4, s3: 6, s4: 8, s5: 10,
  s6: 12, s8: 16, s10: 20, s12: 24, s16: 32,
  s20: 40, s24: 48, s32: 64,
} as const);

export const ZIndex = Object.freeze({
  base: 0, raised: 1, dropdown: 100, sticky: 200,
  overlay: 300, modal: 400, popover: 500, toast: 600,
  tooltip: 700, skipLink: 100000,
} as const);

export const Breakpoint = Object.freeze({
  mobile: 730,
  chatsOpen: 1440,
} as const);

Swift — Color extensions + asset catalog

// AUTO-GENERATED by build-tokens.js — DO NOT EDIT
import SwiftUI

extension Color {
    // Grey scale — theme-aware via asset catalog
    static let grey0  = Color("grey-0")
    static let grey10 = Color("grey-10")
    // ...

    // Font colors
    static let fontPrimary   = Color("font-primary")
    static let fontSecondary = Color("font-secondary")

    // Button colors
    static let buttonPrimary = Color(hex: 0xFF553B)
}

extension LinearGradient {
    // Standard OpenMates gradient helper (135 deg, 9.04% -> 90.06%)
    static func omGradient(start: Color, end: Color) -> LinearGradient {
        LinearGradient(
            gradient: Gradient(stops: [
                .init(color: start, location: 0.0904),
                .init(color: end, location: 0.9006)
            ]),
            startPoint: .topLeading,
            endPoint: .bottomTrailing
        )
    }

    static let appAi = omGradient(start: Color(hex: 0xB85A3A), end: Color(hex: 0xE8956E))
    // ... all 50+ app gradients
    static let primary = omGradient(start: Color(hex: 0x4867CD), end: Color(hex: 0x5A85EB))
}

extension CGFloat {
    // Spacing scale
    static let spacing0: CGFloat = 0
    static let spacing1: CGFloat = 2
    static let spacing2: CGFloat = 4
    static let spacing4: CGFloat = 8
    // ...

    // Border radius
    static let radius1: CGFloat = 4
    static let radius3: CGFloat = 8
    static let radius5: CGFloat = 12
    // ...
}

For theme-aware colors, the generator also produces .xcassets JSON so iOS auto-switches light/dark:

swift/Assets.xcassets/
  grey-0.colorset/Contents.json     # light: #ffffff, dark: #171717
  grey-10.colorset/Contents.json    # light: #f9f9f9, dark: #1c1c1c
  font-primary.colorset/Contents.json
  ...

Cross-Platform Naming Convention

CSS property names are preserved exactly for backwards compatibility.

Category CSS TypeScript Swift
Grey color --color-grey-20 Color.grey20 Color.grey20
Font color --color-font-primary Color.fontPrimary Color.fontPrimary
Spacing --spacing-4 Spacing.s4 CGFloat.spacing4
Font size --font-size-small FontSize.small Font.omSmall
Radius --radius-3 Radius.r3 CGFloat.radius3
Shadow --shadow-sm Shadow.sm (N/A)
Z-index --z-index-modal ZIndex.modal CGFloat.zIndexModal
App gradient --color-app-ai AppGradient.ai LinearGradient.appAi
Icon size --icon-size-sm IconSize.sm CGFloat.iconSizeSm
Breakpoint --breakpoint-mobile Breakpoint.mobile CGFloat.breakpointMobile

Generator Script

frontend/packages/ui/scripts/build-tokens.js

Follows the same pattern as build-translations.js (ESM, yaml package, fileURLToPath).

What it generates from the 11 YAML sources:

  • theme.generated.css — CSS custom properties (:root + [data-theme="dark"])
  • tokens.generated.ts — TypeScript typed constants + LucideToSF + IconAlias exports
  • swift/ColorTokens.generated.swift — SwiftUI Color extensions
  • swift/TypographyTokens.generated.swift — Font extensions
  • swift/SpacingTokens.generated.swift — CGFloat extensions (spacing, radii, icons, breakpoints)
  • swift/GradientTokens.generated.swift — LinearGradient extensions
  • swift/IconMapping.generated.swift — SFSymbol enum + Image extensions + IconAlias enum
  • swift/Assets.xcassets/ — Color catalog (18 theme-aware color sets)
  • swift/Icons.xcassets/ — SVG icon catalog (202 image sets from static/icons/)

Verification: Run node scripts/build-tokens.js --verify to diff generated CSS against current theme.css + fonts.css.

Build pipeline

// package.json — first step in prepare/prebuild/build chains
"build:tokens": "node scripts/build-tokens.js"

turbo.json caches **/tokens/generated/** as build outputs.

Token Validation

frontend/packages/ui/scripts/validate-token-usage.js

Scans all Svelte component <style> blocks for hardcoded values:

  • ERROR: font-size with px units (accessibility violation — must use rem via var(--font-size-*))
  • WARNING: Raw hex/rgb colors, z-index, border-radius, spacing, shadow values

Run: node scripts/validate-token-usage.js (passes with 0 errors). Use --strict to also fail on warnings.

Allowlist for legitimate exceptions: src/tokens/.token-allowlist.json.

Migration Tools (available for future use)

  • scripts/audit-tokens.js — Scans .svelte files, produces token-audit.json manifest mapping every hardcoded value to its token replacement
  • scripts/migrate-tokens.js — Reads the manifest and performs file-by-file replacements. Supports --dry-run, --exact-only, --include-manual

Component Primitives (Phase E — Sub-phase α)

Phases A–D lifted every primitive value (colors, spacing, radii, etc.) into YAML. Sub-phase E-α lifts a small set of reusable component primitives — multi-property class definitions used across many Svelte files — into YAML so web (CSS classes) and iOS (SwiftUI structs) share a single shape.

Why a separate layer

Design-token primitives describe a single value (--spacing-4: 8px). Component primitives describe a combination of values that recurs in identical form across many components — e.g. the green success-icon badge used by every confirmation screen, or the search-result row layout used by every embed search preview. Without a shared definition these would have to be hand-rewritten on iOS and stay in sync by hand.

Source files

src/tokens/sources/components/
  status-feedback.yml      # success-icon-wrapper, success-title, error-title, search-error-title
  search-results.yml       # search-query, search-provider, search-results-info
  loading.yml              # loading-text  (skeleton-line deferred — see notes)
  snippet-card.yml         # snippet-card  (quote container in news/website fullscreens)
  forms.yml                # save-button-container

Generated outputs

  • Web: src/tokens/generated/component-classes.generated.css — emits one .ds-{name} class per primitive plus --ds-{name}-{property} CSS variables. Imported via frontend/packages/ui/index.ts.
  • iOS: src/tokens/generated/swift/ComponentTokens.generated.swift — one nested struct per primitive under enum DS { ... }, e.g. DS.SuccessIconWrapper.size.

Migration pattern

Components opt in per-file: rename class="success-icon-wrapper" to class="ds-success-icon-wrapper" AND delete the local base rule from <style> in the same atomic commit. Mobile descendant selectors are renamed to target the .ds-* class. Local extras (extra padding, layout-specific properties) stay in the file.

Lifted primitives (Sub-phase α — complete)

Class Files migrated Group
.ds-snippet-card 2 snippet-card
.ds-search-query 11 search-results
.ds-search-provider 11 search-results
.ds-search-results-info 11 search-results
.ds-success-icon-wrapper 4 status-feedback
.ds-success-title 4 status-feedback
.ds-error-title 3 status-feedback
.ds-search-error-title 7 status-feedback
.ds-loading-text 5 status-feedback / loading
.ds-save-button-container 8 forms

Deferred (audit revealed collisions or entanglement — separate tickets)

  • .error-icon, .warning-icon, .warning-text — same name used for visually different roles across files
  • .skeleton-line — entangled with per-file @keyframes pulse shared by other classes
  • All PARTIAL classes (.error-state, .success-message, .error-text, .action-buttons, .loading, .loading-state, .confirmation-row) — outliers must be renamed first (sub-phase E-β)
  • All COLLISION classes (.error-message, .loading-spinner, .empty-state, .no-results, .success-icon, .copy-icon, .confirmation-text, .btn family) — sub-phase E-γ

How To

Add a new design token

  1. Edit the relevant YAML source in src/tokens/sources/
  2. Run pnpm --filter @repo/ui build:tokens
  3. CSS, TypeScript, and Swift outputs are regenerated automatically

Add a new custom icon

  1. Drop the SVG file into frontend/packages/ui/static/icons/
  2. Run pnpm --filter @repo/ui build:tokens
  3. Web: available as var(--icon-url-{name}) via generate-icon-urls.js
  4. iOS: available as Image("{name}") via Icons.xcassets/{name}.imageset/

Add a new component primitive

  1. Identify a CSS class definition that recurs in identical form across 3+ Svelte files (run an audit first — same name used for different roles is a COLLISION, not a primitive)
  2. Add a YAML entry under src/tokens/sources/components/{group}.yml using ref: for any primitive token references
  3. Run pnpm --filter @repo/ui build:tokens
  4. Per file: rename class="foo" to class="ds-foo" and delete the local base rule. One file per commit. Verify pixel-identical render on the dev preview route. Repeat for every file.
  5. Optional: temporarily add the legacy class name to validate-token-usage.js warnings during the migration window

Add a new Lucide → SF Symbol mapping

  1. Edit src/tokens/sources/icons-mapping.yml — add entry under lucide:
  2. Run pnpm --filter @repo/ui build:tokens
  3. TypeScript LucideToSF and Swift SFSymbol enum are updated

CLAUDE.md rules (enforced)

- Design tokens are the source of truth: frontend/packages/ui/src/tokens/sources/*.yml
- NEVER edit generated files in src/tokens/generated/
- NEVER use px for font sizes — use rem via var(--font-size-*) tokens
- All spacing, radii, shadows, transitions, z-index MUST use var(--token-name)