Theming
Theme Matrix
Every theme is a combination of three dimensions:
| Dimension | Options |
|---|---|
| Mode |
|
| Contrast |
|
| Color vision |
|
This produces 24 theme variants. Token JSON files live in mode/{dark,light}/:
default.json → dark + default contrast + standard visiondimmer.json → dark + dimmer + standard visionhigh-contrast.json → dark + high-contrast + standard visiondefault-protanopia.json → dark + default + protanopiadimmer-deuteranopia.json → dark + dimmer + deuteranopiahigh-contrast-tritanopia.json → dark + high-contrast + tritanopia...Build Pipeline
cd packages/core-design-systempnpm build # → node scripts/build.jsThe build uses Style Dictionary + @tokens-studio/sd-transforms:
- Reads
$themes.jsonand permutates all theme combinations - Resolves token references (
{gray.500}→ actual LCH value) - Applies custom transforms:
alpha,darken,lightenin LCH space;dropShadowfor data-viz - Marks tokens as
themeableorcore(determines which output file they land in) - Outputs to
dist/:
dist/├── styles/│ ├── core-imports.css # @imports for all core files│ ├── colors.css # Core color primitives (:root)│ ├── core.css # Dimensions, typography (:root)│ ├── breakpoint.css # Layout tokens (:root)│ ├── components.css # Component tokens (:root)│ ├── themes.css # @imports for all 24 theme files│ ├── dark.css # Semantic tokens under .dark selector│ ├── light.css│ ├── dark-dimmer.css│ ├── ... # 24 theme files total│ └── mfe-themes.css # Subset for micro-frontend consumers│└── styles-esm/ ├── index.ts # Theme map + re-exports ├── colors.ts # Programmatic access to primitives └── ... # TypeScript module per fileAll CSS properties use the cn prefix: --cn-bg-0, --cn-text-1, --cn-set-brand-primary-bg, etc.
CSS Selectors
The build script maps each theme file to CSS class selectors in the format .{mode}-{color}-{contrast}:
| Token file | CSS selectors |
|---|---|
dark/default.json | .dark, .dark-std-std |
light/default.json | .light, .light-std-std |
dark/dimmer.json | .dark-dimmer, .dark-std-low |
light/high-contrast.json |
|
dark/default-protanopia.json |
|
light/dimmer-tritanopia.json |
|
To switch themes at runtime, set the class on <body>:
document.body.className = "dark-std-std";All CSS custom properties update instantly — no JS rerender needed for color changes.
Contrast Modes
Three contrast levels let users tune the visual intensity of the interface. Each level shifts shade steps and alpha values uniformly — no layout or component changes needed.
Standard (default)
The baseline. Optimized for typical displays in well-lit environments. Text-to-background ratios comfortably exceed WCAG AA across all semantic roles.
Low Contrast (dimmer)
Reduces visual intensity for low-light or eye-strain situations. Every token shifts ~1 shade step toward the background: text softens, borders lighten, interactive-state alphas decrease. In dark mode, backgrounds are additionally darkened via LCH transforms to create a deeper, less luminous base.
High Contrast
Amplifies separation between foreground and background for bright environments, small screens, or users who need stronger differentiation. Text shifts ~1 step darker (light mode) or brighter (dark mode), borders become more prominent, and interactive-state alphas increase.
Light mode
| Token | Standard | Dimmer | High Contrast |
|---|---|---|---|
bg.0 | gray.25 | gray.50 | gray.25 |
bg.1 | pure.white | gray.25 | pure.white |
bg.2 | gray.25 | gray.50 | gray.25 |
bg.3 | pure.white | gray.25 | pure.white |
text.1 | pure.black | gray.900 | pure.black |
text.2 | gray.850 | gray.800 | gray.850 |
text.3 | gray.700 | gray.700 | gray.800 |
text.4 | gray.600 | gray.600 | gray.700 |
border.1 | gray.500 | gray.400 | gray.600 |
border.2 | gray.150 | gray.150 | gray.300 |
border.3 | gray.100 | gray.100 | gray.200 |
border.success | lime.500 | lime.400 | lime.600 |
border.danger | red.500 | red.400 | red.600 |
border.warning | yellow.400 | yellow.300 | yellow.500 |
state.hover | gray.200 alpha 0.23 | gray.200 alpha 0.16 | gray.200 alpha 0.30 |
state.selected | gray.300 alpha 0.26 | gray.300 alpha 0.19 | gray.300 alpha 0.34 |
Dark mode
| Token | Standard | Dimmer | High Contrast |
|---|---|---|---|
bg.0 | pure.black | pure.black darken 0.80 | pure.black |
bg.1 | gray.1000 | gray.1000 darken 0.60 | gray.1000 |
bg.2 | gray.950 | gray.950 darken 0.56 | gray.950 |
bg.3 | gray.950 | gray.950 darken 0.56 | gray.950 |
text.1 | gray.25 | gray.150 | pure.white |
text.2 | gray.200 | gray.300 | gray.150 |
text.3 | gray.500 | gray.500 | gray.400 |
text.4 | gray.600 | gray.600 | gray.500 |
text.brand | blue.400 | blue.500 | blue.300 |
border.1 | gray.500 | gray.800 | gray.400 |
border.2 | gray.850 | gray.900 | gray.800 |
border.3 | gray.900 | gray.950 | gray.850 |
border.success | forest.500 | forest.600 | forest.400 |
border.danger | red.600 | red.700 | red.500 |
border.warning | yellow.500 | yellow.600 | yellow.400 |
state.hover | gray.850 alpha 0.20 | gray.850 alpha 0.16 | gray.850 alpha 0.36 |
state.selected | gray.850 alpha 0.25 | gray.850 alpha 0.20 | gray.850 alpha 0.40 |
Color Vision Adaptations
Color-blind modes remap semantic hues so that information is conveyed through brightness hierarchy rather than hue alone. The human retina has three cone types — L (red), M (green), and S (blue). When one type is absent, certain color pairs become indistinguishable. Our adaptations shift the affected hues to perceptually distinct alternatives.
Each CVD type is a separate theme mode (e.g. light-std-protanopia, dark-std-deuteranopia). The remapping is applied at the token level — components need no changes.
Protanopia (red-blind)
Missing L-cones (long wavelength). Red appears dark and muddy, indistinguishable from dark green or brown. Affects ~1.3% of males, ~0.02% of females. Because red is severely darkened, we shift danger to the brighter yellow and cascade other warm hues down. Brand (indigo) remains unchanged since blue-violet perception is intact.
| Role | Default | Adapted | Rationale | |
|---|---|---|---|---|
brand | indigo | — | indigo | Unchanged, blue-violet visible |
success | lime | → | indigo | Green invisible, shift to blue-violet |
danger | red | → | yellow | Red invisible, shift to bright warm |
warning | yellow | → | orange | Darker than danger for hierarchy |
set.blue | blue | → | cyan | Differentiate from brand (indigo) |
set.forest-green | forest | → | cyan | Green invisible, shift to cool blue |
set.mint | mint | → | indigo | Green-teal invisible |
set.orange | orange | → | brown | Freed for warning |
set.pink | pink | → | purple | Red component invisible |
Deuteranopia (green-blind)
Missing M-cones (medium wavelength). Green merges with red into a brownish-khaki range. Affects ~1.2% of males, ~0.01% of females. Similar confusion axis to protanopia, but reds are not darkened — only hue is lost. Brand shifts from indigo to blue for stronger separation from the success role (also remapped to indigo).
| Role | Default | Adapted | Rationale | |
|---|---|---|---|---|
brand | indigo | → | blue | Separate from success (indigo) |
success | lime | → | indigo | Green invisible, shift to blue-violet |
danger | red | → | yellow | Red merges with green, shift to warm |
warning | yellow | → | orange | Darker than danger for hierarchy |
set.forest-green | forest | → | blue | Green invisible, shift to blue |
set.mint | mint | → | indigo | Green-teal invisible |
set.orange | orange | → | brown | Freed for warning |
set.pink | pink | → | purple | Red component invisible |
Tritanopia (blue-yellow blind)
Missing S-cones (short wavelength). Blue merges with green, and yellow becomes indistinguishable from light pink. Very rare — affects ~0.003% of the population, equally across genders (not X-linked). Brand (indigo) stays unchanged since even without S-cones, the luminance channel still differentiates blue from other hues.
| Role | Default | Adapted | Rationale | |
|---|---|---|---|---|
warning | yellow | → | orange | Yellow invisible |
set.orange | orange | → | brown | Freed for warning |
set.purple | purple | → | pink | Blue component confused with green |
set.violet | violet | → | red | Blue component confused with green |