Skip to content

Contrast Model

Systema supports two contrast models, selectable per-theme via theme.color.settings.contrastModel:

  • WCAG 2.2 (default) — symmetric luminance ratio 1:1 … 21:1. Uses MCU's Contrast.ratioOfTones() for measurement; the tone-from-ratio search bisects on ratioOfTones() for an exact match rather than MCU's lighterUnsafe()/darkerUnsafe(), which over-shoot and would break WCAG↔APCA round-trip stability.
  • APCA (0.98G-4g, per readtech.org/ARC/) — perceptually-uniform Lc scale, signed ±108. In-house implementation in src/lib/hct-engine.ts avoids the licence constraints of the apca-w3 npm package (AGPL + Silver Bullet dual) that would be awkward for a Figma Community plugin.

Polarity asymmetry (APCA only)

APCA's signed output is direction-dependent:

  • direction: "down" (generated tone darker than background) → positive Lc, max ≈ +106
  • direction: "up" (generated tone lighter than background) → negative Lc, max ≈ −108

User input is always unsigned magnitude. Sign is derived from the effective direction at resolution time:

  1. Clamp magnitude to direction-specific cap (106 for down, 108 for up)
  2. Apply sign by direction
  3. Binary-search grayscale tone on the direction-appropriate side of the background ([0, bgTone] for down, [bgTone, 100] for up) — stays on one side so polarity never flips inside a single inverse

This mirrors Adobe Leonardo's convention — users store semantic magnitudes, never signed values.

Bridge (model switch conversion)

Switching contrastModel runs every stored contrast number through src/lib/contrast-bridge.ts. The bridge is not a static lookup table. It is a background-aware, tone-equivalent conversion: for each stored value it takes the actual background tone and search direction the value renders against, finds the foreground tone that satisfies the value in the source model, then re-measures the contrast of that exact tone-pair under the target model.

text
convertContrastValue(value, from, to, { bgTone, direction }):
  tone = toneForContrastRatio(bgTone, value, direction, from)   // resolve in source model
  return contrastRatio(tone, bgTone, to)                        // re-measure in target model

The principle: the rendered tone stays identical across a model switch; only the stored number changes. Both primitives (the tone search and the APCA Lc forward calc) already live in hct-engine.ts — the bridge just composes them.

The result is stored at full float precision (not pre-rounded); display layers round on render via formatContrastValue. This avoids drift: 2-decimal storage would wedge stored vs measured values apart by ~0.5% per WCAG↔APCA cycle, eventually pushing tokens past the low-contrast floor.

Why conversion is background-aware: a single static (wcag → apca) lookup table, calibrated once against a white background in the down direction, would be lossy by construction:

  1. APCA is direction-asymmetric — text-on-light at WCAG 4.5:1 measures ~Lc 72, but text-on-black at WCAG 4.5:1 measures ~Lc 60. One number-to-number table can't represent both.
  2. APCA also depends on absolute background luminance, not just the ratio — the same WCAG ratio converts to a different Lc against a mid-tone background than against white.

Resolving against the actual background tone handles both, so dark-mode tokens (up direction) and tokens on non-white surfaces convert correctly.

convertContrastStep (stepped-pattern multiplier) and convertContrastValueArray (whole arrays, with either one shared context or one context per index for chained patterns) reuse the same primitive. A step is semantically a contrast ratio between adjacent levels, so it converts via the first jump's background.

Tests

The bridge tests assert tone preservation: a value resolved to a tone in the source model, converted, and re-resolved in the target model lands on the same tone (within 0.5) — across both directions and several background tones. They also assert that up vs down at the same WCAG ratio produce different APCA Lc (direction matters). APCA math in hct-engine.ts has polarity tests (positive for down, negative for up), above-cap clamp tests, and zero-magnitude identity tests. See src/lib/contrast-bridge.test.ts and src/lib/hct-engine.test.ts.

Cheatsheet panel (Containers / Content editors)

Below the Contrasts field in frame and top-layer entity editors, a dedicated Cheatsheet Field row renders a model-aware reference table. Gated by layout.showContrastCheatsheet (plugin Settings → Editor Display → "Contrast cheatsheet", default on).

  • WCAG rows: 14+ near-max / headings, 7+ AAA body (AA large + UI), 4.5+ AA body (AAA large), 3+ AA large text / UI components, 1.04+ subtle surface separation. Thresholds 7, 4.5, 3 are from the WCAG 2.2 spec; 14+ and 1.04+ are design heuristics for surface-ladder separation that WCAG does not define.
  • APCA rows — segmented tab for Bronze / Silver / Gold (hovering a tab previews that tier without committing):
    • Bronze (6 rows, single Lc threshold per use-case): Lc 90 body comfortable / max, Lc 75 body text (recommended min), Lc 60 body bold / medium text, Lc 45 large text / UI components, Lc 30 large bold / decorative, Lc 15 inessential / subtle.
    • Silver (5 rows, per font-size × weight matrix): Lc 75 body 14–18px regular, Lc 60 body 16px bold / 500+, Lc 60 large 18–24px regular, Lc 45 large 24px+ bold, Lc 45 display 48px+.
    • Gold (5 rows, strictest tier): Lc 90 body 14–18px regular, Lc 75 body 16px bold / 500+, Lc 75 large 18–24px regular, Lc 60 large 24px+ bold, Lc 45 display 48px+.
  • Each row shows a compact c-N+ pill indicating the lowest chip index whose value satisfies the row's threshold. Because chips are sorted ascending, a single index encodes "this chip and above". all means every chip satisfies. No pill means nothing does.
  • Hover/focus on a contrast chip highlights the cheatsheet rows whose threshold the chip satisfies — live feedback for "which accessibility tiers does this chip cover". The highlight follows the in-flight edited value (typing, arrow-keys), not just the committed value. Pointer-leave is focus-aware: if a chip input still has focus when the 80ms clear timer fires, the highlight snaps back to the focused chip's value instead of going blank. Same focus-aware pattern applied to the color-checkbox grid (entity color picker → preview override) and the Mode suggestions panel (phantom-mode preview).