> ## Documentation Index
> Fetch the complete documentation index at: https://substrate.docs.unknowncreatives.studio/llms.txt
> Use this file to discover all available pages before exploring further.

# High Contrast: Scaling the Lc Policy with contrastFactor

> High contrast in Substrate is a continuous multiplier over the APCA target policy, not a separate palette. Hue and chroma are preserved; only the solved lightness moves.

High contrast in Substrate is not an inversion, not a black-and-white fallback, and not a separate palette. It is one axis of the preference vector — `contrastFactor` — that **multiplies every APCA target** before the solver runs. The brand's hues and chromas are untouched; only the lightness the solver lands on changes. APCA attribution and use restrictions: see [About APCA](/reference/apca-solver#about-apca).

That distinction matters in practice: a high-contrast Substrate interface still looks like your product, because the only thing that moved is how far apart the foreground and background sit on the L axis.

***

## The policy and the multiplier

The engine's APCA policy is fixed and named. Three targets, all measured in APCA Lc against the context surface:

| Target     | Base Lc | What it governs      |
| ---------- | ------- | -------------------- |
| Foreground | **75**  | Text and icon colors |
| Border     | **50**  | Borders and dividers |
| Focus ring | **60**  | Focus indicators     |

`contrastFactor` scales all three together. It runs from **0.75 to 1.5**, defaults to `1`, and is bounded per brand by the `flexibility.contrast` range in the brand config — a brand can narrow how far its users may push the axis.

The two high-contrast presets both set it to **1.3**:

```ts theme={null}
highContrast:     { scheme: 0, contrastFactor: 1.3 }
darkHighContrast: { scheme: 1, contrastFactor: 1.3 }
```

At 1.3×, the foreground target moves from Lc 75 to roughly **Lc 97.5**, and borders from Lc 50 to Lc 65. These are computed targets, not a fixed floor — there is no "Lc 90 mode" in the engine. Because contrast and scheme are independent axes, high contrast composes with any scheme position, including mid-track ones the presets don't name.

<Note>
  Contrast is a root-level preference scalar, not a per-subtree attribute. You do not mark a container as high-contrast. What *is* scoped is the surface a solve runs against: a nested surface re-solves its foregrounds against its own background, at whatever `contrastFactor` is currently set.
</Note>

***

## Setting it

Like every axis, you set the number and re-sync:

```ts theme={null}
import {
  syncPrefsToCssVars, updateAllVars, defaultPreferences, BRAND_REGISTRY,
} from '@substrate/engine';

const brand = BRAND_REGISTRY[0];
const prefs = defaultPreferences();

prefs.contrastFactor = 1.3;  // the highContrast preset value
syncPrefsToCssVars(prefs);
updateAllVars(brand, prefs);
```

Intermediate values are legitimate. `1.15` is a real, solvable preference — presets are just the labelled stops.

***

## Following the operating system

The runtime ships a helper that maps the OS contrast preference onto the axis and keeps listening for changes. It handles **both** directions:

```ts theme={null}
import { applyContrastPreference } from '@substrate/engine';

const stop = applyContrastPreference(prefs, () => {
  syncPrefsToCssVars(prefs);
  updateAllVars(brand, prefs);
});

// later, to detach the media-query listeners
stop();
```

* `prefers-contrast: more` → `contrastFactor` becomes **1.3**
* `prefers-contrast: less` → `contrastFactor` becomes **0.85**

The `less` direction is easy to overlook. Users who find high contrast uncomfortable — a common need for some forms of visual sensitivity and dyslexia — are served by the same axis running the other way, which is precisely why contrast is continuous rather than a boolean.

A related but separate signal is `forced-colors: active` (Windows High Contrast). There the OS supplies the palette outright, so the runtime disables CVD compensation and lets the system colors through rather than competing with them.

***

## What the no-JS floor covers

Two of the four baked snapshots are the high-contrast ones, gated on `prefers-contrast: more`:

```css theme={null}
@media (prefers-contrast: more) {
  [data-brand="acme"] { /* highContrast: scheme 0, cf 1.3 */ }
}

@media (prefers-color-scheme: dark) and (prefers-contrast: more) {
  [data-brand="acme"] { /* darkHighContrast: scheme 1, cf 1.3 */ }
}
```

That covers a server-rendered first paint at the page surface. Any other `contrastFactor` — including `prefers-contrast: less` at 0.85, and any user-chosen intermediate value — needs the runtime, as do nested surfaces. The snapshot is the accessible floor, not the whole model.

***

## When a target can't be met

The solver does not silently pass. It binary-searches the OKLCH lightness that hits the scaled target, and when the gamut runs out before the target is reached it records the shortfall as an `unmetLc` value on the solution rather than pretending it succeeded.

At generation time this is enforced: the pipeline runs an accessibility gate across the whole brand corpus and fails the build on shortfalls, with output naming the count and kind of failure. Pushing `contrastFactor` toward 1.5 on a brand with very high chroma is the usual way to hit this — the fix belongs in the brand config, either by widening `flexibility.contrast` bounds deliberately or by adjusting the intent's chroma. See the [APCA solver reference](/reference/apca-solver).

***

<CardGroup cols={2}>
  <Card title="Light & Dark" icon="circle-half-stroke" href="/modes/light-dark">
    The scheme axis that composes with contrast, and what the runtime writes.
  </Card>

  <Card title="Color Vision" icon="eye" href="/modes/color-vision">
    CVD compensation, which runs before the contrast solve rather than after it.
  </Card>
</CardGroup>
