Agustin Barrientos
All posts
The Senior EyeAug 3, 2026 - 27 min read

Let CSS Pick Your Text Color

Want more of these in your Google results?

A badge takes one accent color and shows a short label. Easy. You set the background to the accent and the text to white, ship it, and it looks right on the blue you tested with. Then someone uses it for a “Sale” badge in brand yellow, and the white label all but disappears. Someone else gives it a pale near-white accent, and the text vanishes outright. The accent changed and nothing else did, because the foreground was a constant the badge never reconsidered.

Drag the picker in this first demo, or hit the presets for yellow, near-white, and a neutral gray, and watch the contrast report flag white while black stays readable underneath.

naive-badge
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { contrastRatio, resolveToSrgb } from '../library/color-tokens-shared/contrast';
import { demoChrome } from '../library/color-tokens-shared/chrome';
import type { SrgbColor } from '../library/color-tokens-shared/types';

/**
 * The WCAG 2 AA threshold for normal-size text, the bar the report holds white against.
 *
 * The report flags a row below this as failing, so the reader sees exactly where a hardcoded white foreground stops being readable.
 */
const AA_NORMAL = 4.5;

/**
 * Black, as an sRGB color, the readable alternative the naive badge throws away.
 */
const BLACK: SrgbColor = { r: 0, g: 0, b: 0, alpha: 1 };

/**
 * White, as an sRGB color, the foreground the naive badge hardcodes.
 */
const WHITE: SrgbColor = { r: 1, g: 1, b: 1, alpha: 1 };

/**
 * One accent worth landing on, paired with a label, so a preset button can name the trap it triggers.
 */
interface AccentPreset {

	/**
	 * The label shown on the preset button.
	 */
	label: string;

	/**
	 * The accent color the button applies, as a CSS color string the picker can also round-trip.
	 */
	accent: string;
}

/**
 * The presets that make the failure reproducible without the reader hunting for a bad color.
 *
 * Each one is a color where a hardcoded white foreground reads poorly or vanishes, so a single click proves the token is more than one value.
 */
const PRESETS: readonly AccentPreset[] = [
	{ label: 'Yellow', accent: '#ffd60a' },
	{ label: 'Near white', accent: '#eef0f2' },
	{ label: 'Neutral gray', accent: '#9aa0a8' },
];

/**
 * D1, the naive token fails.
 *
 * A color input drives a single `--accent`, and the badge hardcodes `color: white`, the junior instinct that treats a token as one value.
 * The contrast report scores white on the accent against the WCAG 2 AA bar and shows black beside it, so the reader watches white fail on a yellow, a near-white, and a neutral-gray accent while black stays readable.
 * The lesson is that "the accent" is a contract that owes a readable foreground on top of the background color it sets.
 *
 * @element demo-color-token-contract-color-tokens-naive-badge
 */
@customElement( 'demo-color-token-contract-color-tokens-naive-badge' )
export class NaiveBadgeDemo extends LitElement {

	/**
	 * The current accent color, driving `--accent` on the badge and the report.
	 *
	 * It starts on the sRGB rendering of the article's canonical accent `oklch(65% 0.18 260)`, a color where white is the weaker pick, so the badge opens on the running example with white already failing.
	 */
	@state()
	private accent = '#488bfb';

	/**
	 * Styles for the harness, composed on the shared demo chrome so this lab matches the others.
	 *
	 * Only the preview badge and the preset row are unique here; the controls, swatch, and report come from `demoChrome`.
	 */
	static styles = [
		demoChrome,
		css`
			.preview {
				display: flex;
				align-items: center;
				gap: var(--space-3, 12px);
				padding: var(--space-4, 16px);
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--bg, #fcfcfb);
			}

			/* The naive badge: the accent is a runtime variable, but the foreground is hardcoded white, which is the bug the report exposes. */
			.badge {
				display: inline-flex;
				align-items: center;
				padding: 0.3em 0.8em;
				border-radius: var(--radius-pill, 999px);
				background: var(--accent, #5566d8);
				color: white;
				font-weight: 600;
				font-size: var(--fs-14, 14px);
			}

			.presets {
				display: flex;
				flex-wrap: wrap;
				gap: var(--space-2, 8px);
			}

			.preset {
				padding: 0.35em 0.7em;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--surface, #fff);
				color: var(--ink, #17171a);
				font: inherit;
				font-size: var(--fs-13, 13px);
				cursor: pointer;
			}

			.preset:hover {
				background: var(--tint, #eef2fe);
			}

			.preset:focus-visible {
				outline: 2px solid var(--accent, #2257e6);
				outline-offset: 2px;
			}
		`,
	];

	/**
	 * Reads the accent back as an sRGB color, or `null` when the current value does not parse.
	 *
	 * Every ratio in the report runs off this one resolved color, so the badge and the numbers can never disagree about what is on screen.
	 */
	private get resolvedAccent(): SrgbColor | null {
		return resolveToSrgb( this.accent );
	}

	/**
	 * Updates the accent from the color input.
	 *
	 * @param event - The input event from the color picker.
	 */
	private onPick = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.accent = event.target.value;
		}
	};

	/**
	 * Applies a preset accent, the one-click path to a color where white fails.
	 *
	 * @param accent - The preset accent to apply.
	 */
	private applyPreset( accent: string ): void {
		this.accent = accent;
	}

	/**
	 * Renders one report row for a candidate foreground on the current accent.
	 *
	 * It scores the candidate against the accent, prints the ratio, and tags the verdict pass or fail at the AA bar, so white and black sit side by side with the same yardstick.
	 *
	 * @param label - The row label naming the candidate foreground.
	 * @param candidate - The candidate foreground color in sRGB.
	 * @param accent - The resolved accent the candidate sits on.
	 * @returns The rendered report row.
	 */
	private reportRow( label: string, candidate: SrgbColor, accent: SrgbColor ) {
		const ratio = contrastRatio( candidate, accent );
		const passes = ratio >= AA_NORMAL;

		return html`
			<div class="report-row">
				<span class="report-label">${label}</span>
				<span class="report-ratio">${ratio.toFixed( 2 )}:1</span>
				<span class="report-verdict" data-pass=${passes ? 'true' : 'false'}>${passes ? 'AA pass' : 'AA fail'}</span>
			</div>
		`;
	}

	/**
	 * Renders the picker, the presets, the naive badge, and the contrast report.
	 */
	render() {
		const accent = this.resolvedAccent;

		return html`
			<div class="demo">
				<h3 class="demo-title">A badge that hardcodes color: white</h3>
				<div class="controls">
					<div class="control">
						<label class="control-label" for="accent">Accent</label>
						<input id="accent" type="color" .value=${this.accent} @input=${this.onPick} />
						<span class="control-value">${this.accent}</span>
					</div>
				</div>
				<div class="presets">
					${PRESETS.map(
						( preset ) => html`
							<button type="button" class="preset" @click=${() => this.applyPreset( preset.accent )}>
								${preset.label}
							</button>
						`,
					)}
				</div>
				<div class="preview">
					<span class="badge" style="--accent:${this.accent};">Featured</span>
					<span class="swatch" style="background:${this.accent};"></span>
				</div>
				${accent
					? html`
						<div class="report">
							${this.reportRow( 'White on accent (the badge)', WHITE, accent )}
							${this.reportRow( 'Black on accent (thrown away)', BLACK, accent )}
						</div>
					`
					: html`<p class="demo-note">That value is not a color, so there is nothing to score.</p>` }
				<p class="demo-note">
					The badge always paints <strong>white</strong> text.
					On a yellow, a near-white, or a neutral-gray accent that white drops below the AA bar while black stays readable, so the token owes a foreground decision on top of the background it sets.
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-naive-badge': NaiveBadgeDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The badge that produced that is four lines:

scratch.css
/* scratch.css, the naive badge */
.badge {
	background: var(--accent);
	color: white;
}

Nothing there is wrong, exactly. It just treats the token as a single value, when an accent a brand can set is really a promise about a whole family of states. White text is one hardcoded answer to one of them. Modern CSS color functions can derive most of that family from the one accent, in the stylesheet, with the component’s JavaScript reduced to guarding its own door. A color token is a contract, and this article is about reading it, sorting the promises CSS can now keep from the ones that still belong to your design system.

A quick note on letting AI code this

Hand this to an AI and the color-function syntax comes back in seconds, fluently, because that part is well-trodden, so let it write the color-mix() and the relative-color math. The output still sits on a wide quality scale, and what you bring is the contract. Ask vaguely for “a badge that takes an accent” and you get a wall of hand-picked tokens or a hardcoded white foreground, while naming the promises gets you the derived ladder. The engineer stays accountable for defining that contract and for reviewing whether the model met it, and that judgment only matters more as the models improve.

The contract hiding in one accent

Read the token as a set of promises the badge makes to any color it accepts, and ask, for each state, whether CSS can derive it or the design system still has to decide. The answer differs by row, and getting it wrong means reimplementing what the platform now does or shipping a state the accent never owned.

Table Each state the badge owes, who derives it, and the judgment that stays yours.
State the badge must serveJunior instinctCSS that derives itThe senior question
Higher-contrast foregroundcolor: whitecontrast-color(var(--accent))Is black or white actually your brand’s answer?
Border tinted from the accenta second hand-picked tokencolor-mix(in oklab, var(--accent) 62%, canvas)What are you mixing with, and does it follow the theme?
Tonal varianta third hand-picked tokenoklch(from var(--accent) calc(l - 0.12) c h)Which channel changes, and by how much on a 0 to 1 scale?
Theme brancha JavaScript theme objectlight-dark() or a color-mix with canvasWho owns light versus dark, your CSS or the OS?
Unsupported pathship and hope@supports tiersIs the contract still true without the function?

That table is most of the article, and the build answers it row by row.

One promise isn’t in the table, because no single function captures it. A color from your design system, a value from a color input, an inherited custom property, and an arbitrary string a user typed are four different guarantees. “Accept a color” quietly turning into “accept any string” is how a component grows a worse risk profile, so a senior validates the untrusted ones before they ever become a CSS value. Input trust is the last promise, and this badge answers it with a door of its own, a validated property, while the inherited token stays the trusted design-system path.

The bar we’re holding it to

The finished <smart-badge> should:

  • take one opaque accent through two doors, a validated accent property for a single badge and the inherited --smart-badge-accent token an ancestor sets for a whole subtree, across named, hex, hsl, and oklch inputs, with everything untrusted validated at the door before it becomes the token;
  • fill with the raw accent and derive the higher-contrast foreground, black or white, with contrast-color(), picked against that fill, and show that the result is black for the canonical accent;
  • derive a border with color-mix() against canvas, so the edge follows the theme;
  • derive a tonal variant with relative color syntax using unitless numbers, never percentages;
  • support light and dark themes with color-scheme: light dark declared, so light-dark() and the canvas mixes resolve;
  • degrade through @supports tiers that keep the whole default badge where the modern color functions are missing, and stay honest that a custom runtime accent takes effect only where its whole family can derive;
  • carry only the states a status badge actually has, and keep the product decisions, which colors are allowed and what the foreground should be, author-owned.

One concrete case holds it honest. Set the accent to the medium blue oklch(65% 0.18 260) and the platform picks black for the foreground on that fill, not white (step 2 has the move for when the brand wanted white). Slide the lightness down to 25 percent and the pick flips to white, since lightness drives the binary.

How to read the accent

That accent notation is oklch(), and its three numbers have separate jobs.

  1. Lightness, 0 to 100 percent. It’s perceptual, so equal steps look like equal changes and two different hues at the same lightness carry the same visual weight, the promise HSL’s lightness makes and does not keep.
  2. Chroma, the distance from gray. 0 is achromatic, and 0.18 is a solid mid-strength color. It’s not HSL’s saturation, which is relative, a percentage of the most vivid color available at that hue and lightness, while chroma is an absolute distance, so the same number carries the same visual strength on every hue, and a chroma can simply exceed what sRGB can display, which the demo below flags when it happens.
  3. Hue, the angle around the wheel, where 260 lands on blue.

A hex value stores the same color as three channel bytes, which is why every derivation in this article edits an oklch channel instead, since a darker dot is one lightness subtraction while “darker” in hex means changing all three bytes at once.

Drag the sliders and read what the string resolves to, the rgb() and the hex spelling of the same color:

oklch-dials
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { resolveToSrgb } from '../library/color-tokens-shared/contrast';
import { demoChrome } from '../library/color-tokens-shared/chrome';
import { parse, rgb } from 'culori';
import type { SrgbColor } from '../library/color-tokens-shared/types';

/**
 * Whether the engine parses `oklch()` at all, checked once so the readback can say when the floor hex is what actually paints.
 *
 * On an engine without the function the swatch's literal `oklch()` declaration fails to parse and the hex declaration before it holds, the same floor pattern the component uses in step 7, so the sliders stop driving the paint and the demo says so instead of pretending.
 */
const SUPPORTS_OKLCH = CSS.supports( 'color', 'oklch(65% 0.18 260)' );

/**
 * The canonical accent's slider positions, the values every slider opens on.
 *
 * They spell the article's running `oklch(65% 0.18 260)`, so the first readback the reader sees is the accent resolving to `rgb(72, 139, 251)` and the hex `#488bfb` the Tier 0 floor uses.
 */
const SEED = { lightness: 65, chroma: 0.18, hue: 260 } as const;

/**
 * D2, the oklch sliders, where the accent's three numbers become three separate controls.
 *
 * Each slider is a gradient strip that previews its whole range at the other two sliders' current values, lightness from black to white, chroma from the neutral gray axis out past the sRGB edge, where the painted strip saturates, and hue around the wheel, so moving one slider visibly repaints the range of the other two.
 * The swatch paints the literal `oklch()` string the sliders spell, and the readback prints the `rgb()` and hex the string resolves to through the shared culori resolver, since modern engines keep a computed `oklch()` in its oklch form.
 * The strips and the swatch each declare a hex or neutral floor before their `oklch()` value, the same two-declaration fallback the component's Tier 0 uses, so an engine without the function paints the floors and the demo flags that the sliders are no longer in charge.
 * Pushing the sliders outside sRGB splits the readback in two, the channel clip an sRGB screen approximately paints and the chroma-walked spelling the CSS mapping algorithm defines, since past the boundary one honest hex stops existing and any single answer is a policy, the gamut boundary step 5 returns to.
 * The lightness poles are the split at its widest, since the mapping answers plain white at or above 100 percent and plain black at or below 0 whatever the chroma and hue say, while the clip keeps only a bent tint, dropping most of the chroma and letting the hue drift, so the demo words each pole's message for its own answer instead of letting a white answer under a tinted sample read as a bug.
 *
 * @element demo-color-token-contract-color-tokens-oklch-dials
 */
@customElement( 'demo-color-token-contract-color-tokens-oklch-dials' )
export class OklchDialsDemo extends LitElement {

	/**
	 * The lightness slider on the oklch 0 to 100 percent scale, perceptual rather than HSL's arithmetic lightness.
	 */
	@state()
	private lightness: number = SEED.lightness;

	/**
	 * The chroma slider, distance from gray, where 0 is achromatic and the top of the range sits deliberately past what sRGB can display.
	 */
	@state()
	private chroma: number = SEED.chroma;

	/**
	 * The hue slider in degrees around the wheel, where 260 is the article's blue.
	 */
	@state()
	private hue: number = SEED.hue;

	/**
	 * Styles for the harness, composed on the shared demo chrome.
	 *
	 * The gradient slider strips come from the chrome; only the preview swatch, the readback row, and the value samples are local.
	 */
	static styles = [
		demoChrome,
		css`
			.preview-swatch {
				height: 4.5rem;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
			}

			.readback {
				display: flex;
				flex-wrap: wrap;
				align-items: center;
				gap: var(--space-2, 8px);
				font-size: var(--fs-13, 13px);
				color: var(--ink-soft, #56565c);
			}

			/* A small rendered sample beside each printed color value, so the reader sees the color the string names; the oklch and hex samples only disagree when a wide-gamut screen can outrun the sRGB spelling. */
			.chip-swatch {
				display: inline-block;
				width: 0.9em;
				height: 0.9em;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: 3px;
			}
		`,
	];

	/**
	 * The literal `oklch()` string the three sliders currently spell, the exact value the swatch paints.
	 */
	private get oklchString(): string {
		return `oklch(${this.lightness}% ${this.chroma} ${this.hue})`;
	}

	/**
	 * Whether the current sliders leave the sRGB gamut, converted without mapping so an out-of-range channel is visible.
	 *
	 * The check is about the sRGB spelling the readback prints rather than the paint, since a wide-gamut display can render past sRGB, so the flag names the moment the rgb() and hex answers stop being the whole story.
	 */
	private get outOfSrgbGamut(): boolean {
		const parsed = parse( this.oklchString );

		if ( parsed === undefined ) {
			return false;
		}

		const converted = rgb( parsed );

		if ( converted === undefined ) {
			return false;
		}

		return [ converted.r, converted.g, converted.b ].some( ( channel ) => channel < 0 || channel > 1 );
	}

	/**
	 * The sliders' color resolved to sRGB through the shared culori resolver, the same chroma-reduction strategy CSS defines, or `null` when the string does not resolve.
	 *
	 * This is the demo's readback source instead of the computed style, because modern engines serialize a computed `oklch()` background in its oklch form, which would answer the "what rgb and hex is this" question with the question.
	 */
	private get resolved(): SrgbColor | null {
		return resolveToSrgb( this.oklchString );
	}

	/**
	 * Which lightness pole the sliders sit on with chroma still asked for, or `null` when they are off both poles.
	 *
	 * CSS Color 4's gamut mapping returns plain white for a lightness at or above 100 percent and plain black at or below 0, whatever the chroma and hue say, while the paint clips channels instead, so each pole gets a message worded for its own answer rather than a white-worded message over a black result.
	 */
	private get lightnessPole(): 'white' | 'black' | null {
		if ( this.chroma <= 0 ) {
			return null;
		}

		if ( this.lightness >= 100 ) {
			return 'white';
		}

		if ( this.lightness <= 0 ) {
			return 'black';
		}

		return null;
	}

	/**
	 * The raw sRGB conversion with each overflowing channel clamped at its limit, the spelling an sRGB screen approximately paints for an out-of-gamut request.
	 *
	 * Engines do not run the CSS mapping algorithm when rendering, they clip the channels that overshoot, so this answer matches the vivid paint while the shared resolver's answer matches the spec, and the demo prints both once they diverge.
	 */
	private get clipped(): SrgbColor | null {
		const parsed = parse( this.oklchString );

		if ( parsed === undefined ) {
			return null;
		}

		const converted = rgb( parsed );

		if ( converted === undefined ) {
			return null;
		}

		const clamp = ( channel: number ): number => {
			return Math.min( 1, Math.max( 0, channel ) );
		};

		return {
			r: clamp( converted.r ),
			g: clamp( converted.g ),
			b: clamp( converted.b ),
			alpha: 1,
		};
	}

	/**
	 * The lightness strip's gradient, black to white at the current chroma and hue, so the slider previews exactly what moving it would paint.
	 */
	private get lightnessTrack(): string {
		const stops = this.trackStops( 11, ( t ) => `oklch(${Math.round( t * 100 )}% ${this.chroma} ${this.hue})` );

		return `linear-gradient(to right, ${stops})`;
	}

	/**
	 * The chroma strip's gradient, from the neutral gray axis out past the sRGB edge at the current lightness and hue, the fan of the reference diagrams unrolled into a strip.
	 */
	private get chromaTrack(): string {
		const stops = this.trackStops( 9, ( t ) => `oklch(${this.lightness}% ${( t * 0.4 ).toFixed( 2 )} ${this.hue})` );

		return `linear-gradient(to right, ${stops})`;
	}

	/**
	 * The hue strip's gradient, the wheel unrolled from 0 to 360 degrees at the current lightness and chroma.
	 */
	private get hueTrack(): string {
		const stops = this.trackStops( 13, ( t ) => `oklch(${this.lightness}% ${this.chroma} ${Math.round( t * 360 )})` );

		return `linear-gradient(to right, ${stops})`;
	}

	/**
	 * Builds an evenly spaced gradient stop list from a color-per-position function.
	 *
	 * @param count - How many stops to place from 0 to 100 percent inclusive.
	 * @param colorAt - The color for a position, where the argument runs 0 to 1.
	 * @returns The comma-joined `color percent%` stop list.
	 */
	private trackStops( count: number, colorAt: ( t: number ) => string ): string {
		const stops: string[] = [];

		for ( let i = 0; i < count; i += 1 ) {
			const t = i / ( count - 1 );
			stops.push( `${colorAt( t )} ${( t * 100 ).toFixed( 1 )}%` );
		}

		return stops.join( ', ' );
	}

	/**
	 * Formats a resolved sRGB color as the `rgb()` string a legacy notation would spell it as.
	 *
	 * @param color - The resolved sRGB color with channels on a 0 to 1 scale.
	 * @returns The `rgb(r, g, b)` string with 0 to 255 channel bytes.
	 */
	private toRgbText( color: SrgbColor ): string {
		const byte = ( channel: number ): number => {
			return Math.round( channel * 255 );
		};

		return `rgb(${byte( color.r )}, ${byte( color.g )}, ${byte( color.b )})`;
	}

	/**
	 * Formats an sRGB color as a lowercase hex string, the spelling the Tier 0 floor uses.
	 *
	 * @param color - The resolved sRGB color with channels on a 0 to 1 scale.
	 * @returns The `#rrggbb` hex string.
	 */
	private toHex( color: SrgbColor ): string {
		const byte = ( channel: number ): string => {
			return Math.round( channel * 255 ).toString( 16 ).padStart( 2, '0' );
		};

		return `#${byte( color.r )}${byte( color.g )}${byte( color.b )}`;
	}

	/**
	 * Updates the lightness slider from its strip.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onLightness = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.lightness = Number( event.target.value );
		}
	};

	/**
	 * Updates the chroma slider from its strip.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onChroma = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.chroma = Number( event.target.value );
		}
	};

	/**
	 * Updates the hue slider from its strip.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onHue = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.hue = Number( event.target.value );
		}
	};

	/**
	 * Renders the three gradient sliders, the painted swatch, and the resolved rgb and hex readback.
	 */
	render() {
		const outOfGamut = this.outOfSrgbGamut;
		const pole = this.lightnessPole;
		const resolved = this.resolved;
		const clipped = this.clipped;

		return html`
			<div class="demo">
				<h3 class="demo-title">Three sliders, one color</h3>
				<div class="sliders">
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="l-slider">Lightness</label>
							<span class="control-value">${this.lightness}%</span>
						</div>
						<input
							id="l-slider"
							class="slider-strip"
							type="range"
							min="0"
							max="100"
							step="1"
							.value=${String( this.lightness )}
							@input=${this.onLightness}
							style="background: var(--line, #e7e6e2); background: ${this.lightnessTrack};"
						/>
						<p class="slider-hint">Black to white, independent of the other two sliders.</p>
					</div>
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="c-slider">Chroma</label>
							<span class="control-value">${this.chroma}</span>
						</div>
						<input
							id="c-slider"
							class="slider-strip"
							type="range"
							min="0"
							max="0.4"
							step="0.005"
							.value=${String( this.chroma )}
							@input=${this.onChroma}
							style="background: var(--line, #e7e6e2); background: ${this.chromaTrack};"
						/>
						<p class="slider-hint">Neutral gray out to as vivid as it gets, an absolute distance, not a relative saturation.</p>
					</div>
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="h-slider">Hue</label>
							<span class="control-value">${this.hue}</span>
						</div>
						<input
							id="h-slider"
							class="slider-strip"
							type="range"
							min="0"
							max="360"
							step="1"
							.value=${String( this.hue )}
							@input=${this.onHue}
							style="background: var(--line, #e7e6e2); background: ${this.hueTrack};"
						/>
						<p class="slider-hint">The color family around the wheel, red near 30, green near 145, blue at 260.</p>
					</div>
				</div>
				<!-- The hex floor before the literal oklch() is the component's own Tier 0 pattern, so an engine without the function paints the floor instead of nothing. -->
				<div class="preview-swatch" style="background: #488bfb; background: ${this.oklchString};"></div>
				<p class="readback">
					<span>You wrote</span>
					<span class="chip-swatch" aria-hidden="true" style="background: #488bfb; background: ${this.oklchString};"></span>
					<span class="code-chip">${this.oklchString}</span>
					${resolved && ! outOfGamut
						? html`
							<span>which is</span>
							<span class="chip-swatch" aria-hidden="true" style="background: ${this.toHex( resolved )};"></span>
							<span class="code-chip">${this.toRgbText( resolved )}</span>
							<span class="code-chip">${this.toHex( resolved )}</span>
						`
						: null }
					${resolved && clipped && outOfGamut
						? html`
							<span>an sRGB screen shows about</span>
							<span class="chip-swatch" aria-hidden="true" style="background: ${this.toHex( clipped )};"></span>
							<span class="code-chip">${this.toRgbText( clipped )}</span>
							<span class="code-chip">${this.toHex( clipped )}</span>
							<span>while the CSS mapping spells it</span>
							<span class="chip-swatch" aria-hidden="true" style="background: ${this.toHex( resolved )};"></span>
							<span class="code-chip">${this.toRgbText( resolved )}</span>
							<span class="code-chip">${this.toHex( resolved )}</span>
						`
						: null }
				</p>
				${SUPPORTS_OKLCH
					? null
					: html`
						<p class="readback">
							<span class="report-verdict" data-pass="false">no oklch() here</span>
							<span>This browser does not parse the function, so the hex floor is what paints and the sliders no longer drive the swatch.</span>
						</p>
					` }
				${pole === 'white' && SUPPORTS_OKLCH
					? html`
						<p class="readback">
							<span class="report-verdict" data-pass="false">lightness pole</span>
							<span>Nothing at white's own lightness can also be vivid, so the contradiction has to break one way or the other. The clip keeps a visible tint but bends it, dropping most of the chroma and letting the hue drift, which is the working group's own complaint about clipping, while the mapping algorithm keeps the lightness and answers plain white, the same policy that pins a runaway calc(l + 20) to white. Past the gamut there is no single true hex, only a policy.</span>
						</p>
					`
					: null }
				${pole === 'black' && SUPPORTS_OKLCH
					? html`
						<p class="readback">
							<span class="report-verdict" data-pass="false">lightness pole</span>
							<span>Nothing at black's own lightness can also be vivid, so the same contradiction breaks here the other way. The clip keeps a faint bent tint, which is what you are seeing, while the mapping algorithm keeps the lightness and answers plain black, the mirror of the rule that pins a runaway calc(l + 20) to white. Past the gamut there is no single true hex, only a policy.</span>
						</p>
					`
					: null }
				${outOfGamut && pole === null && SUPPORTS_OKLCH
					? html`
						<p class="readback">
							<span class="report-verdict" data-pass="false">outside sRGB</span>
							<span>These numbers overshoot what sRGB can hold, so one honest hex stops existing and the demo prints two. The paint keeps the vividness by clipping each channel at its limit, while the CSS mapping algorithm keeps the lightness and walks chroma down, and a wide-gamut display can sit past both.</span>
						</p>
					`
					: null }
				<p class="demo-note">
					Each strip previews its slider's range at the other two sliders' current values, so moving one repaints the other two.
					The sliders open on the article's canonical accent, which resolves to <span class="chip-swatch" aria-hidden="true" style="background: #488bfb;"></span> <span class="code-chip">rgb(72, 139, 251)</span>, exactly the <span class="code-chip">#488bfb</span> the Tier 0 floor spells it as in step 7.
					Lightness is the perceptual slider, so equal steps look like equal changes, the promise HSL's lightness does not keep.
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-oklch-dials': OklchDialsDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The sliders open on the canonical accent, which resolves to rgb(72, 139, 251), exactly #488bfb, the same color in the oldest spelling every engine parses. Step 7 leans on that equality when the fallback floor needs the color without the function. Push chroma high or lightness far and the numbers stop naming a color sRGB can hold, and past that line one honest hex stops existing, so the demo prints two spellings, the channel clip your screen approximately shows and the chroma-walked answer the CSS mapping algorithm defines, a boundary step 5 comes back to. Ride lightness to 100 percent with chroma still up and the two split as far as they can, the clip keeping only a bent, faded tint while the mapping answers plain white, since nothing at white’s own lightness can also be colorful, the same policy that pins a runaway calc(l + 20) to white.

What you’ll need

Be comfortable with TypeScript and the DOM. No Lit experience required, since it’s a thin layer over web components and I’ll explain the Lit-specific parts as they come up. Beyond that you’ll want Lit, your browser’s devtools (the Styles panel for the computed tokens, the color picker for its contrast readout), and the console for a CSS.supports() check. No design tool, because the work here is the contract and the code.

One feature in the build isn’t safe to assume everywhere yet. As of the time of writing, contrast-color() is Baseline Newly available, shipping in the current Chrome, Edge, Firefox, and Safari but not on every engine your users run, so the badge keeps a fallback for it. Its support table is in step 2.

Try it yourself first

Give it a real attempt before reading on. Fill the badge with var(--accent) and start the foreground from contrast-color(var(--accent)) instead of a hardcoded color, then derive the border and the tonal dot from the same accent with color-mix() and relative color syntax. Notice where you stop being able to let CSS decide, the moment you ask “but is that the color my brand actually wants,” because that line is the lesson.

Building it

Step 1: the naive token, and why it breaks

Start where most badges start, with the accent as the background and a hardcoded foreground:

scratch.css
/* scratch.css, the naive badge */
.badge {
	background: var(--accent);
	color: white;
}

That first demo runs exactly this and scores the foreground on the WCAG 2 bar. On the canonical oklch(65% 0.18 260) blue, white lands around 3.3 to 1, under the 4.5 to 1 it wants for normal text, while black sits near 6.37 to 1 and passes. The easy mistake is reading the preset failures as “yellow is a bad accent” and banning it. The fix is to stop hardcoding the foreground and let CSS compute it, against the fill the badge actually paints.

Step 2: the foreground the platform can pick

contrast-color() takes a color and returns black or white, whichever has the higher contrast against it. That’s the whole function. It replaces the hardcoded foreground with one derived against the accent the badge is filled with:

scratch.css
/* scratch.css */
.badge {
	background: var(--accent);
	color: contrast-color(var(--accent));
}

Now the foreground tracks the accent, and CSS is picking your text color, the title’s promise made literal. Drive the lightness slider in this lab and watch the pick flip between black and white, pinned so the canonical accent opens on black, with both WCAG ratios beside it.

contrast-lab
import { LitElement, html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { pickForeground } from '../library/color-tokens-shared/contrast';
import { demoChrome } from '../library/color-tokens-shared/chrome';

/**
 * The WCAG 2 AA threshold for normal-size text, the bar the report reads each ratio against.
 */
const AA_NORMAL = 4.5;

/**
 * The lightness, on the oklch 0 to 100 percent scale, at which the canonical accent lands so its foreground pick is black.
 *
 * It is the article's pinned value, so the lab opens on the central lesson rather than asking the reader to find it.
 */
const CANONICAL_LIGHTNESS = 65;

/**
 * Whether the engine ships `contrast-color()`, checked once so the lab can show the live browser pick beside the WCAG 2 comparison when it exists.
 *
 * When it is missing the WCAG 2 comparison drives the badge and explains the black-versus-white result, and where `contrast-color()` runs the live browser pick is the authoritative one, since CSS Color 5 leaves the exact algorithm user-agent defined.
 */
const SUPPORTS_CONTRAST_COLOR = CSS.supports( 'color', 'contrast-color(red)' );

/**
 * D3, the contrast-color lab, where the platform picks the foreground and the pick is often black.
 *
 * A gradient-strip lightness slider moves a source `oklch(L 0.18 260)`, its track painting the slider's whole range at the pinned chroma and hue, opening on the canonical accent where black wins, and the report prints both WCAG ratios so the reader sees black beat white on a medium blue.
 * Where `contrast-color()` ships, the badge's foreground is the live `contrast-color(var(--accent))` result, read back and shown beside the WCAG 2 comparison, which lands on the same result for these accents; where it does not, the WCAG 2 comparison drives the badge.
 * The senior move is the closing beat: a brand that wants white changes the contract by darkening the accent until white is the higher-contrast pick, rather than overriding the browser's pick by hand.
 *
 * @element demo-color-token-contract-color-tokens-contrast-lab
 */
@customElement( 'demo-color-token-contract-color-tokens-contrast-lab' )
export class ContrastLabDemo extends LitElement {

	/**
	 * The source lightness on the oklch 0 to 100 percent scale, driven by the slider.
	 *
	 * Chroma and hue are pinned, so lightness alone flips the foreground and the demo opens on the canonical accent's value.
	 */
	@state()
	private lightness = CANONICAL_LIGHTNESS;

	/**
	 * The badge whose foreground is the live `contrast-color()` result, read back to display when the function is supported.
	 */
	@query( '.platform-badge' )
	private $platformBadge?: HTMLElement;

	/**
	 * The computed foreground the platform painted, read from the badge after each render, or an empty string before the first read.
	 */
	@state()
	private platformForeground = '';

	/**
	 * Styles for the harness, composed on the shared demo chrome.
	 *
	 * The badge fill is the source accent; the platform badge sets its foreground with `contrast-color()` so the engine, not the JavaScript, paints it.
	 */
	static styles = [
		demoChrome,
		css`
			.badges {
				display: flex;
				flex-wrap: wrap;
				gap: var(--space-3, 12px);
			}

			.badge {
				display: inline-flex;
				align-items: center;
				padding: 0.4em 0.9em;
				border-radius: var(--radius-pill, 999px);
				background: var(--accent-source, oklch(65% 0.18 260));
				font-weight: 600;
				font-size: var(--fs-14, 14px);
			}

			/* The JavaScript badge paints the WCAG 2 winner the shared helper returns, so it works on every engine. */
			.js-badge {
				color: var(--js-foreground, black);
			}

			/* The platform badge lets contrast-color() pick, and the lab reads that pick back to show it beside the WCAG 2 comparison. */
			.platform-badge {
				color: contrast-color(var(--accent-source, oklch(65% 0.18 260)));
			}

			.badge-stack {
				display: flex;
				flex-direction: column;
				gap: var(--space-1, 4px);
				align-items: flex-start;
			}

			.badge-caption {
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
			}

			.senior {
				display: flex;
				flex-wrap: wrap;
				align-items: center;
				gap: var(--space-3, 12px);
				padding: var(--space-3, 12px);
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--tint, #eef2fe);
			}

			.senior-button {
				padding: 0.4em 0.8em;
				border: 1px solid var(--accent, #2257e6);
				border-radius: var(--radius-sm, 7px);
				background: var(--surface, #fff);
				color: var(--accent, #2257e6);
				font: inherit;
				font-size: var(--fs-13, 13px);
				font-weight: 600;
				cursor: pointer;
			}

			.senior-button:focus-visible {
				outline: 2px solid var(--accent, #2257e6);
				outline-offset: 2px;
			}

			.senior-text {
				margin: 0;
				font-size: var(--fs-13, 13px);
				color: var(--ink-soft, #56565c);
			}
		`,
	];

	/**
	 * Reads the platform foreground back after each render so the printed value matches what `contrast-color()` painted.
	 *
	 * It only reads when the function is supported and the badge exists, so an engine without `contrast-color()` simply shows the WCAG 2 winner.
	 */
	updated(): void {
		if ( ! SUPPORTS_CONTRAST_COLOR || ! this.$platformBadge ) {
			return;
		}

		const next = getComputedStyle( this.$platformBadge ).color;

		if ( next !== this.platformForeground ) {
			this.platformForeground = next;
		}
	}

	/**
	 * The current source accent as an oklch string, built from the pinned chroma and hue and the slider lightness.
	 *
	 * Keeping chroma and hue fixed makes lightness the only variable, so the foreground flip is attributable to one slider.
	 */
	private get accent(): string {
		return `oklch(${this.lightness}% 0.18 260)`;
	}

	/**
	 * The lightness strip's gradient, the slider's whole range painted at the pinned chroma and hue, so the control previews exactly what moving it selects.
	 */
	private get lightnessTrack(): string {
		const stops: string[] = [];

		for ( let i = 0; i < 13; i += 1 ) {
			const t = i / 12;
			const lightness = Math.round( 20 + t * 75 );
			stops.push( `oklch(${lightness}% 0.18 260) ${( t * 100 ).toFixed( 1 )}%` );
		}

		return `linear-gradient(to right, ${stops.join( ', ' )})`;
	}

	/**
	 * Updates the source lightness from the slider.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onLightness = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.lightness = Number( event.target.value );
		}
	};

	/**
	 * Changes the contract so white becomes the higher-contrast pick, the senior alternative to overriding the foreground.
	 *
	 * It darkens the accent by lowering lightness until black no longer wins, which is what a brand that wants white text should do rather than shipping the lower-contrast pair.
	 */
	private darkenForWhite = (): void => {
		this.lightness = 38;
	};

	/**
	 * Renders one report row for a candidate foreground, with its ratio and AA verdict.
	 *
	 * @param label - The row label naming the candidate.
	 * @param ratio - The WCAG ratio of the candidate on the accent.
	 * @param isWcagWinner - Whether this candidate wins the WCAG 2 comparison.
	 * @returns The rendered report row.
	 */
	private reportRow( label: string, ratio: number, isWcagWinner: boolean ) {
		const passes = ratio >= AA_NORMAL;

		return html`
			<div class="report-row">
				<span class="report-label">${label}${isWcagWinner ? ' (WCAG 2 winner)' : ''}</span>
				<span class="report-ratio">${ratio.toFixed( 2 )}:1</span>
				<span class="report-verdict" data-pass=${passes ? 'true' : 'false'}>${passes ? 'AA pass' : 'AA fail'}</span>
			</div>
		`;
	}

	/**
	 * Renders the slider, the badges, the dual-ratio report, and the change-the-contract panel.
	 */
	render() {
		const pick = pickForeground( this.accent );

		return html`
			<div class="demo" style="--accent-source:${this.accent}; --js-foreground:${pick?.foreground ?? 'black'};">
				<h3 class="demo-title">Lightness drives the foreground pick</h3>
				<div class="sliders">
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="lightness">Lightness</label>
							<span class="control-value">${this.lightness}%</span>
						</div>
						<input
							id="lightness"
							class="slider-strip"
							type="range"
							min="20"
							max="95"
							step="1"
							.value=${String( this.lightness )}
							@input=${this.onLightness}
							style="background: var(--line, #e7e6e2); background: ${this.lightnessTrack};"
						/>
						<p class="slider-hint">Only lightness moves; chroma stays 0.18 and hue 260, so the foreground flip is attributable to one thing.</p>
					</div>
				</div>
				<div class="badges">
					<div class="badge-stack">
						<span class="badge js-badge">Featured</span>
						<span class="badge-caption">WCAG 2 winner: ${pick?.foreground ?? 'n/a'}</span>
					</div>
					${SUPPORTS_CONTRAST_COLOR
						? html`
							<div class="badge-stack">
								<span class="badge platform-badge">Featured</span>
								<span class="badge-caption">contrast-color(): ${this.platformForeground || 'reading'}</span>
							</div>
						`
						: html`
							<div class="badge-stack">
								<span class="badge-caption">contrast-color() is not supported here, so the badge falls back to the WCAG 2 winner.</span>
							</div>
						` }
				</div>
				${pick
					? html`
						<div class="report">
							${this.reportRow( 'Black on accent', pick.blackRatio, pick.foreground === 'black' )}
							${this.reportRow( 'White on accent', pick.whiteRatio, pick.foreground === 'white' )}
						</div>
					`
					: html`<p class="demo-note">That accent does not parse, so there is nothing to score.</p>` }
				<p class="demo-note">
					On the canonical <span class="code-chip">oklch(65% 0.18 260)</span> <strong>black</strong> wins, where a brand reflexively reaches for white.
					Slide lightness down and the winner flips to white, so the binary tracks lightness, and a passing ratio can still read poorly on a mid-tone, which is why the mid-tones still want a human eye.
					At the slider's far ends <span class="code-chip">oklch(L 0.18 260)</span> leaves the sRGB gamut, where the browser paints a chroma-reduced mapping and the report scores the accent mapped the same way rather than the raw string.
				</p>
				<div class="senior">
					<button type="button" class="senior-button" @click=${this.darkenForWhite}>Make white readable</button>
					<p class="senior-text">
						When the brand wants white, change the contract by darkening the accent until white earns the higher-contrast pick, rather than overriding the browser's pick by hand.
					</p>
				</div>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-contrast-lab': ContrastLabDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The lesson is what the pick actually is. For oklch(65% 0.18 260) it’s black, and that surprises people who reach for white on a colored button. The platform is right here, but contrast-color() is useful and incomplete at once. It picks the better of black or white, never a tinted foreground, and even the higher-contrast of the two can read poorly on a mid-tone. Lea Verou makes the sharper point that in some tests WCAG 2.1 contrast “performs almost as bad as random chance for any color that is not very light or very dark.” The precise algorithm is user-agent defined, so the lab’s JavaScript readout is a WCAG 2 comparison that explains the black-versus-white result, not a promise about the browser’s own pick. Where contrast-color() runs, the live browser result is the one that ships. So the contract the badge can keep is the higher-contrast of black and white, not guaranteed readability for every accent; promising that stronger pair would mean restricting the accepted accents or validating the result, a product policy the function does not carry.

The real senior move is what you do with the black. When the brand wants white on this blue, you don’t override the higher-contrast pick and ship the worse ratio. You change the contract instead, darkening the accent until white wins or switching to a stronger variant. The platform surfaces the conflict, but the product decision stays yours.

This is also the one function not broadly supported yet, so it earns a table.

Browser supportcontrast-color80% of users
Chrome147
Edge147
Firefox146
Safari26
Safari iOS26
Chrome Android147

Browser data updated September 10, 2026

As of the time of writing it’s Baseline Newly available, which is why step 7 keeps an @supports fallback for it. Everything newly Baseline from here gets that same guard rather than an assumption. The shipped function is the plain two-color pick, the minimum CSS Color 5 took in, while candidate lists and algorithm selection stay in the Level 6 draft, with Safari already shipping some of that extended syntax experimentally, so black-or-white is the Baseline shape rather than the ceiling.

Step 3: the border, by mixing

The badge fills with the raw accent, and derives its border from the same value rather than a second hand-picked token. color-mix() blends the accent with canvas, the system surface color for the used scheme, so the edge follows the theme:

scratch.css
/* scratch.css, the border from one accent */
.badge {
	background: var(--accent);
	border: 1px solid color-mix(in oklab, var(--accent) 62%, canvas);
}

Mixing against canvas makes the border theme-aware for free, since canvas resolves against the used color scheme at used-value time, so the edge lightens under a light scheme and darkens under a dark one. The same mix at a lower percentage, 16% against canvas, gives a soft container tint, the kind a soft-badge variant fills with instead of the solid accent. One caveat rides along with canvas. It’s the scheme’s system surface, not the actual element behind the badge, so inside a card with its own background the border still mixes toward the system surface rather than the card. This badge mixes toward canvas on purpose, so the edge follows the used scheme; a design system with custom local surfaces should mix toward an inherited surface token instead.

The detail that earns a demo is in oklab. This lab mixes the badge’s own canvas tint in srgb and in oklab side by side, prints what the browser actually paints under each, and adds a second pair across distant hues where the space decision turns visible:

container-mix
import { LitElement, html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { demoChrome } from '../library/color-tokens-shared/chrome';

/**
 * D4, the container mix, where the interpolation space is the visible decision.
 *
 * The badge's own container mix, the accent against `canvas` at one percentage, is computed twice, once `in srgb` and once `in oklab`, with the value the browser actually paints printed under each, and for that near-neutral tint the two spaces land almost together, which the note says out loud.
 * The mix slider is a gradient strip painting its own range of oklab tints for the current accent, so the control previews what each position selects before the reader commits to it.
 * A second, fixed pair mixes the accent against the opener's brand yellow at 50 percent, where the paths split visibly, srgb toward olive and oklab toward a cooler sage, so the reader sees what naming the space protects even though the badge itself never mixes distant hues.
 *
 * @element demo-color-token-contract-color-tokens-container-mix
 */
@customElement( 'demo-color-token-contract-color-tokens-container-mix' )
export class ContainerMixDemo extends LitElement {

	/**
	 * The accent feeding both mixes, driven by the color input.
	 *
	 * It starts on the canonical accent so the demo opens on the article's running color.
	 */
	@state()
	private accent = '#488bfb';

	/**
	 * The mix percentage of accent against canvas, shared by both spaces so only the space differs.
	 *
	 * A soft-badge variant's container tint mixes near 16 percent, the default here, and raising it deepens the tint while the two spaces keep nearly agreeing on this near-neutral pair, which is exactly why the fixed distant-hue pair below exists.
	 */
	@state()
	private mix = 16;

	/**
	 * The mix strip's gradient, the slider's range painted as the oklab mix of the current accent toward canvas, so the control shows the tints it selects and follows the scheme the way the swatches do.
	 */
	private get mixTrack(): string {
		const stops: string[] = [];

		for ( let i = 0; i < 9; i += 1 ) {
			const t = i / 8;
			const percent = Math.round( 8 + t * 52 );
			stops.push( `color-mix(in oklab, ${this.accent} ${percent}%, canvas) ${( t * 100 ).toFixed( 1 )}%` );
		}

		return `linear-gradient(to right, ${stops.join( ', ' )})`;
	}

	/**
	 * The srgb container swatch, read back to print the value the browser paints.
	 */
	@query( '.srgb-swatch' )
	private $srgbSwatch!: HTMLElement;

	/**
	 * The oklab container swatch, read back to print the value the browser paints.
	 */
	@query( '.oklab-swatch' )
	private $oklabSwatch!: HTMLElement;

	/**
	 * The computed srgb mix, read from the painted swatch after each render, or an empty string before the first read.
	 */
	@state()
	private srgbComputed = '';

	/**
	 * The computed oklab mix, read from the painted swatch after each render, or an empty string before the first read.
	 */
	@state()
	private oklabComputed = '';

	/**
	 * The srgb distant-hue swatch, read back to print the value the browser paints.
	 */
	@query( '.hue-srgb-swatch' )
	private $hueSrgbSwatch!: HTMLElement;

	/**
	 * The oklab distant-hue swatch, read back to print the value the browser paints.
	 */
	@query( '.hue-oklab-swatch' )
	private $hueOklabSwatch!: HTMLElement;

	/**
	 * The computed srgb distant-hue mix, read from the painted swatch after each render, or an empty string before the first read.
	 */
	@state()
	private hueSrgbComputed = '';

	/**
	 * The computed oklab distant-hue mix, read from the painted swatch after each render, or an empty string before the first read.
	 */
	@state()
	private hueOklabComputed = '';

	/**
	 * Styles for the harness, composed on the shared demo chrome.
	 *
	 * Each swatch applies its own `color-mix` so the browser, not the JavaScript, computes the result, and the code reads the painted value back rather than re-deriving it.
	 */
	static styles = [
		demoChrome,
		css`
			.pair {
				display: grid;
				grid-template-columns: 1fr 1fr;
				gap: var(--space-3, 12px);
			}

			.space {
				display: flex;
				flex-direction: column;
				gap: var(--space-2, 8px);
				padding: var(--space-3, 12px);
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--bg, #fcfcfb);
			}

			.space-name {
				font-family: var(--font-mono, ui-monospace, monospace);
				font-size: var(--fs-12, 12px);
				color: var(--ink, #17171a);
			}

			.container-swatch {
				height: 4rem;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
			}

			/* The two swatches differ only by interpolation space; the accent and the percentage are identical. */
			.srgb-swatch {
				background: color-mix(in srgb, var(--accent-source, #488bfb) var(--mix, 16%), canvas);
			}

			.oklab-swatch {
				background: color-mix(in oklab, var(--accent-source, #488bfb) var(--mix, 16%), canvas);
			}

			/* The distant-hue pair is pinned at 50 percent against the opener's brand yellow, the case where the two paths visibly split, srgb toward olive and oklab toward a cooler sage. */
			.hue-srgb-swatch {
				background: color-mix(in srgb, var(--accent-source, #488bfb) 50%, #ffd60a);
			}

			.hue-oklab-swatch {
				background: color-mix(in oklab, var(--accent-source, #488bfb) 50%, #ffd60a);
			}

			.pair-title {
				margin: 0;
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
			}

			.computed {
				font-family: var(--font-mono, ui-monospace, monospace);
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
				word-break: break-all;
			}

			@media (max-width: 30rem) {
				.pair {
					grid-template-columns: 1fr;
				}
			}
		`,
	];

	/**
	 * Reads all four painted swatches after every render so the printed values track the live mixes.
	 *
	 * Reading the computed `backgroundColor` back is the honest move: the browser owns the interpolation, and the lab reports what it produced instead of re-implementing the mix in JavaScript.
	 */
	updated(): void {
		if ( ! this.$srgbSwatch || ! this.$oklabSwatch || ! this.$hueSrgbSwatch || ! this.$hueOklabSwatch ) {
			return;
		}

		const nextSrgb = getComputedStyle( this.$srgbSwatch ).backgroundColor;
		const nextOklab = getComputedStyle( this.$oklabSwatch ).backgroundColor;
		const nextHueSrgb = getComputedStyle( this.$hueSrgbSwatch ).backgroundColor;
		const nextHueOklab = getComputedStyle( this.$hueOklabSwatch ).backgroundColor;

		if ( nextSrgb !== this.srgbComputed ) {
			this.srgbComputed = nextSrgb;
		}

		if ( nextOklab !== this.oklabComputed ) {
			this.oklabComputed = nextOklab;
		}

		if ( nextHueSrgb !== this.hueSrgbComputed ) {
			this.hueSrgbComputed = nextHueSrgb;
		}

		if ( nextHueOklab !== this.hueOklabComputed ) {
			this.hueOklabComputed = nextHueOklab;
		}
	}

	/**
	 * Updates the accent from the color input.
	 *
	 * @param event - The input event from the color picker.
	 */
	private onPick = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.accent = event.target.value;
		}
	};

	/**
	 * Updates the shared mix percentage from the slider.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onMix = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.mix = Number( event.target.value );
		}
	};

	/**
	 * Renders the controls and the two container swatches with their computed values.
	 */
	render() {
		return html`
			<div class="demo" style="--accent-source:${this.accent}; --mix:${this.mix}%;">
				<h3 class="demo-title">The same container, mixed two ways</h3>
				<div class="controls">
					<div class="control">
						<label class="control-label" for="accent">Accent</label>
						<input id="accent" type="color" .value=${this.accent} @input=${this.onPick} />
						<span class="control-value">${this.accent}</span>
					</div>
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="mix">Mix amount</label>
							<span class="control-value">${this.mix}%</span>
						</div>
						<input
							id="mix"
							class="slider-strip"
							type="range"
							min="8"
							max="60"
							step="1"
							.value=${String( this.mix )}
							@input=${this.onMix}
							style="background: var(--line, #e7e6e2); background: ${this.mixTrack};"
						/>
						<p class="slider-hint">How much accent survives the mix toward canvas, shared by both spaces so only the space differs.</p>
					</div>
				</div>
				<p class="pair-title">The badge's own mix, the accent tinted toward canvas.</p>
				<div class="pair">
					<div class="space">
						<span class="space-name">color-mix(in srgb, accent ${this.mix}%, canvas)</span>
						<div class="container-swatch srgb-swatch"></div>
						<span class="computed">${this.srgbComputed}</span>
					</div>
					<div class="space">
						<span class="space-name">color-mix(in oklab, accent ${this.mix}%, canvas)</span>
						<div class="container-swatch oklab-swatch"></div>
						<span class="computed">${this.oklabComputed}</span>
					</div>
				</div>
				<p class="pair-title">A distant-hue pair, pinned at 50 percent against the opener's brand yellow, where the space decision turns visible.</p>
				<div class="pair">
					<div class="space">
						<span class="space-name">color-mix(in srgb, accent 50%, #ffd60a)</span>
						<div class="container-swatch hue-srgb-swatch"></div>
						<span class="computed">${this.hueSrgbComputed}</span>
					</div>
					<div class="space">
						<span class="space-name">color-mix(in oklab, accent 50%, #ffd60a)</span>
						<div class="container-swatch hue-oklab-swatch"></div>
						<span class="computed">${this.hueOklabComputed}</span>
					</div>
				</div>
				<p class="demo-note">
					In the canvas pair only the space differs, and for this blue tint toward a near-neutral surface the two results land almost together, differing past the digits that matter.
					In the distant-hue pair the same decision turns visible, srgb dragging the midpoint toward olive while oklab holds a cooler sage.
					The badge writes <strong>in oklab</strong> on purpose, a more perceptually uniform blend than sRGB, and the author names that space as part of the contract rather than letting a default stand in or trusting the endpoints to stay friendly.
				</p>
				<p class="demo-note">
					The polar spaces (<span class="code-chip">oklch</span>, <span class="code-chip">hsl</span>) add a second choice, the hue arc, where <span class="code-chip">longer hue</span> takes the other way around the wheel, but the badge never mixes across distant hues, so that stays a note here rather than a control.
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-container-mix': ContainerMixDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

For the badge’s own mix, a blue tint toward a near-neutral canvas, the two spaces land almost together and the printed values differ only in their trailing digits. The second pair mixes the accent against the opener’s brand yellow, and there the paths split visibly, the srgb mix dragging the midpoint toward olive while the oklab mix holds a cooler sage. The badge never mixes distant hues, so the second pair shows what the space decision protects elsewhere in a design system, and the badge still writes in oklab, a more perceptually uniform blend than sRGB, so the contract depends on neither an engine default nor the endpoints staying friendly. The polar spaces like oklch add a hue-arc choice on top, which stays a demo note.

Step 4: alpha that’s a color, not an element

Alpha isn’t a state the solid badge itself needs, but it’s the same source token feeding a different surface, a tooltip backdrop or a highlight sheet the design system builds nearby, so it’s worth the detour. A translucent panel looks like a job for opacity. It usually isn’t. Fading the element fades the text and border along with the fill, when you usually want a translucent fill under crisp content. color-mix() toward transparent stores the alpha in the color itself:

scratch.css
/* scratch.css, a translucent color versus a faded element */
.panel-color {
	/* alpha in the color: only the fill is translucent */
	background: color-mix(in srgb, var(--accent) 20%, transparent);
}

.panel-opacity {
	/* alpha on the element: the fill, border, and text all fade together */
	background: var(--accent);
	opacity: 0.2;
}

This lab puts both over a striped backdrop and splits its input two ways. A free-text field takes an absolute color in the common syntaxes, named, hex, hsl, or oklch, gated by CSS.supports and then resolved to a concrete value, so a context-dependent color like currentColor is turned away with the typos, while a separate picker applies a trusted var(--brand-*) token that the free-text path would reject as a reference, a hand-rolled split the component’s own accent door formalizes in step 6.

alpha-lab
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { resolveToSrgb } from '../library/color-tokens-shared/contrast';
import { demoChrome } from '../library/color-tokens-shared/chrome';

/**
 * A trusted brand token the picker can apply as a real `var(--brand-*)` reference.
 *
 * The label names it for the button, the token is the custom property the demo root defines, and the swatch color is only for the button's own chip, never the value applied to the panel.
 */
interface BrandToken {

	/**
	 * The label shown on the token button.
	 */
	label: string;

	/**
	 * The custom-property reference applied to the panel, for example `var(--brand-blue)`.
	 *
	 * This is a design-system token, so it is trusted and set directly, the path the free-text field deliberately rejects.
	 */
	token: string;

	/**
	 * The concrete color the demo root binds the token to, used only to tint the button's own swatch.
	 */
	swatch: string;
}

/**
 * The curated brand tokens, the trusted half of the input split.
 *
 * A consumer picks one of these named tokens and the panel applies `var(--brand-*)` directly, because a design-system reference is a trusted token rather than a user-typed color.
 */
const BRAND_TOKENS: readonly BrandToken[] = [
	{ label: '--brand-blue', token: 'var(--brand-blue)', swatch: '#5566d8' },
	{ label: '--brand-teal', token: 'var(--brand-teal)', swatch: '#0f9e8f' },
	{ label: '--brand-plum', token: 'var(--brand-plum)', swatch: '#8a4fb0' },
];

/**
 * D5, alpha from any color, with the input-trust split made visible.
 *
 * A free-text field takes an absolute color in the common syntaxes, named, hex, hsl, or oklch, gated by `CSS.supports` and resolved to a concrete value before it ever becomes a CSS value, and a separate picker applies a trusted `var(--brand-*)` token directly, so the two trust levels never share a path.
 * The accepted accent feeds two panels that look similar and are not: one stores the alpha in the color with `color-mix(in srgb, accent 20%, transparent)`, the other leaves the color opaque and fades the whole element with `opacity`.
 * Over a striped backdrop the difference shows: the color-mix panel keeps crisp opaque text and border while only its fill is translucent, and the opacity panel fades its text and border too, which is why a translucent color and a faded element are different decisions.
 *
 * @element demo-color-token-contract-color-tokens-alpha-lab
 */
@customElement( 'demo-color-token-contract-color-tokens-alpha-lab' )
export class AlphaLabDemo extends LitElement {

	/**
	 * The accent currently applied to the two panels, either an accepted free-text color or a trusted token reference.
	 *
	 * It starts on the canonical accent so the panels open on the article's running color.
	 */
	@state()
	private accent = 'oklch(65% 0.18 260)';

	/**
	 * The raw text in the free-text field, kept separate from `accent` so a rejected value never reaches the panels.
	 */
	@state()
	private draft = 'oklch(65% 0.18 260)';

	/**
	 * Why the current draft was rejected, or `null` when it is a valid color.
	 *
	 * It drives the inline error, so an invalid string is shown as rejected rather than injected into the panels.
	 */
	@state()
	private rejection: string | null = null;

	/**
	 * Styles for the harness, composed on the shared demo chrome.
	 *
	 * The brand tokens are defined here on `.demo` so a `var(--brand-*)` reference resolves, and the striped backdrop is what makes the alpha difference observable.
	 */
	static styles = [
		demoChrome,
		css`
			.demo {
				/* The trusted design-system tokens live on the demo root, so a var(--brand-*) reference the picker applies resolves in the cascade. */
				--brand-blue: #5566d8;
				--brand-teal: #0f9e8f;
				--brand-plum: #8a4fb0;
			}

			.token-row {
				display: flex;
				flex-wrap: wrap;
				gap: var(--space-2, 8px);
			}

			.token {
				display: inline-flex;
				align-items: center;
				gap: 0.45em;
				padding: 0.35em 0.7em;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--surface, #fff);
				color: var(--ink, #17171a);
				font-family: var(--font-mono, ui-monospace, monospace);
				font-size: var(--fs-12, 12px);
				cursor: pointer;
			}

			.token:hover {
				background: var(--tint, #eef2fe);
			}

			.token:focus-visible {
				outline: 2px solid var(--accent, #2257e6);
				outline-offset: 2px;
			}

			.token-swatch {
				width: 0.9em;
				height: 0.9em;
				border-radius: 3px;
				border: 1px solid var(--line, #e7e6e2);
			}

			.error {
				margin: 0;
				font-size: var(--fs-13, 13px);
				color: color-mix(in oklab, crimson 75%, var(--ink, #17171a));
			}

			.ok {
				margin: 0;
				font-size: var(--fs-13, 13px);
				color: var(--ink-soft, #56565c);
			}

			.panels {
				display: grid;
				grid-template-columns: 1fr 1fr;
				gap: var(--space-3, 12px);
			}

			/* The striped backdrop sits behind both panels, so a translucent fill lets the stripes show through and a faded element does too, which is how the eye tells the two apart. */
			.stage {
				display: flex;
				flex-direction: column;
				gap: var(--space-1, 4px);
			}

			.stage-label {
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
			}

			.backdrop {
				padding: var(--space-3, 12px);
				border-radius: var(--radius-sm, 7px);
				background:
					repeating-linear-gradient(
						45deg,
						var(--line-soft, #f2f1ec),
						var(--line-soft, #f2f1ec) 8px,
						var(--line, #e7e6e2) 8px,
						var(--line, #e7e6e2) 16px
					);
			}

			.panel {
				display: flex;
				align-items: center;
				justify-content: center;
				min-height: 4.5rem;
				padding: var(--space-3, 12px);
				border: 2px solid var(--accent-source, #5566d8);
				border-radius: var(--radius-sm, 7px);
				color: var(--ink, #17171a);
				font-weight: 600;
				font-size: var(--fs-13, 13px);
				text-align: center;
			}

			/* Alpha lives in the color: only the fill is translucent, while the border and text stay fully opaque. */
			.panel.color-alpha {
				background: color-mix(in srgb, var(--accent-source, #5566d8) 20%, transparent);
			}

			/* Alpha lives on the element: the color is opaque but opacity fades the fill, the border, and the text together. */
			.panel.element-opacity {
				background: var(--accent-source, #5566d8);
				opacity: 0.2;
			}

			@media (max-width: 32rem) {
				.panels {
					grid-template-columns: 1fr;
				}
			}
		`,
	];

	/**
	 * Validates and applies the free-text draft, the untrusted path.
	 *
	 * It trims the value, rejects an empty string, a `var()` reference (a trusted design-system token, which belongs on the picker path), and the CSS-wide keywords (which resolve to something other than a concrete color), then gates syntax with `CSS.supports` and resolves the value to a concrete color, so a typo or a context-dependent color like `currentColor` updates the error rather than the panels.
	 *
	 * @param event - The input event from the free-text field.
	 */
	private onDraftInput = ( event: Event ): void => {
		if ( ! ( event.target instanceof HTMLInputElement ) ) {
			return;
		}

		this.draft = event.target.value;
		const candidate = this.draft.trim();

		if ( candidate === '' ) {
			this.rejection = 'Type a color to apply it.';
			return;
		}

		if ( /\bvar\s*\(/.test( candidate.toLowerCase() ) ) {
			this.rejection = 'A var() reference is a trusted token, so apply it from the picker below.';
			return;
		}

		if ( [ 'inherit', 'initial', 'unset', 'revert', 'revert-layer' ].includes( candidate.toLowerCase() ) ) {
			this.rejection = 'A CSS-wide keyword resolves to something other than a concrete color, so it is rejected, not applied.';
			return;
		}

		if ( ! CSS.supports( 'color', candidate ) ) {
			this.rejection = 'That is not a color the browser can parse, so it is rejected, not applied.';
			return;
		}

		if ( resolveToSrgb( candidate ) === null ) {
			this.rejection = 'That is a valid color, but one this field cannot resolve to a fixed value, like currentColor, a system color, or a nested color function, so it takes only plain absolute colors.';
			return;
		}

		this.rejection = null;
		this.accent = candidate;
	};

	/**
	 * Applies a trusted brand token directly, the path a design-system reference takes.
	 *
	 * It clears any free-text rejection and sets the accent to a `var(--brand-*)` reference, which resolves against the tokens defined on the demo root.
	 *
	 * @param token - The brand token reference to apply.
	 */
	private applyToken( token: string ): void {
		this.rejection = null;
		this.accent = token;
	}

	/**
	 * Renders the two input paths, the inline validation, and the two alpha panels over the striped backdrop.
	 */
	render() {
		return html`
			<div class="demo">
				<h3 class="demo-title">Two inputs, two trust levels, one accent</h3>
				<div class="controls">
					<div class="control">
						<label class="control-label" for="free-text">Free text</label>
						<input
							id="free-text"
							type="text"
							.value=${this.draft}
							placeholder="named, hex, hsl(), or oklch()"
							aria-invalid=${this.rejection !== null ? 'true' : 'false'}
							aria-describedby="free-text-note"
							@input=${this.onDraftInput}
						/>
					</div>
				</div>
				<p id="free-text-note" class=${this.rejection ? 'error' : 'ok'} role="status">
					${this.rejection ?? html`Accepted, applied as <span class="code-chip">${this.accent}</span>.`}
				</p>
				<div class="token-row">
					${BRAND_TOKENS.map(
						( brand ) => html`
							<button type="button" class="token" @click=${() => this.applyToken( brand.token )}>
								<span class="token-swatch" style="background:${brand.swatch};"></span>
								${brand.label}
							</button>
						`,
					)}
				</div>
				<div class="panels" style="--accent-source:${this.accent};">
					<div class="stage">
						<span class="stage-label">Translucent color: <span class="code-chip">color-mix(in srgb, accent 20%, transparent)</span></span>
						<div class="backdrop">
							<div class="panel color-alpha">Crisp text, crisp border</div>
						</div>
					</div>
					<div class="stage">
						<span class="stage-label">Faded element: <span class="code-chip">opacity: 0.2</span></span>
						<div class="backdrop">
							<div class="panel element-opacity">Faded text, faded border</div>
						</div>
					</div>
				</div>
				<p class="demo-note">
					Both panels read 20 percent, and they are not the same decision.
					The color-mix panel fades only its fill, so its border and label stay opaque, while <strong>opacity</strong> fades the whole element, label and border included, and composites the stripes differently.
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-alpha-lab': AlphaLabDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

Both panels read 20 percent over the stripes, and only the color-mix one keeps its border and label crisp. One cross-engine point is worth holding. A transparent mix stays hue-clean because interpolation premultiplies by alpha, so the alpha-zero endpoint carries no weight in the color channels (CSS Color 4 on premultiplied alpha). Older engines still diverged on non-srgb transparent mixes, so keep it in srgb or test elsewhere first.

Step 5: a tonal variant with relative color syntax

The badge wants a darker tone for its status dot, one shade down from the accent rather than a separate token that drifts. Relative color syntax destructures the accent into its channels and lets you adjust one:

scratch.css
/* scratch.css, a darker tone from the same accent */
.badge {
	--accent-strong: oklch(from var(--accent) calc(l - 0.12) c h);
}

The from var(--accent) exposes the accent’s l, c, and h as keywords to rebuild a color from, here nudging lightness down. A lighter tone flips the sign, calc(l + 0.2). Drag the four sliders in this lab to move lightness, chroma, hue, and alpha through the relative form and watch the source become the result:

relative-lab
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { demoChrome } from '../library/color-tokens-shared/chrome';

/**
 * The source color every derivation starts from, the article's canonical accent.
 *
 * Keeping the source pinned makes the sliders the only variables, so the generated string reads as one accent plus a set of named channel adjustments.
 */
const SOURCE = 'oklch(65% 0.18 260)';

/**
 * D6, the relative color syntax lab, where channel math is a unitless number.
 *
 * Four gradient strips drive lightness, chroma, hue, and alpha through the relative form, each strip painting its own channel's range at the other three's current values, and a source-becomes-result row prints the exact string the result swatch paints, over stripes so a lowered alpha stays visible.
 * The dropped panel is live rather than a frozen exhibit: its valid cell paints the full four-slider string and follows every control, while the twin swaps only the lightness slot for the same number as a percentage and never moves, since one poisoned channel fails the entire declaration and the literal source floor shows through, so the drop is shown rather than described.
 * On an engine with no relative color syntax at all every relative declaration falls to its source floor, so the verdict line says that plainly, gated on a probe of the valid form.
 * The rule on screen is the lesson: in relative color syntax the channel keywords resolve to unitless numbers on a 0 to 1 scale, so a percentage cannot be added to one.
 *
 * @element demo-color-token-contract-color-tokens-relative-lab
 */
@customElement( 'demo-color-token-contract-color-tokens-relative-lab' )
export class RelativeLabDemo extends LitElement {

	/**
	 * The lightness delta added to the `l` channel, a unitless number on the 0 to 1 scale.
	 */
	@state()
	private lightnessDelta = 0.2;

	/**
	 * The chroma multiplier applied to the `c` channel, where 1 leaves chroma unchanged.
	 */
	@state()
	private chromaScale = 1;

	/**
	 * The hue delta added to the `h` channel, in degrees, the one channel that is an angle.
	 */
	@state()
	private hueDelta = 0;

	/**
	 * The alpha applied after the slash, from 0 (transparent) to 1 (opaque).
	 */
	@state()
	private alpha = 1;

	/**
	 * Styles for the harness, composed on the shared demo chrome, which owns the gradient strips.
	 *
	 * The stripes under the result swatch are what make a lowered alpha observable, the same trick the alpha lab's backdrop uses.
	 */
	static styles = [
		demoChrome,
		css`
			.becomes {
				display: flex;
				flex-wrap: wrap;
				align-items: center;
				gap: var(--space-2, 8px);
				font-size: var(--fs-13, 13px);
				color: var(--ink-soft, #56565c);
			}

			/* The striped stage under the result swatch, so a lowered alpha visibly lets the stripes through; inline-flex gives the inline span swatch inside it a real box. */
			.alpha-stage {
				display: inline-flex;
				padding: 0;
				border-radius: var(--radius-sm, 7px);
				background: repeating-linear-gradient(45deg, var(--line-soft, #f2f1ec), var(--line-soft, #f2f1ec) 6px, var(--line, #e7e6e2) 6px, var(--line, #e7e6e2) 12px);
			}

			.dropped {
				display: grid;
				grid-template-columns: 1fr 1fr;
				gap: var(--space-3, 12px);
				padding: var(--space-3, 12px);
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--bg, #fcfcfb);
			}

			.dropped-cell {
				display: flex;
				flex-direction: column;
				gap: var(--space-1, 4px);
			}

			.dropped-label {
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
			}

			/* Each cell paints the source's sRGB spelling as a floor (the same #488bfb the article's Tier 0 uses, so even an engine without oklch() shows the source color) and then its full relative string inline; the valid one applies and tracks every slider, while its twin carries a percentage in the lightness slot, so the entire declaration fails and the floor shows through, which is the drop made visible. */
			.dropped-swatch {
				height: 3rem;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
			}

			.verdict-line {
				display: flex;
				align-items: center;
				gap: var(--space-2, 8px);
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
			}
		`,
	];

	/**
	 * Formats a signed channel adjustment as the calc() expression relative color syntax takes.
	 *
	 * The sign lives in the operator, so `0.2` prints `calc(l + 0.20)` and `-0.12` prints `calc(l - 0.12)`, the two spellings the article teaches.
	 *
	 * @param channel - The channel keyword, `l` or `h`.
	 * @param delta - The signed adjustment the slider holds.
	 * @param digits - How many decimals the number prints with, 2 for lightness and 0 for degrees.
	 * @returns The calc() expression for the channel slot.
	 */
	private signedCalc( channel: string, delta: number, digits: number ): string {
		const sign = delta >= 0 ? '+' : '-';

		return `calc(${channel} ${sign} ${Math.abs( delta ).toFixed( digits )})`;
	}

	/**
	 * Builds the relative-color string for a full set of channel positions, the one form every strip stop, the result swatch, and the printed string share.
	 *
	 * @param lightnessDelta - The signed lightness adjustment.
	 * @param chromaScale - The chroma multiplier.
	 * @param hueDelta - The signed hue rotation in degrees.
	 * @param alpha - The alpha after the slash.
	 * @returns The `oklch(from ...)` string for those positions.
	 */
	private relativeString( lightnessDelta: number, chromaScale: number, hueDelta: number, alpha: number ): string {
		const lightness = this.signedCalc( 'l', lightnessDelta, 2 );
		const chroma = `calc(c * ${chromaScale.toFixed( 2 )})`;
		const hue = this.signedCalc( 'h', hueDelta, 0 );

		return `oklch(from ${SOURCE} ${lightness} ${chroma} ${hue} / ${alpha.toFixed( 2 )})`;
	}

	/**
	 * The generated relative-color string for the current sliders, the exact value the result swatch paints and the reader copies.
	 */
	private get generated(): string {
		return this.relativeString( this.lightnessDelta, this.chromaScale, this.hueDelta, this.alpha );
	}

	/**
	 * Builds a gradient of relative-color stops varying one channel across its range while the other three hold their current values.
	 *
	 * The stops are literal relative-color strings the browser computes, so on an engine without the syntax the whole gradient fails to parse and the strip's neutral inline floor shows instead, the same two-declaration fallback the article teaches.
	 *
	 * @param colorAt - The relative-color string for a position, where the argument runs 0 to 1 across the slider's range.
	 * @returns The `linear-gradient` value for the strip.
	 */
	private trackFor( colorAt: ( t: number ) => string ): string {
		const stops: string[] = [];

		for ( let i = 0; i < 13; i += 1 ) {
			const t = i / 12;
			stops.push( `${colorAt( t )} ${( t * 100 ).toFixed( 1 )}%` );
		}

		return `linear-gradient(to right, ${stops.join( ', ' )})`;
	}

	/**
	 * The lightness strip's gradient, `calc(l + n)` swept across the slider's range at the other channels' current values.
	 */
	private get lightnessTrack(): string {
		return this.trackFor( ( t ) => this.relativeString( -0.4 + t * 0.74, this.chromaScale, this.hueDelta, this.alpha ) );
	}

	/**
	 * The chroma strip's gradient, `calc(c * k)` swept from gray at 0 out past the sRGB edge at 2.
	 */
	private get chromaTrack(): string {
		return this.trackFor( ( t ) => this.relativeString( this.lightnessDelta, t * 2, this.hueDelta, this.alpha ) );
	}

	/**
	 * The hue strip's gradient, `calc(h + deg)` swept a half turn each way around the wheel.
	 */
	private get hueTrack(): string {
		return this.trackFor( ( t ) => this.relativeString( this.lightnessDelta, this.chromaScale, -180 + t * 360, this.alpha ) );
	}

	/**
	 * The alpha strip's gradient layered over stripes, the slash channel swept 0 to 1 so the fade is visible against a pattern.
	 */
	private get alphaTrack(): string {
		const fade = this.trackFor( ( t ) => this.relativeString( this.lightnessDelta, this.chromaScale, this.hueDelta, t ) );

		return `${fade}, repeating-linear-gradient(45deg, var(--line-soft, #f2f1ec), var(--line-soft, #f2f1ec) 6px, var(--line, #e7e6e2) 6px, var(--line, #e7e6e2) 12px)`;
	}

	/**
	 * Whether the engine parses relative color syntax at all, probed with the valid base form.
	 *
	 * When this is false both dropped-panel cells fall to the literal source floor, so the verdict line states that plainly instead of implying the valid form painted while the percentage form was dropped.
	 */
	private get relativeFormSupported(): boolean {
		return CSS.supports( 'color', 'oklch(from red l c h)' );
	}

	/**
	 * Updates the lightness delta from its slider.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onLightness = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.lightnessDelta = Number( event.target.value );
		}
	};

	/**
	 * Updates the chroma multiplier from its slider.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onChroma = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.chromaScale = Number( event.target.value );
		}
	};

	/**
	 * Updates the hue delta from its slider.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onHue = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.hueDelta = Number( event.target.value );
		}
	};

	/**
	 * Updates the alpha from its slider.
	 *
	 * @param event - The input event from the range slider.
	 */
	private onAlpha = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.alpha = Number( event.target.value );
		}
	};

	/**
	 * Renders the four gradient strips, the source-becomes-result row, and the live dropped-percentage panel.
	 */
	render() {
		const generated = this.generated;
		const relativeSupported = this.relativeFormSupported;
		const validForm = this.signedCalc( 'l', this.lightnessDelta, 2 );
		const percentSign = this.lightnessDelta >= 0 ? '+' : '-';
		const percentForm = `calc(l ${percentSign} ${Math.round( Math.abs( this.lightnessDelta ) * 100 )}%)`;
		const poisoned = `oklch(from ${SOURCE} ${percentForm} calc(c * ${this.chromaScale.toFixed( 2 )}) ${this.signedCalc( 'h', this.hueDelta, 0 )} / ${this.alpha.toFixed( 2 )})`;
		const percentParses = CSS.supports( 'color', poisoned );

		return html`
			<div class="demo">
				<h3 class="demo-title">Adjust channels with unitless numbers</h3>
				<div class="sliders">
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="l-delta">Lightness delta</label>
							<span class="control-value">${this.lightnessDelta.toFixed( 2 )}</span>
						</div>
						<input
							id="l-delta"
							class="slider-strip"
							type="range"
							min="-0.4"
							max="0.34"
							step="0.01"
							.value=${String( this.lightnessDelta )}
							@input=${this.onLightness}
							style="background: var(--line, #e7e6e2); background: ${this.lightnessTrack};"
						/>
						<p class="slider-hint">Applied as ${validForm}, a unitless number on the 0 to 1 scale.</p>
					</div>
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="c-scale">Chroma multiplier</label>
							<span class="control-value">${this.chromaScale.toFixed( 2 )}</span>
						</div>
						<input
							id="c-scale"
							class="slider-strip"
							type="range"
							min="0"
							max="2"
							step="0.05"
							.value=${String( this.chromaScale )}
							@input=${this.onChroma}
							style="background: var(--line, #e7e6e2); background: ${this.chromaTrack};"
						/>
						<p class="slider-hint">Applied as calc(c * ${this.chromaScale.toFixed( 2 )}), where 0 collapses to gray.</p>
					</div>
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="h-delta">Hue rotation</label>
							<span class="control-value">${this.hueDelta}</span>
						</div>
						<input
							id="h-delta"
							class="slider-strip"
							type="range"
							min="-180"
							max="180"
							step="1"
							.value=${String( this.hueDelta )}
							@input=${this.onHue}
							style="background: var(--line, #e7e6e2); background: ${this.hueTrack};"
						/>
						<p class="slider-hint">Applied as ${this.signedCalc( 'h', this.hueDelta, 0 )}, the one channel measured in degrees.</p>
					</div>
					<div class="slider-row">
						<div class="slider-head">
							<label class="control-label" for="alpha">Alpha</label>
							<span class="control-value">${this.alpha.toFixed( 2 )}</span>
						</div>
						<input
							id="alpha"
							class="slider-strip"
							type="range"
							min="0"
							max="1"
							step="0.05"
							.value=${String( this.alpha )}
							@input=${this.onAlpha}
							style="background: var(--line, #e7e6e2); background: ${this.alphaTrack};"
						/>
						<p class="slider-hint">Applied after the slash; the strip and the result sit on stripes so translucency shows.</p>
					</div>
				</div>
				<div class="becomes">
					<span class="swatch" style="background: #488bfb; background: ${SOURCE};"></span>
					<span class="code-chip">${SOURCE}</span>
					<span>becomes</span>
					<span class="alpha-stage"><span class="swatch" style="background: #488bfb; background: ${generated};"></span></span>
				</div>
				<pre class="code-block">${generated}</pre>
				<p class="demo-note">
					Every channel keyword (<span class="code-chip">l</span>, <span class="code-chip">c</span>, <span class="code-chip">h</span>) resolves to a <strong>unitless number</strong>, with lightness on a 0 to 1 scale, so the math is <span class="code-chip">calc(l + 0.2)</span>, never <span class="code-chip">calc(l + 20%)</span>.
					The panel below proves it with your own numbers.
				</p>
				<div class="dropped">
					<div class="dropped-cell">
						<span class="dropped-label">Valid: <span class="code-chip">${validForm}</span></span>
						<div class="dropped-swatch" style="background: #488bfb; background: ${generated};"></div>
					</div>
					<div class="dropped-cell">
						<span class="dropped-label">Dropped: <span class="code-chip">${percentForm}</span></span>
						<div class="dropped-swatch" style="background: #488bfb; background: ${poisoned};"></div>
					</div>
				</div>
				${relativeSupported
					? html`
						<p class="verdict-line">
							<span class="report-verdict" data-pass=${percentParses ? 'false' : 'true'}>${percentParses ? 'parsed' : 'dropped'}</span>
							<span>
								Drag any slider and the valid cell follows the whole string, while the twin with <span class="code-chip">${percentForm}</span> in its lightness slot never paints, since a percentage cannot be added to the unitless <span class="code-chip">l</span> and one poisoned channel drops the entire declaration, leaving the source floor showing.
							</span>
						</p>
					`
					: html`
						<p class="verdict-line">
							<span class="report-verdict" data-pass="false">unsupported</span>
							<span>
								This browser has no relative color syntax at all, so both forms fail to parse and both cells fall to the source's sRGB floor; on a supporting engine only the percentage form is dropped.
							</span>
						</p>
					` }
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-relative-lab': RelativeLabDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The rule on screen is the one that trips people. In relative color syntax the channel keywords resolve to unitless numbers, with lightness on a 0 to 1 scale, so the math is calc(l - 0.12) or calc(l + 0.2), never calc(l + 20%). A percentage there is a parse-time type error, because CSS Values 4 says a <number> and a <percentage> can’t be combined, and MDN spells out this exact case: “if we tried to do calc(l + 20%), that would result in an invalid color.” A panel in the lab proves it with your own numbers, the valid cell following every slider while its twin, identical except for a percentage in the lightness slot, never paints, one poisoned channel dropping the whole declaration, checked live with CSS.supports. So calc(l - 0.12) on this accent computes oklch(0.53 0.18 260), and calc(l + 0.2) computes oklch(0.85 0.18 260).

Pushing lightness or chroma can leave the sRGB gamut, where the browser gamut-maps by reducing chroma, so a relative step isn’t always visually linear, a point Evil Martians covers well in “OKLCH in CSS.”

A fixed offset also has a floor and a ceiling. Subtract 0.12 from an already dark accent and the dot lands near black on a near-black fill, so it disappears into the badge, and add to an already light one and it washes out. This badge makes a product decision about that. The dot is decoration, an aria-hidden tonal accent rather than a guaranteed-contrast state, so a very dark accent that sinks it into the fill is a graceful downgrade, not a broken badge. A version that needs the dot always visible would constrain the accent to a mid-lightness range or derive the dot toward the foreground rather than always darker, and the playground in step 7 takes any opaque color precisely so you can push it to that edge, where it flags when the dot collapses.

Step 6: wrap it in Lit, and keep the component thin

The derivation is all CSS, so the component stays small. Lit gives it a tag, a shadow root for the static styles, and a two-node template, the right amount of framework for a badge whose real logic lives in the stylesheet.

The accent gets two doors, one per caller. The everyday door is a validated Lit accent property, what a developer types on one badge, and the design-system door is the inherited --smart-badge-accent custom property, set on any ancestor so a whole subtree re-derives, the one thing a property cannot do, since properties do not cascade:

html
<!-- the everyday door: one badge, one validated property -->
<smart-badge accent="oklch(70% 0.15 150)">Featured</smart-badge>

<!-- the design-system door: a stylesheet rule sets the token and every badge inside re-derives -->
<section class="product-rail">
	<smart-badge>Featured</smart-badge>
	<smart-badge>New</smart-badge>
</section>
css
.product-rail {
	--smart-badge-accent: oklch(70% 0.15 150);
}

That scoped rule is not this badge’s invention. Google’s Material Web themes its entire component set the same way, custom properties declared in scoped stylesheet rules, so this door needs no JavaScript and no attribute on any badge.

A valid accent becomes one <style> line the template renders, declaring the token on the host, so the property outranks an inherited token, and clearing it removes the declaration and hands the badge back to the ancestor.

The accent must be opaque. The badge fills with it and scores the foreground against that fill, so a translucent value composites with whatever sits behind the badge and leaves contrast-color() no reliable surface to derive from. A <color> syntax check does not enforce opacity, so the property door rejects a translucent accent itself, and anything reaching the token door is expected to honor the same requirement.

Two naming decisions carry weight. The token is namespaced rather than a generic --accent, collision bait against another component’s --accent. And the stylesheet never declares the token on :host, since that would beat an inherited value and break the ancestor-override promise; only the template declares it, one <style> line rendered while a valid accent is set. It resolves into a private --_accent with a curated default that the foreground, border, and dot derive from, through the @supports tiers step 7 assembles.

The shell carries the element, its TSDoc, one property, the styles, a render(), and one guard, with the TSDoc trimmed to its tags here since the full contract doc lands on the assembled file in step 7:

index.ts
// index.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { PropertyValues } from 'lit';
import { rejectAccent, accentRejectionMessage } from './accent-door';

/**
 * A solid status badge that fills with one accent and derives a foreground, border, and tonal dot from it.
 *
 * @element smart-badge
 * @slot - The badge label.
 * @cssprop --smart-badge-accent - The design-system door: the one source color the whole contract derives from, which must be opaque; set it on the host or any ancestor.
 */
@customElement( 'smart-badge' )
export class SmartBadge extends LitElement {

}

declare global {
	interface HTMLElementTagNameMap {
		'smart-badge': SmartBadge;
	}
}

Notice what the badge does not have: no :hover, :active, :focus-visible, or [disabled] styling. A status badge isn’t focusable or pressable, so those states would fake an affordance it doesn’t have. Deriving a state is cheap, so the discipline is deriving only the ones the component’s role actually has, and a clickable badge is a different component built on a real <button>.

The styles open with the theme decision, since it’s what makes everything else resolve. The :host sets color-scheme: light dark, then the Tier 0 default accent and floor, and the host layout, which fills with the raw accent:

index.ts
/* index.ts, inside static styles */
:host {
	/* color-scheme opts the component into both themes, so canvas, canvastext, and light-dark() resolve to the user's preference. */
	/* Without it light-dark() returns the light branch in both modes, which is the trap. */
	color-scheme: light dark;

	/* Tier 0 reads a curated sRGB default only; the public --smart-badge-accent token is resolved in the activation tier below, which needs contrast-color(), color-mix(), and relative color syntax all present, so an engine missing any one keeps the whole default badge rather than a custom fill whose family it cannot derive. */
	/* The default is the sRGB form of the canonical oklch(65% 0.18 260), not the oklch() itself, so the floor survives on an engine without oklch() instead of falling to an invalid background, the same custom-property trap the shadow guards against below. */
	--_accent: #488bfb;

	/* Tier 0 floor: a hand-authored set for the default accent, so the badge looks right where the functions below are missing. */
	/* Because Tier 0 pins the default accent and its foreground together, an unsupported engine ships a coherent default badge and a custom accent simply does not apply there, which is the real limit the article states plainly. */
	/* The foreground is black, the pick contrast-color() makes on the default accent, so no tier ships a foreground the next tier contradicts. */
	/* The border floor is the Tier 1 mix precomputed against a light canvas, color-mix(in oklab, #488bfb 62%, white) = #8eb9ff, since a single hex can match only one scheme, the price of a floor that cannot run color-mix(). */
	--_accent-fg: black;
	--_accent-border: #8eb9ff;
	--_accent-strong: #2f63d6;

	/* The floor elevation shadow is a plain rgb shadow; the guarded @supports block below upgrades it with light-dark() so dark mode gets a deeper one. */
	/* That upgrade sits in its own @supports, because a custom property accepts the unsupported light-dark() token sequence at parse time and only fails later when box-shadow consumes it (invalid at computed-value time), so a plain re-declaration would lose this floor. */
	--_accent-shadow: 0 1px 2px rgb(0 0 0 / 0.18);

	display: inline-flex;
	align-items: center;
	gap: 0.45em;
	padding: 0.32em 0.78em;
	border: 1px solid var(--_accent-border);
	border-radius: 999px;
	/* The badge fills with the raw accent, so the foreground below is picked against the surface the label actually sits on. */
	background: var(--_accent);
	color: var(--_accent-fg);
	box-shadow: var(--_accent-shadow);
	font: inherit;
	font-size: 0.8em;
	font-weight: 600;
	line-height: 1.2;
	letter-spacing: 0.01em;
}

The color-scheme: light dark at the top is load-bearing. light-dark() and the canvas mixes both resolve against the element’s used scheme, so an undeclared color-scheme strands them on the light branch in both OS modes. Declaring it on :host is also a policy, not just the trap’s fix. It opts the badge’s subtree into following the OS, so on a page pinned to a single scheme the border mix and the shadow still track the user’s preference rather than the page. The alternative is to leave the property inherited and document that the consuming page owns the scheme. This badge follows the OS on purpose, which answers the contract table’s theme row, who owns light versus dark, for it.

The Tier 0 default is load-bearing in a quieter way too. It’s the sRGB hex #488bfb, not the oklch(65% 0.18 260) it equals, because a floor written in oklch() would spring the same custom-property trap the shadow guards against below, where an older engine parses the token fine but drops background when it consumes an oklch() it does not support, leaving the floor with no fill at all.

Beyond its slot the badge renders the status dot, painted with the strong tonal variant, plus the one accent declaration while a valid property is set, and render() stays tiny:

index.ts
/* index.ts, inside static styles */
/* A decorative status dot (aria-hidden) painted with the strong tonal variant, so the badge visibly leans on a derived token beyond its own fill; the fixed lightness offset is best-effort and can collapse into a very dark fill, so the dot is decoration, not a guaranteed-contrast state. */
.dot {
	width: 0.55em;
	height: 0.55em;
	border-radius: 50%;
	background: var(--_accent-strong);
}
ts
/**
 * Renders the accent declaration, the status dot, and the slotted label.
 *
 * The one-line `<style>` declares the door's accepted accent on the host, so an explicit property beats an inherited token, and it disappears entirely when there is nothing valid to declare, handing the badge back to the cascade.
 * Interpolating into a stylesheet is safe here because only values the door has already proven to be one absolute color can reach it.
 */
render() {
	return html`
		${this.validAccent === null
			? null
			: html`<style>:host { --smart-badge-accent: ${this.validAccent}; }</style>`}
		<span class="dot" aria-hidden="true"></span>
		<slot></slot>
	`;
}

The last promise, input trust, becomes the property door’s job. The property takes one absolute, opaque color, and a guard turns everything else away before it can become the token:

index.ts
/* index.ts, the property door and its guard */
/**
 * The property door: one absolute, opaque color the whole contract re-derives from, validated before it becomes the token.
 *
 * A valid value becomes the one `--smart-badge-accent` declaration `render()` emits, a rejected one is refused with a console warning naming the reason, and clearing the property drops the declaration so an inherited token or the default applies again.
 * It is set from the `accent` attribute, so `<smart-badge accent="oklch(70% 0.15 150)">` is the everyday spelling.
 */
@property()
accent?: string;

/**
 * The accent the door accepted, or `null` when the property is unset or was rejected, the single value the template declares.
 *
 * Keeping the validated value as its own state means `render()` stays declarative, one conditional `<style>` line instead of imperative style writes, and a bad value falls back to the cascade by declaring nothing at all.
 */
@state()
private validAccent: string | null = null;

/**
 * Guards the property door whenever `accent` changes, distilling it into the value the template may declare.
 *
 * A valid value lands in `validAccent`, a rejected one leaves it `null` and warns with the door's reason, and an unset property clears it silently, so falling back to the default is nothing more than declaring nothing.
 * The guard never touches the DOM; the declaration itself belongs to `render()`.
 *
 * @param changed - The reactive properties that changed, from Lit.
 */
protected willUpdate( changed: PropertyValues<this> ): void {
	if ( ! changed.has( 'accent' ) ) {
		return;
	}

	if ( this.accent === undefined ) {
		this.validAccent = null;
		return;
	}

	const rejection = rejectAccent( this.accent );

	if ( rejection === null ) {
		this.validAccent = this.accent;
		return;
	}

	this.validAccent = null;
	console.warn( `<smart-badge> rejected accent "${this.accent}": ${accentRejectionMessage[ rejection ]}` );
}

The door’s rules live beside the component in their own module, resolved with culori rather than gated on CSS.supports, so they run in node and carry unit tests. The messages next to the reasons are the door’s public voice, the same strings the component warns with and the playground shows the reader, and the module is short enough to read whole:

accent-door.ts
// accent-door.ts, the rules and their public voice, shared with the playground
import { resolveToSrgb } from '../color-tokens-shared/contrast';
import type { AccentRejection } from './types';

/**
 * Explains why an accent value cannot pass the badge's property door, or returns `null` for a value the badge can derive from.
 *
 * The door takes exactly one shape, an absolute opaque color, because the badge fills with the accent and scores its foreground against that fill, so anything the badge cannot resolve to one solid surface is turned away with a named reason.
 * A `var()` reference is rejected not because it is unsafe but because it is the other door's job: a design-system token belongs on the inherited `--smart-badge-accent` custom property, where the cascade resolves it.
 * The check runs on culori rather than `CSS.supports`, so it accepts every absolute syntax, rejects context-dependent values it cannot pin down, and stays testable in node.
 *
 * @param value - The raw accent candidate, trusted or not.
 * @returns The rejection reason, or `null` when the value is an absolute, opaque color.
 */
export function rejectAccent( value: string ): AccentRejection | null {
	const normalized = value.trim().toLowerCase();

	if ( normalized === '' ) {
		return 'empty';
	}

	if ( /\bvar\s*\(/.test( normalized ) ) {
		return 'var-reference';
	}

	if ( [ 'inherit', 'initial', 'unset', 'revert', 'revert-layer' ].includes( normalized ) ) {
		return 'css-wide-keyword';
	}

	const resolved = resolveToSrgb( value.trim() );

	if ( resolved === null ) {
		return 'unresolvable';
	}

	if ( resolved.alpha < 1 ) {
		return 'not-opaque';
	}

	return null;
}

/**
 * A plain-language explanation for each rejection reason, shared by the component's console warning and any boundary that surfaces the rejection to a reader.
 *
 * Keyed by {@link AccentRejection}, so a message always tracks the exact branch the validation took rather than a guess.
 */
export const accentRejectionMessage: Record<AccentRejection, string> = {
	empty: 'An empty value carries no color to derive from, so the previous accent stays.',
	'var-reference': 'A var() reference is a design-system token, so set it on the inherited --smart-badge-accent custom property instead of the accent property.',
	'css-wide-keyword': 'A CSS-wide keyword resolves to something other than a color, so it never becomes the token.',
	unresolvable: 'That value does not resolve to one absolute color, either a typo or a context-dependent value like currentColor, a system color, or a nested color function.',
	'not-opaque': 'That color is translucent; the accent must be opaque so the badge can fill with it and score the foreground against that fill.',
};

One reason does double duty. 'unresolvable' covers both a typo and a context-dependent value like currentColor, since anything culori cannot pin to one absolute color gets the same answer at this door, and its message says so. The reason union lives in its own types file, one import away for a consumer who only wants the type:

types.ts
// types.ts, the door's reason union
/**
 * Why the badge turned an accent value away at its property door, or nothing when the value passed.
 *
 * `'empty'` is a blank or whitespace-only string, which carries no color to derive from.
 * `'var-reference'` is a `var(...)` reference, which is a design-system token and belongs on the inherited custom-property door, not the property.
 * `'css-wide-keyword'` is one of the CSS-wide keywords, which resolves to something other than a color.
 * `'unresolvable'` is a string that does not resolve to one absolute color, a typo as well as a context-dependent value like `currentColor`, a system color, or a nested color function.
 * `'not-opaque'` is a translucent color, which the badge cannot fill with and still score a meaningful foreground against.
 */
export type AccentRejection = 'empty' | 'var-reference' | 'css-wide-keyword' | 'unresolvable' | 'not-opaque';

The guard never touches the DOM. It distills accent into the one value the template may declare, and render() emits it as a single <style> line, safe to interpolate because nothing reaches it without first proving to be one absolute color, so falling back to the default is nothing more than declaring nothing. What no door decides is whether the color is in your brand range, a design call the step 7 playground layers on top.

Step 7: the fallback tiers, and the whole file

contrast-color() and relative color syntax aren’t everywhere yet, so each derived state sits in its own @supports tier. A browser with color-mix() but not relative color syntax (Firefox 113 through 127 was exactly that) gets the border mix and keeps the floor for the tonal dot. The guards, in order:

index.ts
/* index.ts, inside static styles */
@supports (color: color-mix(in oklab, red, white)) {
	:host {
		/* Tier 1: derive the border from the one accent, mixed in oklab against canvas so the edge follows the theme. */
		--_accent-border: color-mix(in oklab, var(--_accent) 62%, canvas);
	}
}

@supports (color: oklch(from red calc(l + 0.2) c h)) {
	:host {
		/* Tier 2: the strong tonal variant from relative color syntax, with lightness as a unitless 0 to 1 number. */
		--_accent-strong: oklch(from var(--_accent) calc(l - 0.12) c h);
	}
}

@supports (color: contrast-color(red)) and (color: color-mix(in oklab, red, white)) and (color: oklch(from red calc(l + 0.2) c h)) {
	:host {
		/* Custom accent activation: the public token replaces the sRGB default only where the whole family can derive, contrast-color() for the foreground, color-mix() for the border, and relative color syntax for the dot, so an engine missing any one keeps the coherent default badge rather than a custom fill wearing the default's blue border and dot. */
		/* The stylesheet reads --smart-badge-accent but never declares it, so an inherited value or the template's door declaration wins; the sRGB #488bfb fallback keeps this declaration from leaning on an unguarded oklch(). */
		--_accent: var(--smart-badge-accent, #488bfb);
	}
}

@supports (color: contrast-color(red)) {
	:host {
		/* Tier 3: pick the foreground against the fill, which is black for the sRGB default and the higher-contrast of black or white for whatever custom accent activated above. */
		--_accent-fg: contrast-color(var(--_accent));
	}
}

@supports (box-shadow: 0 1px 2px light-dark(black, white)) {
	:host {
		/* Theme layer: light-dark() upgrades the shadow per used scheme, guarded in its own @supports tested in the box-shadow context where it is consumed, so the floor survives where light-dark() is unsupported. */
		--_accent-shadow: 0 1px 2px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.36));
	}
}

The fallback forces an honest admission, and it’s a real product decision. The Tier 0 floor is a hand-authored family for the default accent, and the public token is resolved only where contrast-color(), color-mix(), and relative color syntax are all present, so on an engine missing any of them a consumer who sets --smart-badge-accent: red gets the default badge, not a red one. That is deliberate. A custom accent takes effect only where its whole family can derive, so a red fill never wears the default’s blue border and dot, and an older engine keeps the coherent default rather than a half-derived custom badge. Gating the accent on the whole family it needs is what stops the unsupported path from either recreating the unreadable-text failure this article opened on or shipping a red fill under a default-blue border. A CSS-only floor can preserve the default design, but it can’t derive a whole family for an arbitrary custom accent at the same time, so you pick a strategy on purpose rather than pretend the fallback is lossless. The strategies are custom accents as enhancement-only like this, a full fallback token family per accent, a curated set of accent presets, or a little JavaScript on the unsupported path.

The assembled index.ts, which is the whole component:

index.ts
// index.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { PropertyValues } from 'lit';
import { rejectAccent, accentRejectionMessage } from './accent-door';

/**
 * A solid status badge that fills with one accent and derives a foreground, border, and tonal dot from it.
 *
 * The accent has two doors, one per trust shape: the `accent` property takes one absolute, opaque color and is validated at the door, while the inherited `--smart-badge-accent` custom property is the design-system door, set on any ancestor so a whole subtree re-derives, which is the one thing a Lit property cannot do.
 * A valid `accent` becomes one `<style>` line the template renders, declaring the token on the host, so the property outranks an inherited token, and clearing the property removes the declaration and hands control back to the ancestor.
 * The static stylesheet never declares the token on `:host` itself, so with the property unset an inherited value wins; only the template's declaration, rendered while a valid `accent` is set, beats it.
 * The badge fills with the raw `--_accent`, so `contrast-color()` picks the foreground against the exact surface the label sits on, not a surface the reader never sees.
 * The accent must be opaque, since the badge fills with it and scores the foreground against that fill, so a translucent value would composite with whatever sits behind the badge and leave `contrast-color()` no reliable surface to derive from; the property door rejects one, and the token door documents the same requirement.
 * A custom accent is enhancement-only, resolved only where `contrast-color()`, `color-mix()`, and relative color syntax are all present, so its whole family derives together, while Tier 0 pins the default accent otherwise rather than a custom fill wearing the default's border and dot.
 * Every derived state lives behind an `@supports` tier, so the source color stays a single value across every support level, and the only JavaScript in the component is the door's validation, never a color derivation.
 *
 * @element smart-badge
 * @slot - The badge label.
 * @cssprop --smart-badge-accent - The design-system door: the one source color the whole contract derives from, which must be opaque; set it on the host or any ancestor.
 */
@customElement( 'smart-badge' )
export class SmartBadge extends LitElement {

	/**
	 * The property door: one absolute, opaque color the whole contract re-derives from, validated before it becomes the token.
	 *
	 * A valid value becomes the one `--smart-badge-accent` declaration `render()` emits, a rejected one is refused with a console warning naming the reason, and clearing the property drops the declaration so an inherited token or the default applies again.
	 * It is set from the `accent` attribute, so `<smart-badge accent="oklch(70% 0.15 150)">` is the everyday spelling.
	 */
	@property()
	accent?: string;

	/**
	 * The accent the door accepted, or `null` when the property is unset or was rejected, the single value the template declares.
	 *
	 * Keeping the validated value as its own state means `render()` stays declarative, one conditional `<style>` line instead of imperative style writes, and a bad value falls back to the cascade by declaring nothing at all.
	 */
	@state()
	private validAccent: string | null = null;

	/**
	 * Styles for the component, scoped to its shadow root.
	 *
	 * The ladder keeps the source accent as one value and adds each derived state in its own `@supports` tier, guarded by the exact function it uses, so a browser that has `color-mix()` but not relative color syntax never emits an unsupported declaration.
	 */
	static styles = css`
		:host {
			/* color-scheme opts the component into both themes, so canvas, canvastext, and light-dark() resolve to the user's preference. */
			/* Without it light-dark() returns the light branch in both modes, which is the trap. */
			color-scheme: light dark;

			/* Tier 0 reads a curated sRGB default only; the public --smart-badge-accent token is resolved in the activation tier below, which needs contrast-color(), color-mix(), and relative color syntax all present, so an engine missing any one keeps the whole default badge rather than a custom fill whose family it cannot derive. */
			/* The default is the sRGB form of the canonical oklch(65% 0.18 260), not the oklch() itself, so the floor survives on an engine without oklch() instead of falling to an invalid background, the same custom-property trap the shadow guards against below. */
			--_accent: #488bfb;

			/* Tier 0 floor: a hand-authored set for the default accent, so the badge looks right where the functions below are missing. */
			/* Because Tier 0 pins the default accent and its foreground together, an unsupported engine ships a coherent default badge and a custom accent simply does not apply there, which is the real limit the article states plainly. */
			/* The foreground is black, the pick contrast-color() makes on the default accent, so no tier ships a foreground the next tier contradicts. */
			/* The border floor is the Tier 1 mix precomputed against a light canvas, color-mix(in oklab, #488bfb 62%, white) = #8eb9ff, since a single hex can match only one scheme, the price of a floor that cannot run color-mix(). */
			--_accent-fg: black;
			--_accent-border: #8eb9ff;
			--_accent-strong: #2f63d6;

			/* The floor elevation shadow is a plain rgb shadow; the guarded @supports block below upgrades it with light-dark() so dark mode gets a deeper one. */
			/* That upgrade sits in its own @supports, because a custom property accepts the unsupported light-dark() token sequence at parse time and only fails later when box-shadow consumes it (invalid at computed-value time), so a plain re-declaration would lose this floor. */
			--_accent-shadow: 0 1px 2px rgb(0 0 0 / 0.18);

			display: inline-flex;
			align-items: center;
			gap: 0.45em;
			padding: 0.32em 0.78em;
			border: 1px solid var(--_accent-border);
			border-radius: 999px;
			/* The badge fills with the raw accent, so the foreground below is picked against the surface the label actually sits on. */
			background: var(--_accent);
			color: var(--_accent-fg);
			box-shadow: var(--_accent-shadow);
			font: inherit;
			font-size: 0.8em;
			font-weight: 600;
			line-height: 1.2;
			letter-spacing: 0.01em;
		}

		/* A decorative status dot (aria-hidden) painted with the strong tonal variant, so the badge visibly leans on a derived token beyond its own fill; the fixed lightness offset is best-effort and can collapse into a very dark fill, so the dot is decoration, not a guaranteed-contrast state. */
		.dot {
			width: 0.55em;
			height: 0.55em;
			border-radius: 50%;
			background: var(--_accent-strong);
		}

		@supports (color: color-mix(in oklab, red, white)) {
			:host {
				/* Tier 1: derive the border from the one accent, mixed in oklab against canvas so the edge follows the theme. */
				--_accent-border: color-mix(in oklab, var(--_accent) 62%, canvas);
			}
		}

		@supports (color: oklch(from red calc(l + 0.2) c h)) {
			:host {
				/* Tier 2: the strong tonal variant from relative color syntax, with lightness as a unitless 0 to 1 number. */
				--_accent-strong: oklch(from var(--_accent) calc(l - 0.12) c h);
			}
		}

		@supports (color: contrast-color(red)) and (color: color-mix(in oklab, red, white)) and (color: oklch(from red calc(l + 0.2) c h)) {
			:host {
				/* Custom accent activation: the public token replaces the sRGB default only where the whole family can derive, contrast-color() for the foreground, color-mix() for the border, and relative color syntax for the dot, so an engine missing any one keeps the coherent default badge rather than a custom fill wearing the default's blue border and dot. */
				/* The stylesheet reads --smart-badge-accent but never declares it, so an inherited value or the template's door declaration wins; the sRGB #488bfb fallback keeps this declaration from leaning on an unguarded oklch(). */
				--_accent: var(--smart-badge-accent, #488bfb);
			}
		}

		@supports (color: contrast-color(red)) {
			:host {
				/* Tier 3: pick the foreground against the fill, which is black for the sRGB default and the higher-contrast of black or white for whatever custom accent activated above. */
				--_accent-fg: contrast-color(var(--_accent));
			}
		}

		@supports (box-shadow: 0 1px 2px light-dark(black, white)) {
			:host {
				/* Theme layer: light-dark() upgrades the shadow per used scheme, guarded in its own @supports tested in the box-shadow context where it is consumed, so the floor survives where light-dark() is unsupported. */
				--_accent-shadow: 0 1px 2px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.36));
			}
		}
	`;

	/**
	 * Guards the property door whenever `accent` changes, distilling it into the value the template may declare.
	 *
	 * A valid value lands in `validAccent`, a rejected one leaves it `null` and warns with the door's reason, and an unset property clears it silently, so falling back to the default is nothing more than declaring nothing.
	 * The guard never touches the DOM; the declaration itself belongs to `render()`.
	 *
	 * @param changed - The reactive properties that changed, from Lit.
	 */
	protected willUpdate( changed: PropertyValues<this> ): void {
		if ( ! changed.has( 'accent' ) ) {
			return;
		}

		if ( this.accent === undefined ) {
			this.validAccent = null;
			return;
		}

		const rejection = rejectAccent( this.accent );

		if ( rejection === null ) {
			this.validAccent = this.accent;
			return;
		}

		this.validAccent = null;
		console.warn( `<smart-badge> rejected accent "${this.accent}": ${accentRejectionMessage[ rejection ]}` );
	}

	/**
	 * Renders the accent declaration, the status dot, and the slotted label.
	 *
	 * The one-line `<style>` declares the door's accepted accent on the host, so an explicit property beats an inherited token, and it disappears entirely when there is nothing valid to declare, handing the badge back to the cascade.
	 * Interpolating into a stylesheet is safe here because only values the door has already proven to be one absolute color can reach it.
	 */
	render() {
		return html`
			${this.validAccent === null
				? null
				: html`<style>:host { --smart-badge-accent: ${this.validAccent}; }</style>`}
			<span class="dot" aria-hidden="true"></span>
			<slot></slot>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'smart-badge': SmartBadge;
	}
}

One optional enhancement pairs with the syntax-gate lesson. Registering the public token with @property constrains it to <color>, so a non-color value is rejected rather than becoming the token, while normal cascade and inheritance still apply and initial-value supplies the registered default:

css
@property --smart-badge-accent {
	syntax: '<color>';
	inherits: true;
	initial-value: #488bfb;
}

The rule has a placement catch. In current engines @property registers only from a document-level stylesheet, so it belongs in the page’s own CSS or a CSS.registerProperty call, and a copy pasted into the component’s static styles silently never registers inside the shadow root (Adobe’s shadow DOM support tables track exactly this). Once it’s registered, initial-value covers a missing token, and every engine that passes the badge’s activation tier also has @property, so inside that tier the component’s own #488bfb fallback stops being load-bearing; the component keeps it anyway, because it cannot know whether any page registered the property. It’s still only a syntax constraint, so @property validates that the value is a color but not that it’s opaque or in your brand range, both judgments that stay yours.

The payoff is the finished <smart-badge> in a review playground, and this time the read-only panel shows the playground’s own harness, the tier simulation and the door wiring included, since the component’s file is already printed above. Pour an accent in through the constrained picker, then try the free-text field with a typo, a var(), a CSS-wide keyword, or a translucent color and watch the accent door turn each away with its reason. Flip the color-scheme switch between light, dark, and “normal (no opt-in)” and the border mix and the light-dark() shadow track the used scheme, while each tier toggle, guarded by a real @supports, drops the preview to the default badge, since a custom accent activates only where its whole family can derive.

smart-badge-playground
import { LitElement, html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { demoChrome } from '../library/color-tokens-shared/chrome';
import { pickForeground, resolveToSrgb, contrastRatio } from '../library/color-tokens-shared/contrast';
import { rejectAccent, accentRejectionMessage } from '../library/smart-badge/accent-door';
import { parse, oklch, rgb, toGamut } from 'culori';
import type { Oklch } from 'culori';
import type { SrgbColor } from '../library/color-tokens-shared/types';
import type { AccentRejection } from '../library/smart-badge/types';
import '../library/smart-badge'; // registers <smart-badge>
import type { SmartBadge } from '../library/smart-badge';

/**
 * The `color-scheme` value the scheme switch drives onto the authored preview.
 *
 * `'light'` and `'dark'` opt the preview into one branch, which is how a demo forces a scheme since a sandboxed iframe cannot change the OS `prefers-color-scheme`.
 * `'normal'` forces `color-scheme: normal`, the state an element that never declares the property resolves to, which exposes the trap where `light-dark()` returns its light branch and the `canvas` mixes stop following the theme.
 */
type SchemeChoice = 'light' | 'dark' | 'normal';

/**
 * Maps a culori color into the sRGB gamut by OKLCH chroma reduction, the same constant-lightness constant-hue strategy the shared contrast helper uses.
 *
 * The dot-collapse warning derives its own OKLCH color object rather than a string, so it needs the gamut mapper directly here to convert that object to sRGB the way the shared helper converts a parsed string.
 */
const toSrgbGamut = toGamut( 'rgb', 'oklch' );

/**
 * The canonical accent pinned across the whole article, whose `contrast-color()` result is black.
 *
 * It seeds the picker, the free-text field, and the applied token so the playground opens on the value the central lesson is built around.
 */
const CANONICAL_ACCENT = 'oklch(65% 0.18 260)';

/**
 * The Tier 0 fill the preview paints when a custom accent cannot apply, the exact sRGB value the component's floor uses.
 *
 * It is the sRGB form of the canonical accent, so it looks identical, but the report and ladder print this string rather than the `oklch()` so their numbers describe the pixel the preview actually paints.
 */
const TIER_0_ACCENT = '#488bfb';

/**
 * The lowest dot-versus-fill WCAG contrast the tonal dot may reach before the demo flags it as collapsed.
 *
 * The dot is the fill darkened by a fixed `calc(l - 0.12)` offset, so a fill that is already dark leaves almost no separation between the two.
 * This threshold is chosen empirically to sit between the two cases the article cares about: the canonical `oklch(65% 0.18 260)` clears it at about 1.65, while a near-black accent like `black` (1.00) or `oklch(20% 0.03 260)` (about 1.15) falls under it and warns.
 */
const DOT_COLLAPSE_MIN_CONTRAST = 1.3;

/**
 * One derived token in the generated ladder, paired with whether its value follows the used color scheme.
 *
 * `followsScheme` marks the values whose painted result tracks light or dark (the `canvas` border mix and the `light-dark()` shadow), so the panel can flag them rather than show a swatch that would mislead under the normal-scheme trap.
 */
interface LadderRow {

	/**
	 * The private custom-property name the badge derives, such as `--_accent-border`.
	 */
	name: string;

	/**
	 * The generated CSS value for the current accent, the literal text the ladder produces.
	 */
	value: string;

	/**
	 * Whether this token's painted result follows the used color scheme, either through a `canvas` mix or a `light-dark()` value.
	 */
	followsScheme: boolean;
}

/**
 * D7, the review playground for the finished `<smart-badge>`, the article's centerpiece demo.
 *
 * It pours one accent into the live component's property door: a constrained color picker (a color input can only produce an opaque, valid hex) and an untrusted free-text field both set the badge's `accent`, and the harness runs the component's own `rejectAccent` first so a typo, a `var()`, a keyword, or a translucent color is surfaced with the door's reason instead of a silent console warning.
 * Beside the live badge it renders an authored preview that the scheme switch and the tier toggles drive, since neither the used color scheme nor real feature support can be forced from a page, so the controllable model degrades while the real component stays untouched, with its own source printed as the article's step 7 file and this harness in the demo's source panel.
 * The contrast report and the generated token ladder follow the effective accent, the one the preview actually paints, so with `contrast-color()` simulated off they describe the default the badge falls back to rather than a custom accent that is not in force, while the copyable one-line API carries the requested accent the consumer would set.
 *
 * @element demo-color-token-contract-color-tokens-smart-badge
 */
@customElement( 'demo-color-token-contract-color-tokens-smart-badge' )
export class SmartBadgePlaygroundDemo extends LitElement {

	/**
	 * The accent currently applied to the contract, the single source the report, token list, and CSS all read.
	 *
	 * The constrained picker writes a hex here directly, and an accepted free-text value updates it, so it always holds the value the badge actually paints from.
	 */
	@state()
	private appliedAccent = CANONICAL_ACCENT;

	/**
	 * The hex held by the constrained color input, kept in sync so the swatch reflects the live accent.
	 *
	 * A color input is constrained at the source to a safe opaque hex, so this path needs no validation, which is the constrained half of the split.
	 */
	@state()
	private pickerHex = '#488bfb';

	/**
	 * The raw text in the untrusted free-text field, before any validation.
	 *
	 * It is held as state so the field stays controlled and a rejected value can persist on screen next to its rejection message.
	 */
	@state()
	private freeText = CANONICAL_ACCENT;

	/**
	 * The reason the last free-text submission was rejected, or `null` when the last value was accepted.
	 *
	 * It comes from the component's own `rejectAccent`, run by the harness before it sets the property, so the message shows the door's real verdict instead of leaving it as a console warning.
	 */
	@state()
	private rejection: AccentRejection | null = null;

	/**
	 * The free-text value that was rejected, held so the message can quote the exact string the reader typed.
	 */
	@state()
	private rejectedValue = '';

	/**
	 * The color scheme the scheme switch drives onto the authored preview.
	 *
	 * It starts at `'light'` so the preview opens in a known branch, and the `'normal'` position is labelled as the trap rather than a neutral default.
	 */
	@state()
	private scheme: SchemeChoice = 'light';

	/**
	 * Whether the Tier 1 `color-mix()` border layer is simulated as unsupported on the preview.
	 *
	 * It only adds or removes a harness class on the host, which lives inside the same `@supports` block the real component uses, so the toggle is a second simulation layer stacked on top of genuine feature detection.
	 */
	@state()
	private noTier1 = false;

	/**
	 * Whether the Tier 2 relative-color tonal layer is simulated as unsupported on the preview.
	 */
	@state()
	private noTier2 = false;

	/**
	 * Whether the Tier 3 `contrast-color()` foreground layer is simulated as unsupported on the preview.
	 *
	 * With it on, the preview drops the applied accent and its derived family back to the whole Tier 0 default badge, since the accent activates only under the combined selector that needs all three tiers present and this class removes one.
	 * This mirrors how an engine missing `contrast-color()`, `color-mix()`, or relative color syntax keeps the validated default instead of a custom fill whose family it cannot fully derive.
	 */
	@state()
	private noTier3 = false;

	/**
	 * Whether the `light-dark()` shadow upgrade is simulated as unsupported on the preview.
	 *
	 * With it on, the preview keeps the plain sRGB floor shadow, so the reader sees the scheme switch stop flipping the shadow depth once the function is gone.
	 */
	@state()
	private noTierTheme = false;

	/**
	 * Whether the copy button has just copied the generated CSS, for a brief confirmation label.
	 */
	@state()
	private copied = false;

	/**
	 * The live `<smart-badge>`, resolved by Lit, driven only by setting its `--smart-badge-accent` custom property.
	 */
	@query( 'smart-badge' )
	private $badge!: SmartBadge;

	/**
	 * The accent the preview actually paints under the current simulation, which is not always the one the reader requested.
	 *
	 * A custom accent is enhancement-only, and it applies only where its whole family can derive: the applied accent is in force just when `contrast-color()`, `color-mix()`, and relative color syntax are all present and none of Tier 1/2/3 is simulated off, mirroring the component's combined activation `@supports`.
	 * It returns the applied accent under that whole-family condition, and otherwise falls back to `TIER_0_ACCENT`, the exact sRGB fill the preview paints when the accent cannot apply, so a custom fill never wears the default's blue border and dot.
	 * The report, the token ladder, and the collapse warning all read this rather than `appliedAccent`, so they describe the surface the preview really shows instead of a custom accent that is not in force.
	 *
	 * @returns The applied accent when the whole family activates, otherwise the Tier 0 sRGB fill.
	 */
	private get effectiveAccent(): string {
		const wholeFamily =
			CSS.supports( 'color', 'contrast-color(red)' ) &&
			CSS.supports( 'color', 'color-mix(in oklab, red, white)' ) &&
			CSS.supports( 'color', 'oklch(from red calc(l + 0.2) c h)' ) &&
			! this.noTier1 &&
			! this.noTier2 &&
			! this.noTier3;

		return wholeFamily ? this.appliedAccent : TIER_0_ACCENT;
	}

	/**
	 * Styles for the playground, composed on top of the shared demo chrome so it reads as one surface with the other labs.
	 *
	 * The authored preview owns its own ladder here on purpose, each enhanced tier wrapped in the same `@supports` block the production component uses with the `.no-tier-*` host class kept inside the guard, so real feature detection runs first and the toggles degrade the simulation on top of it while the real component's `static styles` stay untouched.
	 */
	static styles = [
		demoChrome,
		css`
			.stage {
				display: grid;
				grid-template-columns: 1fr;
				gap: var(--space-4, 16px);
			}

			@media (min-width: 34rem) {
				.stage {
					grid-template-columns: 1fr 1fr;
				}
			}

			.stage-cell {
				display: flex;
				flex-direction: column;
				gap: var(--space-2, 8px);
				align-items: flex-start;
				padding: var(--space-4, 16px);
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--bg, #fcfcfb);
			}

			.stage-label {
				font-family: var(--font-mono, ui-monospace, monospace);
				font-size: var(--fs-11, 11px);
				letter-spacing: 0.06em;
				text-transform: uppercase;
				color: var(--ink-faint, #71717b);
			}

			/* The authored preview mirrors the badge's structure and ladder, so it can be driven by the scheme switch and the tier toggles the real component will not expose. */
			.preview {
				/* Tier 0 reads the default accent only; the applied accent (--sim-accent) is resolved in the combined activation block below only where the whole family (contrast-color(), color-mix(), and relative color syntax) can derive, so disabling any one of Tier 1/2/3 drops the preview to the whole default badge, mirroring the component. */
				/* This is the sRGB form of the canonical oklch(65% 0.18 260), not the oklch() itself, so the floor does not itself depend on oklch() and survives on an engine without it rather than falling to an invalid background. */
				--_preview-accent: #488bfb;

				/* Tier 0 floor: the same hand-authored sRGB set the library ships, a curated default contract that stays true when a tier is simulated off. */
				/* The foreground is black, the pick contrast-color() makes on the default accent fill, so the floor and Tier 3 agree on the surface the label sits on. */
				--_preview-fg: black;
				--_preview-border: #8eb9ff;
				--_preview-strong: #2f63d6;

				/* Floor elevation shadow: a plain rgb shadow, upgraded to a light-dark() shadow in the guarded @supports block below so the scheme switch can flip its depth. */
				--_preview-shadow: 0 1px 2px rgb(0 0 0 / 0.18);

				display: inline-flex;
				align-items: center;
				gap: 0.45em;
				padding: 0.32em 0.78em;
				border: 1px solid var(--_preview-border);
				border-radius: 999px;
				/* The preview fills with the raw accent, mirroring the component, so its foreground is picked against the same surface the label actually sits on. */
				background: var(--_preview-accent);
				color: var(--_preview-fg);
				box-shadow: var(--_preview-shadow);
				font-size: 0.95rem;
				font-weight: 600;
				line-height: 1.2;
				letter-spacing: 0.01em;
			}

			.preview .dot {
				width: 0.55em;
				height: 0.55em;
				border-radius: 50%;
				background: var(--_preview-strong);
			}

			/* The scheme switch sets color-scheme inline: light and dark opt the wrapper in, and the normal position forces normal, which is what an element that never declares color-scheme resolves to, so light-dark() returns light and the canvas mixes stop following the theme. */
			.preview-scheme {
				display: inline-flex;
				padding: var(--space-3, 12px);
				border-radius: var(--radius-sm, 7px);
				background: canvas;
				color: canvastext;
			}

			/* Tier 1: the border mix, guarded by real color-mix() support with the no-tier-1 class kept inside the guard so the checkbox is a second simulation layer on top of feature detection. */
			@supports (color: color-mix(in oklab, red, white)) {
				:host(:not(.no-tier-1)) .preview {
					--_preview-border: color-mix(in oklab, var(--_preview-accent) 62%, canvas);
				}
			}

			/* Tier 2: the strong tonal variant from relative color syntax, guarded by real relative-color support. */
			@supports (color: oklch(from red calc(l + 0.2) c h)) {
				:host(:not(.no-tier-2)) .preview {
					--_preview-strong: oklch(from var(--_preview-accent) calc(l - 0.12) c h);
				}
			}

			/* Custom accent activation: the applied accent (--sim-accent) replaces the sRGB default only where the whole family can derive, contrast-color() for the foreground, color-mix() for the border, and relative color syntax for the dot, and only when none of Tier 1/2/3 is simulated off, so with any one simulated off the preview keeps the coherent default badge rather than a custom fill wearing the default's blue border and dot. */
			/* The #488bfb fallback keeps this declaration from leaning on an unguarded oklch(); the base floor already reads #488bfb, so where this block does not win the preview paints the whole default badge. */
			@supports (color: contrast-color(red)) and (color: color-mix(in oklab, red, white)) and (color: oklch(from red calc(l + 0.2) c h)) {
				:host(:not(.no-tier-1):not(.no-tier-2):not(.no-tier-3)) .preview {
					--_preview-accent: var(--sim-accent, #488bfb);
				}
			}

			/* Tier 3: pick the foreground against the fill, which is black for the sRGB default and the higher-contrast of black or white for whatever custom accent activated above; with no-tier-3 (or an engine lacking contrast-color()) the preview keeps the black floor foreground. */
			@supports (color: contrast-color(red)) {
				:host(:not(.no-tier-3)) .preview {
					--_preview-fg: contrast-color(var(--_preview-accent));
				}
			}

			/* Theme layer: light-dark() upgrades the shadow per used scheme, guarded in its own @supports tested in the box-shadow context where it is consumed, so the floor survives where light-dark() is unsupported. */
			@supports (box-shadow: 0 1px 2px light-dark(black, white)) {
				:host(:not(.no-tier-theme)) .preview {
					--_preview-shadow: 0 1px 2px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.36));
				}
			}

			.trap-note {
				margin: 0;
				padding: var(--space-2, 8px) var(--space-3, 12px);
				border-radius: var(--radius-sm, 7px);
				background: color-mix(in oklab, orange 14%, var(--surface, #fff));
				color: var(--ink, #17171a);
				font-size: var(--fs-12, 12px);
				line-height: 1.5;
			}

			.rejection {
				margin: 0;
				padding: var(--space-2, 8px) var(--space-3, 12px);
				border-radius: var(--radius-sm, 7px);
				background: color-mix(in oklab, crimson 12%, var(--surface, #fff));
				color: var(--ink, #17171a);
				font-size: var(--fs-12, 12px);
				line-height: 1.5;
			}

			.rejection code,
			.trap-note code {
				font-family: var(--font-mono, ui-monospace, monospace);
			}

			.segmented {
				display: inline-flex;
				flex-wrap: wrap;
				gap: var(--space-1, 4px);
			}

			/* The native radio is visually hidden and the label pill carries the checked state, so the OS never paints a dark control on a light card when the schemes disagree. */
			.segmented input {
				position: absolute;
				width: 1px;
				height: 1px;
				margin: -1px;
				clip-path: inset(50%);
				overflow: hidden;
			}

			.segmented label {
				display: inline-flex;
				align-items: center;
				gap: 0.35em;
				padding: 0.3em 0.6em;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				font-size: var(--fs-13, 13px);
				cursor: pointer;
			}

			.segmented label:has(input:checked) {
				border-color: var(--accent, #2257e6);
				background: var(--tint, #eef2fe);
				color: var(--accent, #2257e6);
				font-weight: 600;
			}

			.segmented label:has(input:focus-visible) {
				outline: 2px solid var(--accent, #2257e6);
				outline-offset: 2px;
			}

			.toggles {
				display: flex;
				flex-direction: column;
				gap: var(--space-2, 8px);
			}

			.toggle {
				display: flex;
				align-items: center;
				gap: 0.5em;
				font-size: var(--fs-13, 13px);
				cursor: pointer;
			}

			/* The tier toggles are drawn switches rather than native checkboxes, for the same scheme-clash reason as the radios above. */
			.toggle input {
				position: absolute;
				width: 1px;
				height: 1px;
				margin: -1px;
				clip-path: inset(50%);
				overflow: hidden;
			}

			.toggle-track {
				flex: none;
				box-sizing: border-box;
				display: inline-flex;
				align-items: center;
				width: 2.1em;
				height: 1.2em;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: 999px;
				background: var(--tint, #eef2fe);
				transition: background 120ms ease, border-color 120ms ease;
			}

			/* The thumb stays clearly inset from the track in both states, so the pill reads as a switch rather than a blob. */
			.toggle-thumb {
				box-sizing: border-box;
				display: block;
				width: 0.85em;
				height: 0.85em;
				margin-left: 0.15em;
				border-radius: 50%;
				border: 1px solid var(--line, #e7e6e2);
				background: var(--surface, #fff);
				box-shadow: 0 1px 1px rgb(0 0 0 / 0.15);
				transition: translate 120ms ease;
			}

			.toggle input:checked + .toggle-track {
				border-color: var(--accent, #2257e6);
				background: var(--accent, #2257e6);
			}

			.toggle input:checked + .toggle-track .toggle-thumb {
				border-color: transparent;
				translate: 0.9em 0;
			}

			.toggle input:focus-visible + .toggle-track {
				outline: 2px solid var(--accent, #2257e6);
				outline-offset: 2px;
			}

			.sim-flag {
				font-size: var(--fs-12, 12px);
				color: var(--ink-soft, #56565c);
			}

			.token-list {
				display: flex;
				flex-direction: column;
				gap: var(--space-1, 4px);
			}

			.token-row {
				display: grid;
				grid-template-columns: auto 1fr auto;
				align-items: center;
				gap: var(--space-2, 8px);
				font-size: var(--fs-12, 12px);
			}

			.token-value {
				font-family: var(--font-mono, ui-monospace, monospace);
				color: var(--ink, #17171a);
				overflow-x: auto;
				white-space: nowrap;
			}

			.token-swatch {
				width: 1.4rem;
				height: 1.4rem;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
			}

			/* A small chip rather than bare text, matching how the other articles tag a row; it hides itself when empty so scheme-independent rows keep a clean third column. */
			.token-follows {
				justify-self: end;
				padding: 0.1em 0.5em;
				border-radius: 999px;
				background: var(--tint, #eef2fe);
				font-size: var(--fs-11, 11px);
				color: var(--ink-soft, #56565c);
				white-space: nowrap;
			}

			.token-follows:empty {
				display: none;
			}

			/* The copy panel stacks its explainer row and the snippet with the same rhythm the rest of the demo uses. */
			.copy-panel {
				display: flex;
				flex-direction: column;
				gap: var(--space-2, 8px);
			}

			.copy-row {
				display: flex;
				align-items: center;
				gap: var(--space-3, 12px);
			}

			/* The button never shrinks or wraps; the note beside it is the flexible column. */
			.copy-button {
				flex: none;
				white-space: nowrap;
				padding: 0.4em 0.8em;
				border: 1px solid var(--line, #e7e6e2);
				border-radius: var(--radius-sm, 7px);
				background: var(--surface, #fff);
				color: var(--ink, #17171a);
				font: inherit;
				font-size: var(--fs-13, 13px);
				cursor: pointer;
			}

			.copy-button:focus-visible {
				outline: 2px solid var(--accent, #2257e6);
				outline-offset: 2px;
			}

			.ship {
				display: flex;
				flex-direction: column;
				gap: var(--space-2, 8px);
				padding: var(--space-4, 16px);
				border: 1px solid var(--line, #e7e6e2);
				border-left: 3px solid var(--accent, #2257e6);
				border-radius: var(--radius-sm, 7px);
				background: var(--bg, #fcfcfb);
			}

			.ship-title {
				margin: 0;
				font-size: var(--fs-14, 14px);
				font-weight: 600;
			}

			.ship p {
				margin: 0;
				font-size: var(--fs-13, 13px);
				line-height: 1.6;
				color: var(--ink-soft, #56565c);
			}

			.ship strong {
				color: var(--ink, #17171a);
				font-weight: 600;
			}
		`,
	];

	/**
	 * Adds or removes the `.no-tier-*` host classes after each render so the authored preview matches the toggle state.
	 *
	 * The classes gate the preview's enhanced ladder inside the `@supports` blocks in `static styles`, so flipping a toggle degrades the simulation without ever touching the real component.
	 */
	updated(): void {
		this.classList.toggle( 'no-tier-1', this.noTier1 );
		this.classList.toggle( 'no-tier-2', this.noTier2 );
		this.classList.toggle( 'no-tier-3', this.noTier3 );
		this.classList.toggle( 'no-tier-theme', this.noTierTheme );
	}

	/**
	 * Applies a constrained hex from the color input through the badge's accent property.
	 *
	 * A color input is constrained to a safe opaque hex at the source, so the door will always accept it, and going through the property anyway means the playground exercises the exact API a consumer uses.
	 * It clears any standing rejection, since a fresh accepted value has now replaced whatever the free-text field last complained about.
	 *
	 * @param event - The input event from the color picker.
	 */
	private onPickerInput = ( event: Event ): void => {
		if ( ! ( event.target instanceof HTMLInputElement ) ) {
			return;
		}

		const hex = event.target.value;
		this.$badge.accent = hex;
		this.pickerHex = hex;
		this.appliedAccent = hex;
		this.freeText = hex;
		this.rejection = null;
	};

	/**
	 * Tracks the untrusted field's text without applying it yet, so a value is only validated on submit.
	 *
	 * @param event - The input event from the free-text field.
	 */
	private onFreeTextInput = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			this.freeText = event.target.value;
		}
	};

	/**
	 * Runs the untrusted field's text through the component's own door on submit, then either applies it or reports why it was turned away.
	 *
	 * The check is the badge's `rejectAccent`, the exact function the property door runs, so the playground surfaces the door's verdict with its reason instead of leaving it as a console warning.
	 * On accept it sets the badge's `accent` property, updates the applied accent, and clears any standing message.
	 * On reject it records the reason and the offending value and leaves the previous accent in place, so the badge keeps painting the last good color while the bad value is surfaced.
	 *
	 * @param event - The submit event from the free-text form.
	 */
	private onFreeTextSubmit = ( event: Event ): void => {
		event.preventDefault();

		const candidate = this.freeText.trim();
		const reason = rejectAccent( candidate );

		if ( reason !== null ) {
			this.rejection = reason;
			this.rejectedValue = this.freeText;
			return;
		}

		this.$badge.accent = candidate;
		this.appliedAccent = candidate;
		this.rejection = null;
		this.rejectedValue = '';
	};

	/**
	 * Updates the simulated color scheme on the authored preview.
	 *
	 * @param event - The change event from the scheme switch.
	 */
	private onSchemeChange = ( event: Event ): void => {
		if ( event.target instanceof HTMLInputElement ) {
			const value = event.target.value;

			if ( value === 'light' || value === 'dark' || value === 'normal' ) {
				this.scheme = value;
			}
		}
	};

	/**
	 * Copies the generated CSS to the clipboard and flashes a brief confirmation.
	 *
	 * It writes the one-line public API a consumer actually authors, the single `--smart-badge-accent` declaration for the requested accent, not the private derived tokens, since the component owns the derivations and the fallback.
	 */
	private onCopy = async (): Promise<void> => {
		try {
			await navigator.clipboard.writeText( this.generatedCss() );
			this.copied = true;
			window.setTimeout( () => ( this.copied = false ), 1200 );
		} catch {
			// Clipboard access can be denied in a sandboxed frame; leave the block on screen to copy by hand.
		}
	};

	/**
	 * Builds the active ladder for the effective accent, the token each tier actually produces under the current support simulation.
	 *
	 * Each row shows the enhanced formula where its tier is on and the Tier 0 floor value where the tier is simulated off, so the ladder always matches what the preview paints rather than the formula the badge would use on full support.
	 * It reads `effectiveAccent`, not `appliedAccent`, so with Tier 3 off the fill and its derivations describe the default the preview actually paints rather than a custom accent that is not in force.
	 *
	 * @returns One row per token, flagged when its painted value follows the used color scheme.
	 */
	private ladder(): LadderRow[] {
		const accent = this.effectiveAccent;
		const tier1 = CSS.supports( 'color', 'color-mix(in oklab, red, white)' ) && ! this.noTier1;
		const tier2 = CSS.supports( 'color', 'oklch(from red calc(l + 0.2) c h)' ) && ! this.noTier2;
		const tier3 = CSS.supports( 'color', 'contrast-color(red)' ) && ! this.noTier3;
		const themeTier = CSS.supports( 'box-shadow', '0 1px 2px light-dark(black, white)' ) && ! this.noTierTheme;

		return [
			{ name: '--_accent-fg', value: tier3 ? `contrast-color(${ accent })` : 'black', followsScheme: false },
			{ name: '--_accent-border', value: tier1 ? `color-mix(in oklab, ${ accent } 62%, canvas)` : '#8eb9ff', followsScheme: tier1 },
			{ name: '--_accent-strong', value: tier2 ? `oklch(from ${ accent } calc(l - 0.12) c h)` : '#2f63d6', followsScheme: false },
			{ name: '--_accent-shadow', value: themeTier ? '0 1px 2px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.36))' : '0 1px 2px rgb(0 0 0 / 0.18)', followsScheme: themeTier },
		];
	}

	/**
	 * Computes the WCAG contrast between the tonal dot and the fill it sits on, both derived from a given accent, or `null` when the accent does not resolve.
	 *
	 * It reproduces the component's dot exactly: parse the accent to OKLCH, subtract the fixed `0.12` from lightness clamped to the 0 to 1 range, keep chroma and hue, then convert that dot color and the fill (the accent itself) to sRGB and score them.
	 * The dot is the fill darkened by a fixed offset, so a fill that is already dark leaves the two nearly the same color, and this ratio is how the demo decides whether the decorative dot has collapsed into its fill.
	 * It returns `null` rather than throwing when the accent cannot be parsed to a fixed color, so a caller can treat that as "nothing to warn about" without a try/catch.
	 *
	 * @param accent - The accent the fill and dot are both derived from, as a CSS color string.
	 * @returns The dot-versus-fill WCAG contrast ratio, or `null` when the accent does not resolve to a fixed color.
	 */
	private dotCollapseContrast( accent: string ): number | null {
		const parsed = parse( accent.trim() );

		if ( parsed === undefined ) {
			return null;
		}

		const base = oklch( parsed );

		if ( base === undefined ) {
			return null;
		}

		const dotColor: Oklch = {
			mode: 'oklch',
			l: Math.min( 1, Math.max( 0, base.l - 0.12 ) ),
			c: base.c,
			h: base.h,
		};

		const converted = rgb( toSrgbGamut( dotColor ) );
		const fill = resolveToSrgb( accent );

		if ( converted === undefined || fill === null ) {
			return null;
		}

		const clamp = ( channel: number ): number => Math.min( 1, Math.max( 0, channel ) );
		const dot: SrgbColor = {
			r: clamp( converted.r ),
			g: clamp( converted.g ),
			b: clamp( converted.b ),
			alpha: converted.alpha ?? 1,
		};

		return contrastRatio( dot, fill );
	}

	/**
	 * Renders the one-liner a consumer writes to request this accent, both doors shown.
	 *
	 * The first line is the everyday property door on one badge, and the comment names the design-system alternative, the inherited token an ancestor sets for a whole subtree; a consumer never sets the `--_accent-*` implementation tokens either way.
	 * It uses `appliedAccent`, the accent the reader asked for, because the consumer copies the value they want to request and the component decides the fallback when it cannot apply.
	 *
	 * @returns The usage snippet for the requested accent.
	 */
	private generatedCss(): string {
		return `<smart-badge accent="${ this.appliedAccent }">Featured</smart-badge>\n\n/* or from an ancestor's stylesheet rule, for a whole subtree: */\n.product-rail {\n\t--smart-badge-accent: ${ this.appliedAccent };\n}`;
	}

	/**
	 * Renders the note shown when the requested accent is not the one the preview paints, or `null` when they match.
	 *
	 * A custom accent applies only where its whole family can derive, so with any one of `contrast-color()`, `color-mix()`, or relative color syntax simulated off it does not apply and the badge keeps the default; this note tells the reader that the report and formulas below therefore describe that default rather than the accent they typed.
	 * It is shown only when `appliedAccent` and `effectiveAccent` differ, so the honest case where the request is in force stays quiet.
	 *
	 * @returns The mismatch note, or `null` when the requested accent is the one the preview paints.
	 */
	private renderRequestedNote() {
		if ( this.appliedAccent === this.effectiveAccent ) {
			return null;
		}

		return html`<p class="demo-note">
			You requested <code>${ this.appliedAccent }</code>.
			A custom accent applies only where <code>contrast-color()</code>, <code>color-mix()</code>, and relative color syntax are all present, so with any one simulated off the badge keeps the default, and the report and formulas below describe that default.
		</p>`;
	}

	/**
	 * Builds the contrast report for the effective accent, the foreground pick with both WCAG ratios.
	 *
	 * Since the badge fills with the raw accent, that accent is the surface behind the label, so scoring black and white against it is the right comparison.
	 * It reads `effectiveAccent`, not `appliedAccent`, so with Tier 3 simulated off it scores the default the preview actually paints rather than a custom accent that is not in force, with the mismatch called out by the requested note above it.
	 * It leans on the shared `pickForeground`, a WCAG 2 comparison that explains the black-versus-white result rather than a guaranteed match for `contrast-color()`, whose exact algorithm CSS Color 5 leaves user-agent defined, so where the function runs the live browser result is authoritative.
	 * The boundary validation already blocks a translucent or unparseable accent, so the pick is only ever `null` defensively, in which case a graceful note stands in for the report.
	 *
	 * @returns The report rows, or a single note when the accent does not resolve to an opaque color.
	 */
	private renderReport() {
		const pick = pickForeground( this.effectiveAccent );

		if ( ! pick ) {
			return html`
				${ this.renderRequestedNote() }
				<p class="demo-note">The current accent does not resolve to an opaque color, so there is nothing to score.</p>
			`;
		}

		const blackWins = pick.foreground === 'black';
		const gap = Math.abs( pick.blackRatio - pick.whiteRatio );

		return html`
			${ this.renderRequestedNote() }
			<div class="report">
				<div class="report-row">
					<span class="report-label">Black on the accent</span>
					<span class="report-ratio">${ pick.blackRatio.toFixed( 2 ) }:1</span>
					<span class="report-verdict" data-pass=${ blackWins ? 'true' : 'false' }>
						${ blackWins ? 'higher' : 'lower' }
					</span>
				</div>
				<div class="report-row">
					<span class="report-label">White on the accent</span>
					<span class="report-ratio">${ pick.whiteRatio.toFixed( 2 ) }:1</span>
					<span class="report-verdict" data-pass=${ blackWins ? 'false' : 'true' }>
						${ blackWins ? 'lower' : 'higher' }
					</span>
				</div>
			</div>
			${ gap < 1
				? html`<p class="demo-note">
						<span class="report-verdict" data-pass="false">coin flip</span>
						<strong>${ pick.foreground }</strong> wins by only ${ gap.toFixed( 2 ) } here, the mid-tone zone where the WCAG 2 math is nearly a coin flip and the losing candidate often looks better, so this is exactly the accent a human eye should overrule by changing the contract, not the pick.
					</p>`
				: null }
			<p class="demo-note">
				This is a WCAG 2 comparison against the accent fill, so it explains why <strong>${ pick.foreground }</strong> wins the black-versus-white call; where <code>contrast-color()</code> runs, the live browser result is the authoritative one.
				The math can pass while the result still reads poorly, so a mid-tone accent still wants a human eye on it.
			</p>
		`;
	}

	/**
	 * Resolves a scheme-independent ladder value to a hex swatch, or `null` when it cannot be resolved here.
	 *
	 * It only resolves values that do not depend on the used color scheme, since a `canvas` mix or a `light-dark()` value needs a scheme that a detached probe does not carry, so the panel flags those rather than show a misleading chip.
	 * The row value is already tier-aware (the Tier 0 floor where a tier is simulated off), so the chip paints exactly the color the preview does at the current support level.
	 *
	 * @param row - The ladder row to resolve.
	 * @returns A CSS color string safe to paint as a swatch, or `null` to skip the swatch.
	 */
	private swatchValue( row: LadderRow ): string | null {
		if ( row.followsScheme ) {
			return null;
		}

		return CSS.supports( 'color', row.value ) ? row.value : null;
	}

	/**
	 * Renders the live component, the authored scheme-and-tier preview, the controls, and the review panels.
	 */
	render() {
		const ladder = this.ladder();
		// The collapse warning only applies to the relative-color dot the preview actually paints, so gate it on the same Tier 2 state: with Tier 2 simulated off (or relative color unsupported) the dot is the hand-authored floor #2f63d6, which does not collapse, and warning about the calc(l - 0.12) derivation would contradict the preview.
		const derivedDotInForce = CSS.supports( 'color', 'oklch(from red calc(l + 0.2) c h)' ) && ! this.noTier2;
		const dotCollapse = derivedDotInForce ? this.dotCollapseContrast( this.effectiveAccent ) : null;

		return html`
			<div class="demo">
				<h3 class="demo-title">The smart-badge review playground</h3>
				<p class="demo-note">
					Pour one accent into the contract and watch every derived state, the contrast call, and the generated CSS follow it.
					The picker is the constrained path, since a color input can only produce an opaque, valid hex; the free-text field is the untrusted one, run through the badge's own accent door so its rejection reasons surface here instead of in the console.
				</p>

				<div class="stage">
					<div class="stage-cell">
						<span class="stage-label">Live component (follows your OS theme)</span>
						<smart-badge>Featured</smart-badge>
					</div>
					<div class="stage-cell">
						<span class="stage-label">Scheme and support preview (simulated)</span>
						<div class="preview-scheme" style="color-scheme: ${ this.scheme === 'normal' ? 'normal' : this.scheme }">
							<span class="preview" style="--sim-accent: ${ this.appliedAccent }">
								<span class="dot" aria-hidden="true"></span>
								Featured
							</span>
						</div>
						<span class="sim-flag">
							The demo cannot change your OS light or dark preference, so the preview sets its own used color scheme, and it simulates missing support with the toggles.
						</span>
					</div>
				</div>

				${ this.scheme === 'normal'
					? html`<p class="trap-note">
							This is the trap. An element that never declares <code>color-scheme</code> resolves to <code>normal</code>, so <code>light-dark()</code> returns its light branch in both OS modes and the <code>canvas</code> mixes stop following the theme.
							Declaring <code>color-scheme: light dark</code> is what makes the theme branch real.
						</p>`
					: null }

				${ this.rejection
					? html`<p id="free-text-error" class="rejection" role="alert">
							Rejected <code>${ this.rejectedValue }</code>. ${ accentRejectionMessage[ this.rejection ] }
						</p>`
					: null }

				<div class="controls">
					<div class="control">
						<label class="control-label" for="picker">Accent (constrained)</label>
						<input
							id="picker"
							type="color"
							.value=${ this.pickerHex }
							@input=${ this.onPickerInput }
						/>
						<span class="control-value">${ this.pickerHex }</span>
					</div>

					<form class="control" @submit=${ this.onFreeTextSubmit }>
						<label class="control-label" for="free-text">Accent (free text)</label>
						<input
							id="free-text"
							type="text"
							.value=${ this.freeText }
							@input=${ this.onFreeTextInput }
							placeholder="try yellow, #abc, or a typo"
							aria-invalid=${ this.rejection !== null ? 'true' : 'false' }
							aria-describedby=${ this.rejection ? 'free-text-help free-text-error' : 'free-text-help' }
						/>
						<button class="copy-button" type="submit">Apply</button>
					</form>
					<p id="free-text-help" class="demo-note">
						A color input cannot produce an invalid value; this field can, so the harness validates it and rejects a <code>var()</code>, a CSS-wide keyword, a typo, a context-dependent color like <code>currentColor</code>, or a translucent color.
					</p>

					<div class="control">
						<span class="control-label" id="scheme-label">color-scheme</span>
						<div class="segmented" role="radiogroup" aria-labelledby="scheme-label">
							${ ( [ 'light', 'dark', 'normal' ] as const ).map(
								( option ) => html`
									<label>
										<input
											type="radio"
											name="scheme"
											value=${ option }
											.checked=${ this.scheme === option }
											@change=${ this.onSchemeChange }
										/>
										${ option === 'normal' ? 'Normal (no opt-in)' : option }
									</label>
								`,
							) }
						</div>
					</div>

					<div class="control">
						<span class="control-label" id="tier-label">Support tiers</span>
						<div class="toggles" role="group" aria-labelledby="tier-label">
							<label class="toggle">
								<input type="checkbox" .checked=${ this.noTier1 } @change=${ () => ( this.noTier1 = ! this.noTier1 ) } />
								<span class="toggle-track"><span class="toggle-thumb"></span></span>
								Disable Tier 1 (color-mix border)
							</label>
							<label class="toggle">
								<input type="checkbox" .checked=${ this.noTier2 } @change=${ () => ( this.noTier2 = ! this.noTier2 ) } />
								<span class="toggle-track"><span class="toggle-thumb"></span></span>
								Disable Tier 2 (relative-color variant)
							</label>
							<label class="toggle">
								<input type="checkbox" .checked=${ this.noTier3 } @change=${ () => ( this.noTier3 = ! this.noTier3 ) } />
								<span class="toggle-track"><span class="toggle-thumb"></span></span>
								Disable Tier 3 (contrast-color foreground)
							</label>
							<label class="toggle">
								<input type="checkbox" .checked=${ this.noTierTheme } @change=${ () => ( this.noTierTheme = ! this.noTierTheme ) } />
								<span class="toggle-track"><span class="toggle-thumb"></span></span>
								Disable light-dark() shadow
							</label>
						</div>
					</div>
					<p class="sim-flag">
						These toggles add demo classes inside the real <code>@supports</code> guards, so they simulate support on top of feature detection; they do not change what your browser actually supports.
					</p>
				</div>

				${ this.renderReport() }

				<div>
					<p class="demo-note"><strong>The active tokens under this support level, matching the preview.</strong> Each falls back to the Tier 0 floor where its tier is simulated off. The <code>canvas</code> border mix and the <code>light-dark()</code> shadow follow the scheme, so their result is shown on the live badge above rather than as a swatch here.</p>
					<div class="token-list">
						${ ladder.map( ( row ) => {
							const swatch = this.swatchValue( row );

							return html`
								<div class="token-row">
									${ swatch
										? html`<span class="token-swatch" style="background: ${ swatch }"></span>`
										: html`<span class="token-swatch" style="background: var(--line, #e7e6e2)"></span>` }
									<code class="token-value">${ row.name }: ${ row.value }</code>
									<span class="token-follows">${ row.followsScheme ? 'follows scheme' : '' }</span>
								</div>
							`;
						} ) }
					</div>
					${ dotCollapse !== null && dotCollapse < DOT_COLLAPSE_MIN_CONTRAST
						? html`<p class="rejection">
								Review required: the tonal dot collapses into this fill (contrast ${ dotCollapse.toFixed( 2 ) } to 1).
								A production badge that needs the dot visible would constrain the accent's lightness range or derive the dot toward the foreground, the article's step-5 boundary.
							</p>`
						: null }
				</div>

				<div class="copy-panel">
					<div class="copy-row">
						<button class="copy-button" type="button" @click=${ this.onCopy }>
							${ this.copied ? 'Copied' : 'Copy the usage' }
						</button>
						<span class="demo-note">The one-liner a consumer writes: the validated <code>accent</code> property on one badge, or the inherited <code>--smart-badge-accent</code> token an ancestor sets for a whole subtree. The ladder above shows what that accent expands to inside the component, which you do not author.</span>
					</div>
					<pre class="code-block"><code>${ this.generatedCss() }</code></pre>
				</div>

				<div class="ship">
					<p class="ship-title">Ship?</p>
					<p>
						Yes, once the contract is named. CSS owns the derivations now, so the accent drives the foreground, border, and tonal dot through <code>contrast-color()</code>, <code>color-mix()</code>, and relative color syntax, while <code>light-dark()</code> drives a separate theme-based elevation shadow that is not derived from the accent, and the component's only JavaScript is the door validating its accent property (this playground still parses and scores colors with culori, which is the honest split).
					</p>
					<p>
						The tonal dot is decorative and best-effort, not part of the guaranteed contract: its fixed <code>calc(l - 0.12)</code> offset can collapse into a very dark fill, which is why it is <code>aria-hidden</code> and flagged above rather than promised.
						A production badge that needed the dot always visible would constrain the accent's lightness range or derive the dot toward the foreground instead, which is the article's step-5 boundary.
					</p>
					<p>
						The design system still owns the policy. It decides which colors are allowed, whether this accent belongs on a badge, and what the brand foreground should be when the platform picks black.
						A custom runtime accent is progressive enhancement that activates only where <code>contrast-color()</code>, <code>color-mix()</code>, and relative color syntax are <strong>all</strong> present, so its whole family, the foreground, border, and dot, derives together or the engine keeps the coherent <strong>sRGB-default badge</strong>, never a custom fill wearing the default's blue border and dot.
						Gating the accent on the whole family that derives it is what keeps the fallback coherent on the way down, instead of shipping a custom fill with the default's foreground, border, and dot.
					</p>
				</div>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-color-token-contract-color-tokens-smart-badge': SmartBadgePlaygroundDemo;
	}
}
The panel shows the playground harness itself, the tier simulation and the input boundary included; the finished smart-badge component is the step 7 file in the article.

The live badge follows your real OS theme. The demo can’t change your OS light or dark preference, so the preview beside it sets its own used color scheme, and it simulates the newest functions being missing with the tier toggles, so the source panel holds the harness that does the simulating while the component itself stays exactly the step 7 file.

A few ways this goes wrong

  • Styling states the component doesn’t have. A status badge isn’t focusable or pressable, so hover, focus, and disabled styling just fakes an affordance and misleads. Derive the states the component’s role actually has, and if it needs interaction, build it on a real <button> instead.
  • Picking the foreground against the wrong surface. contrast-color() scores the color you hand it, so if the badge is filled with one color and the text is contrast-picked against another, the ratio is right for a surface the reader never sees. Fill and pick against the same value.
  • A translucent accent. The badge fills with the accent and scores the foreground against that fill, so a semi-transparent accent composites with whatever sits behind the badge and contrast-color() loses a reliable surface. Keep the accent opaque on both doors; the property door rejects a translucent value itself, and a <color> syntax check alone would wave one through to the token.
  • The custom-property fallback trap. A plain re-declaration of a token before its guarded light-dark() or contrast-color() version doesn’t preserve the floor, because the unsupported value parses fine as a custom property and only fails when the real property consumes it. Guard the upgrade with the exact @supports it needs, in the demo preview as much as in the component.
  • Reaching for calc(l + 20%). In relative color syntax the channels are unitless numbers, so a percentage on l is a parse-time type error and the declaration is dropped. Use calc(l - 0.12) on the 0 to 1 scale; a bare calc(l + 20) parses but pins the result to white.
  • Forgetting color-scheme. light-dark() returns its light branch in both OS modes until you declare color-scheme: light dark on the element where the value resolves, which for a light-dark() stored in a custom property is where the property is consumed.

What separates a senior from a junior here

  • They read the token as a contract across many states. A senior asks what the accent owes across foreground, border, a tonal variant, theme, and input trust, then derives each promise from the one source instead of hand-picking tokens that drift apart.
  • They decide the component’s role before its states. A status badge gets no interaction styling, because deriving a :hover you can does not mean the component should have one. The states you ship follow the role, not the reach of the CSS.
  • They treat the platform’s contrast pick as information to act on. When contrast-color() returns black, a senior changes the contract so the higher-contrast pick is the one the brand wants, and still trusts a human eye on the mid-tones the math waves through.
  • They keep the fallback honest about what it gives up. The Tier 0 floor keeps the whole default badge looking right, and a senior gates a custom runtime accent on the whole family it needs, so an old engine shows the validated default rather than a half-derived custom fill, and they pick a real strategy instead of pretending the fallback is lossless.

When CSS should not decide it

The hook for this article was “CSS deletes your color JavaScript,” and the honest version is narrower. These functions delete the token-derivation plumbing, the lighten, darken, mix, and contrast-pick helpers, and the component ships none of it. Parsing, format conversion, gamut mapping, and perceptual scoring like APCA still live in JavaScript, which is why the demos lean on culori for the parsing and contrast reports while the component keeps every derivation in the stylesheet, its only JavaScript the door validating an accent. Those reports are a WCAG 2 comparison, useful for explaining a result, and where contrast-color() runs the browser’s own pick is what actually ships.

Three decisions stay with the design system. The first is arbitrary user color versus curated tokens, since a mix derives a contract from any color but whether one is allowed on a badge is a brand policy. The second is the brand foreground, the curated token or contract change that contrast-color() can’t reach when the platform picks black and the brand wants white. The third is the analysis work above, where a palette tool like culori earns its place for the parsing, gamut mapping, and scoring the demos lean on.

Build your own

You’ve poured colors into the finished badge above, so now make the contract yours. Drop your own brand accent into that last playground and watch the derivations fall out of the one value, then find the moment the platform’s pick and your brand’s wish diverge, because that gap is the decision the stylesheet hands back to you. The code can derive the family, and you still own the policy.

Want more of these in your Google results?