Skip to content

Color Scheme

WAGE uses the CSS-native color-scheme property and light-dark() function to handle light and dark contexts. Components don’t need to know whether they’re light or dark — they use semantic tokens, and the tokens resolve automatically based on the inherited color scheme.

The system has three layers:

  1. Tokens define two values per semantic property — one for light, one for dark
  2. Surfaces (or the surface--dark utility) set color-scheme: dark on an element
  3. Components use semantic tokens that auto-resolve based on the inherited scheme

No manual color overrides. No dark-specific CSS selectors. The cascade handles everything.

Semantic tokens in tokens.php use the CSS light-dark() function. The first value is used in light contexts, the second in dark:

'--text-color' => 'light-dark(var(--base-dark), var(--base-light))',
'--heading-color' => 'light-dark(var(--base-dark), var(--white))',
'--link-color' => 'light-dark(var(--primary), var(--accent-light))',
'--border-color' => 'light-dark(var(--neutral-light), color-mix(in oklch, var(--white) 10%, transparent))',
'--card-bg' => 'light-dark(var(--white), color-mix(in oklch, var(--white) 5%, transparent))',

The full list of adaptive tokens:

TokenLight valueDark value
--body-background-color--base-light--base-dark
--text-color--base-dark--base-light
--text-color-muted--neutral--base-light at 55%
--heading-color--base-dark--white
--link-color--primary--accent-light
--link-color-hover--primary-dark--white
--border-color--neutral-light--white at 10%
--card-bg--white--white at 5%
--modal-bg--base-light--primary-ultra-dark

Child themes can override any of these in their inc/tokens.php.

The framework sets color-scheme: light on :root when outputting tokens:

:root {
color-scheme: light;
--text-color: light-dark(var(--base-dark), var(--base-light));
/* ... all tokens ... */
}

Every page starts in light mode. Dark contexts are opted into per-element.

There are three ways to put an element into dark mode:

Dark surfaces include color-scheme: dark in their CSS definition alongside their visual treatment:

.surface--velvet {
color-scheme: dark;
background: radial-gradient(...);
}

Apply to a section:

<section class="hero surface--velvet">

Everything inside that section — headings, text, links, borders, cards — automatically uses dark token values. See the Surfaces docs for how to create surfaces.

For flipping the color scheme without any visual treatment (no background, no texture):

/* Defined in core.css */
.surface--dark { color-scheme: dark; }

Pass it to any component via the classes property:

echo new \Wage\Header( classes: ['surface--dark'] );
echo new \Wage\TrustStrip( classes: ['surface--dark'] );

Or use it directly in markup:

<div class="surface--dark">
<h2>This heading uses dark tokens</h2>
<p>This text uses dark tokens</p>
</div>

For one-off cases in component CSS:

.my-dark-card {
color-scheme: dark;
background-color: var(--primary-ultra-dark);
}

Components can default to a specific scheme via their constructor:

class Header extends Component {
public function __construct(
array $classes = ['surface--dark'], // dark by default
) {
$this->classes = $classes;
parent::__construct();
}
}

This means new \Wage\Header() is dark, but you can override it:

// Dark (default)
echo new \Wage\Header();
// Light (override)
echo new \Wage\Header( classes: [] );
// Different surface
echo new \Wage\Header( classes: ['surface--velvet'] );

The key rule: use semantic tokens, not raw palette tokens.

/* ✅ Correct — adapts automatically */
.my-component {
color: var(--text-color);
border: 1px solid var(--border-color);
}
.my-component h3 {
color: var(--heading-color);
}
.my-component a {
color: var(--link-color);
}
/* ❌ Wrong — hardcoded to light mode */
.my-component {
color: var(--base-dark);
border: 1px solid var(--neutral-light);
}

If a component uses semantic tokens, it works in both light and dark contexts with zero extra CSS.

Raw palette tokens are fine for things that don’t change between schemes:

  • Brand colors that stay constant: color: var(--accent)
  • Decorative elements: background: var(--primary-dark)
  • Gradients and overlays where you’re explicitly choosing colors

The rule of thumb: if a property should flip between light and dark, use a semantic token. If it should stay the same regardless, use a raw token.

Semantic tokens can be overridden locally on any element:

.my-section {
--heading-color: var(--accent);
}

This overrides the light-dark() default for that element and its descendants, regardless of the color scheme. The heading will be --accent in both light and dark contexts.

The framework includes a debug flag that forces the entire page into dark mode:

  • Enable: visit any page with ?wage=dark-mode
  • Disable: ?wage=-dark-mode
  • Persists via cookie across page loads

When active, it sets color-scheme: dark on :root, which flips every light-dark() token on the page. Useful for testing how components look in dark contexts without wrapping them in a dark surface.

See Feature Flags for more on the flag system.

  • Always use semantic tokens (--heading-color, --text-color, --link-color, --border-color) for colors that should adapt between light and dark.
  • Never write manual dark overrides. No .surface--dark .my-component h2 { color: white } — let light-dark() handle it.
  • Dark surfaces must include color-scheme: dark in their CSS definition.
  • One surface = one class. Never stack surface classes. Each surface is self-contained.
  • Use surface--dark for scheme-only flips. When you need dark tokens but no visual treatment.
  • Components default via constructor. Set array $classes = ['surface--dark'] for components that are always dark.
  • Legacy --surface-dark-* tokens exist but should not be used in new code. Use light-dark() semantic tokens instead.