Track four states, not one color string
Diagnosis gets difficult when one color string has to represent source intent, transformation policy, build output, and browser behavior. Keep those facts separate, even when two stages happen to use the same notation.
- Authored token: the approved semantic role, mode, source version, and original value. This records design-system intent, not a browser pixel.
- Policy: the required target gamut and permitted action, such as preserve, constrain, add a fallback, map through a named implementation, or reject.
- Emitted CSS: the exact declaration delivered by the build, including declaration order and generated fallback values. It proves serialization, not display output.
- Consumer observation: the browser, operating system, display conditions, component, state, paired color, and recorded result. Only this state describes the tested consumer.
Keep downstream output downstream
A mapped sRGB value is a delivery artifact for a named policy. Don't silently promote it to the authoritative token or erase an intentional wide-gamut source.
Separate syntax fallback from gamut mapping
People often call three different operations a fallback. A syntax fallback serves a browser that cannot parse the later notation. Browser output-gamut mapping handles a parsed color that the current output cannot reproduce. Explicit build-time mapping creates a controlled target-gamut value before delivery. Evidence for one does not prove another.
.badge {
/* Schematic syntax fallback. */
background-color: rgb(220 54 72);
/* Later declaration for browsers that parse OKLCH. */
background-color: oklch(0.62 0.24 25);
}| Syntax fallback | Browser output mapping | Explicit build-time mapping | |
|---|---|---|---|
| Trigger | The parser does not support the later notation. | The parsed color cannot be represented by the active output gamut. | Project policy requires a precomputed target-gamut value. |
| Typical owner | Application CSS delivery. | Browser and rendering environment. | Token build or transformation pipeline. |
| Evidence produced | Which declaration survives parsing. | An observation from a recorded browser and display environment. | A reproducible artifact from a pinned implementation, method, and target. |
| What it cannot prove | That the OKLCH value fits sRGB or matches the fallback. | Equivalent output across browsers or displays. | That the resulting semantic pair works in every consumer. |
When an older browser uses the first declaration, you've tested fallback selection. When a supporting browser accepts the second, you've tested parsing. Neither observation identifies a build-time mapping method, and a build-time result cannot predict identical pixels across displays.
Choose the target before checking the token
OKLCH can describe colors beyond sRGB and Display-P3. So the useful question isn't whether a value is valid in the abstract. Ask whether it belongs to the gamut required by the delivery policy for a named consumer.
Choose sRGB when the contract requires one standard-gamut result or an explicit fallback that fits sRGB. Preserve an intentional Display-P3 value when wider-gamut output is part of the design and narrower environments have their own delivery rule. Any color outside the required target needs an explicit disposition.
| Situation | Policy | Acceptance evidence | |
|---|---|---|---|
| Inside the required gamut | The authored value fits the named target. | Preserve it unless another requirement justifies transformation. | Record the gamut check, emitted declaration, and representative consumer observation. |
| Intentional wide-gamut source | The source deliberately exceeds a narrower delivery gamut. | Keep the source and define the narrower delivery artifact separately. | Record expectations and observations for each required output path. |
| sRGB required everywhere | Every supported consumer must receive an sRGB-representable value. | Constrain authoring or generate an explicit sRGB artifact with a pinned method. | Confirm target membership and verify every affected semantic pair. |
| Lossy mapping permitted | Policy allows transformation into the named target. | Map with a named implementation, version, method, and target while retaining the source. | Record mapped coordinates, converted coordinates, emitted CSS, and consumer observations. |
| Protected relationship fails | The delivered pair no longer meets a required semantic or contrast condition. | Revise the source, transformation, delivery, or component, or block the change. | Require a passing retest or a bounded approved exception with an owner. |
A picker is an inspection surface
A picker can show gamut boundaries and suggest a fallback for one color. It doesn't know the color's semantic role, its protected pair, the build revision, or which declaration reached the component.
Detect before you map
Color.js documents a controlled fixture: Display-P3 lime, color(display-p3 0 1 0), is outside sRGB. Check membership before applying a lossy operation. Pin the Color.js version in your project record because the implementation and its defaults define part of the evidence boundary.
import Color from "colorjs.io";
const source = new Color("p3", [0, 1, 0]);
const observedMembership = {
p3: source.inGamut(),
srgb: source.inGamut("srgb")
};
console.table(observedMembership);Don't use conversion as a substitute for detection. Color.js documents conversion, default string serialization, and coordinate mapping as different operations. A converted object can retain out-of-gamut coordinates, while default string output may apply gamut mapping. Record only the printed string, and you may hide which operation changed the color.
import Color from "colorjs.io";
const source = new Color("p3", [0, 1, 0]);
const converted = source.to("srgb");
const evidence = {
source: source.toString({ inGamut: false }),
convertedCoordinates: converted.coords,
defaultSerialization: converted.toString(),
unmappedSerialization: converted.toString({ inGamut: false })
};
console.table(evidence);Map, convert, and serialize as separate steps
When policy permits explicit mapping, preserve the authored object and name both the target and method. Color.js documents toGamut() as a coordinate-mutating operation. Its documented default method is css, but naming the method keeps the evidence record from depending on an invisible default.
import Color from "colorjs.io";
const authored = new Color("p3", [0, 1, 0]);
const mapped = authored.clone();
// Operation 1: map the clone into the sRGB gamut.
mapped.toGamut({
space: "srgb",
method: "css"
});
// Operation 2: convert the mapped color into sRGB coordinates.
const mappedSRGB = mapped.to("srgb");
// Operation 3: serialize those coordinates without another implicit map.
const emittedValue = mappedSRGB.toString({ inGamut: false });
const emittedDeclaration = `--fixture-lime: ${emittedValue};`;
console.table({
authored: authored.toString({ inGamut: false }),
target: "srgb",
method: "css",
mappedCoordinatesInOriginalSpace: mapped.coords,
srgbCoordinates: mappedSRGB.coords,
emittedValue,
emittedDeclaration
});The emitted declaration now makes its intended space and operation sequence clear. Even so, it is evidence only for the pinned Color.js version and inputs. It does not establish browser pixels, display equivalence, contrast, or semantic suitability.
A second fixture covers a different case. The value oklch(0.7 0.4 40) appears on oklch.fyi as a high-chroma example outside real display gamuts. Treat that as the documented expectation. Record your project's membership checks, mapping output, browser render, and contrast result only after running them.
import Color from "colorjs.io";
const source = new Color("oklch(0.7 0.4 40)");
const documentedExpectation =
"High-chroma fixture described as outside real display gamuts";
const observedMembership = {
srgb: source.inGamut("srgb"),
p3: source.inGamut("p3")
};
console.table({ documentedExpectation, ...observedMembership });Start with semantic roles, then test delivery
Gamut decisions are easier to review when each color already has a role, mode, and paired foreground or background. Inspect the semantic model before applying this workflow to one high-chroma pair.
Do not substitute clipping for mapping without review
Clipping forces out-of-range coordinates into the target range. The captured OKLab gamut explorer calls it fast but capable of severe hue and lightness shifts, and shows clipping beside two mapping approaches. Clipping is therefore a distinct policy choice, not a harmless shortcut.
No method is universally correct for every role. Record the method, preserve the source, and inspect the transformed result in context. If a destructive action becomes difficult to distinguish from a neutral surface, the token has failed its semantic job even if its coordinates are inside sRGB.
In gamut is necessary, not sufficient
Target-gamut membership establishes representability under the tested implementation. It doesn't prove brand fidelity, adequate contrast, state distinction, accessibility conformance, or equivalent output across environments.
Verify semantic pairs after mapping
A semantic color rarely acts alone. When --primary changes, verify it with --primary-foreground. When --destructive changes, include only the components and states that consume that role. Valid CSS and matching OKLCH lightness values cannot approve the pair.
| Test case | Inspect | Failure condition | |
|---|---|---|---|
| Primary button, light mode, default | Light-mode default primary button. | Inspect --primary with --primary-foreground, label text such as "Save changes," emitted values, and the rendered pair. | Fail if the pair misses the project's applicable contrast requirement, the label becomes difficult to read, or the component consumes a different role. |
| Primary button, dark mode, default | Dark-mode default primary button. | Inspect the dark-mode values for the same roles, the active theme path, and the rendered label. | Fail if the light-mode artifact leaks into dark mode, the protected relationship fails, or the wrong theme branch is delivered. |
| Primary button, hover | Primary button hover state in each affected mode. | Inspect the hover role or transformation with the same label and its adjacent default state. | Fail if hover becomes indistinguishable from default when distinction is required, or its text pair fails the applicable check. |
| Primary button, focus | Keyboard focus on the primary button. | Inspect the focus ring against the button and surrounding surface using a named keyboard-focus fixture. | Fail if the ring is obscured, loses required distinction, or uses an unrelated local color after mapping. |
| Destructive action, default and hover | Destructive action in default and hover states. | Inspect --destructive with its foreground role, concrete copy such as "Delete workspace," and an adjacent neutral action. | Fail if the action loses its destructive meaning, cannot be distinguished from the neutral action, or either foreground pair fails its requirement. |
| Disabled state, when it consumes the changed role | Disabled state only where it consumes the changed role. | Inspect disabled text, fill, border, and opacity composition with concrete disabled content. | Fail if the state remains actionable-looking, content becomes unreadable under the project's rule, or opacity changes the pair beyond the recorded expectation. |
| Protected unrelated surface | A named surface outside the approved change scope. | Inspect a protected consumer such as a secondary button or status badge before and after the change. | Fail if its computed or rendered color changes even though it was outside the approved scope. |
- 1
Name the role, pair, and consumer
Record the semantic role, mode, paired foreground or background, source version, component, and state. Include only states that use the changed role.
- 2
Write a falsifiable expectation
State what must remain true after mapping, including target-gamut membership, the applicable contrast rule, retained state meaning, and protected consumers that must not change.
- 3
Test light and dark independently
Don't infer one mode from the other, even when they share a semantic name. Record the delivered values and selected theme path for each.
- 4
Use concrete content
Inspect realistic labels, icons, and adjacent actions. A blank swatch cannot reveal a weak text pair or a lost distinction between destructive and neutral actions.
- 5
Measure and observe
Run the project's applicable contrast check on the delivered pair, then inspect the state in context. Record the method, environment, result, and any ambiguity without claiming broader accessibility conformance.
- 6
Choose a disposition
Accept only when the named criteria pass. Revise a correctable source, transformation, delivery, or component problem. Block a failed protected relationship unless an authorized, bounded exception exists.
Copy the source-to-render record
Keep this record with the token change or release evidence. Leave observation fields empty until someone runs the checks. That small discipline keeps an expected result from being remembered as an observed one.
record_id: color-primary-light-srgb-001
source:
system: "<design system or kit>"
version: "<immutable version or revision>"
token_role: "--primary"
mode: "light"
authored_value: "<original color value>"
policy:
target_gamut: "srgb"
action: "preserve | constrain | fallback | map | reject"
implementation: "Color.js"
implementation_version: "<pinned version>"
method: "css"
syntax_fallback: "<corresponding approved value or none>"
artifact:
mapped_coordinates: "<recorded coordinates and space>"
converted_coordinates: "<recorded target-space coordinates>"
emitted_declaration: "<exact generated CSS>"
build_revision: "<revision>"
verification:
environment:
browser: "<name and version>"
operating_system: "<name and version>"
display_profile: "<known profile or unknown>"
named_consumers:
- "<component, page, mode, and state>"
test_content:
- "<exact visible label or content>"
protected_pairs:
- "--primary / --primary-foreground"
protected_surfaces:
- "<surface that must not change>"
inspection_criteria:
- "<target membership, contrast rule, and semantic distinction>"
failure_conditions:
- "<condition that causes revise or block>"
expected_result: "<falsifiable expectation>"
observed_result: "<complete only after execution>"
contrast_observation: "<method, result, and scope>"
exception:
status: "none | proposed | approved"
scope: "<bounded scope>"
owner: "<decision owner>"
expires_or_retests: "<trigger>"
correction_owner: "<token | transform | delivery | environment | component>"
disposition: "accept | revise | block"Replace "looks right" with a bounded claim
For example: the emitted --primary value is inside sRGB under the pinned implementation, the primary button passes the project's named pair checks in both modes, and the protected secondary button remains unchanged.
Route failures to the layer that owns them
Don't respond to every mismatch by lowering chroma in component CSS. Start with the first layer where the recorded expectation and the evidence diverge.
| Failure layer | Typical evidence | Correction route | |
|---|---|---|---|
| Token authoring | The approved value falls outside a mandatory gamut or cannot retain a protected semantic relationship. | Revise the authoritative role or approve an intentional wide-gamut policy. | Retest every regenerated artifact and named consumer. |
| Transformation | The wrong target, implementation, version, method, or source input produced the artifact. | Correct and pin the transformation. | Regenerate the output and repeat membership and pair checks. |
| CSS delivery | Declaration order, stale output, cascade, or packaging sends the wrong value to the consumer. | Fix generation or delivery without rewriting the source decision. | Inspect the built declaration and computed consumer value again. |
| Browser or display environment | The same artifact produces an environment-specific observation, or the display condition is unknown. | Record and reproduce the environment within the supported scope. | Decide whether a narrower delivery policy is required. |
| Component implementation | A local override, selector, opacity, blend, or incorrect role changes the final pair. | Correct the component mapping or document a bounded exception. | Retest the affected state and protected surfaces. |
Each layer proves only its own result. The token source proves intent. A Color.js run proves behavior for the pinned library and inputs, while generated CSS proves serialization. A browser observation covers one recorded environment. A component and contrast check covers the tested pair, content, and state. None certifies the entire product alone.
Where Identity Forge stops
Identity Forge can supply upstream semantic light and dark color tokens, along with CSS, Tailwind, shadcn, DTCG, and DESIGN.md artifacts. Those exports provide named roles and implementation material. They don't control browser gamut mapping, guarantee display output, or certify contrast and accessibility in a consuming application.
The consuming project owns the required target gamut, fallback and mapping policy, transformation implementation, CSS delivery, browser and display checks, protected semantic pairs, exceptions, and final approval. Keep that boundary visible in the record so nobody mistakes a generated artifact for release evidence.
Run one bounded acceptance test
Choose one high-chroma semantic foreground-background pair. Record its source version, target gamut, and fallback or mapping policy. Inspect the emitted CSS, then verify both modes in one named consumer with concrete content. Add only the interaction states that consume the changed role. If a criterion fails, route the mismatch to its owning layer before changing another token.
Sources
- oklch() CSS function - CSS | MDN: MDN documents the browser-facing
oklch()notation, its values, formal syntax, and CSS examples. - Gamut mapping - Color.js: Color.js documents target-gamut checks, conversion, mapped serialization, coordinate mutation, selectable mapping methods, and its CSS-oriented default method.
- OKLCH Color Picker & Converter: The picker exposes separate sRGB and P3 representations and describes its displayed sRGB alternative as the closest fallback by chroma.
- oklch.fyi › Pick, convert and generate OKLCH colors: The guide distinguishes color models from gamuts, discusses maximum chroma and browser fallbacks, and provides a high-chroma OKLCH case for inspection.
- Explore OKLab gamut mapping in oklch: The visualization compares OKLCH gamut mapping with simple coordinate clipping and reports severe hue and lightness shifts for clipping.
- Falling For Oklch: A Love Story Of Color Spaces, Gamuts, And CSS: The article demonstrates progressive enhancement by placing a conventional color declaration before an
oklch()declaration. - OKLCH in CSS: why we moved from RGB and HSL: The article explains OKLCH adoption for design systems and palettes while identifying out-of-gamut colors as a practical implementation risk.
- Semantic color tokens explained: Identity Forge documents semantic color roles, paired foreground conventions, and light and dark token values.
- Identity Forge: Identity Forge publicly describes design kits with semantic tokens and CSS, Tailwind, shadcn, DTCG, and DESIGN.md delivery paths.