| name | color-system |
| description | Building a mobile color system with HSL/HCT base, Material 3 tonal palettes, semantic aliases, and verified contrast. Use this when defining or refining color tokens. |
Color System
Instructions
A production color system is layered: perceptual primitives at the bottom, tonal palettes above, and semantic aliases at the top. Components only ever consume the semantic layer.
1. Pick a Perceptual Base
Use HCT (Material 3) or OKLCH for authoring — they keep perceived lightness stable as hue changes, so palettes feel consistent. Fall back to HSL only for documentation.
Generate palettes with the Material 3 HCT algorithm (@material/material-color-utilities) or Leonardo. Do not hand-pick hex ramps.
2. Tonal Palettes (Reference Layer)
For each key hue, generate a 13-stop tonal palette keyed by tone (0 = black, 100 = white):
{
"color": {
"$type": "color",
"brand": {
"0": { "$value": "#000000" },
"10": { "$value": "#14101F" },
"20": { "$value": "#271F3A" },
"30": { "$value": "#3C2F56" },
"40": { "$value": "#524173" },
"50": { "$value": "#695491" },
"60": { "$value": "#8269AE" },
"70": { "$value": "#9C82CB" },
"80": { "$value": "#B79AE8" },
"90": { "$value": "#D3B4FF" },
"95": { "$value": "#EAD9FF" },
"99": { "$value": "#FFFBFF" },
"100": { "$value": "#FFFFFF" }
}
}
}
Repeat for neutral, neutralVariant, success, warning, danger, info.
3. Semantic Aliases (System Layer)
Components consume these. Pair every background with a content token whose contrast is verified.
{
"color": {
"surface": {
"default": { "$value": "{color.neutral.99}" },
"subtle": { "$value": "{color.neutral.95}" },
"inverse": { "$value": "{color.neutral.10}" }
},
"content": {
"default": { "$value": "{color.neutral.10}" },
"muted": { "$value": "{color.neutral.40}" },
"inverse": { "$value": "{color.neutral.99}" },
"onAction": { "$value": "{color.neutral.99}" }
},
"action": {
"primary": {
"bg": { "$value": "{color.brand.40}" },
"bgHover": { "$value": "{color.brand.30}" },
"fg": { "$value": "{color.neutral.99}" }
}
},
"feedback": {
"danger": { "bg": { "$value": "{color.danger.40}" }, "fg": { "$value": "{color.neutral.99}" } },
"success": { "bg": { "$value": "{color.success.40}" }, "fg": { "$value": "{color.neutral.99}" } }
}
}
}
4. Dark Theme = Different Aliasing, Same Palettes
Do not ship a separate palette. Swap the alias mappings:
{
"color": {
"surface": {
"default": { "$value": "{color.neutral.10}" }
},
"content": {
"default": { "$value": "{color.neutral.95}" }
}
}
}
Roughly: light uses tones 90–99 for surfaces and 10–40 for content; dark inverts to tones 0–20 for surfaces and 80–95 for content.
5. Contrast Contract
- Body text against its surface: WCAG 2.2 AA ≥ 4.5:1 (or APCA Lc 60+).
- Large text (≥ 18pt or 14pt bold): ≥ 3:1.
- Non-text UI (icons, focus ring, borders that convey state): ≥ 3:1.
- Encode the pairing in
$extensions so a linter can verify it.
6. Platform Delivery
Compose:
val LightScheme = lightColorScheme(
primary = ds.color.action.primary.bg,
onPrimary = ds.color.action.primary.fg,
surface = ds.color.surface.default,
onSurface = ds.color.content.default,
)
SwiftUI: emit a .xcassets color set per semantic token with Any Appearance + Dark Appearance entries, then use Color("surface/default").
Flutter: declare a ThemeExtension<AppColors> holding the semantic map and attach to ThemeData.
7. Anti-Patterns
- Using
Colors.black.withOpacity(0.6) in components — unverifiable contrast.
- Sharing a single "text primary" token for both light and dark without re-aliasing.
- Encoding state into color only (no iconography, no stroke change).
- More than ~8 semantic surfaces / content levels — it becomes unmanageable.
Checklist