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() / Contrast.lighterUnsafe() / Contrast.darkerUnsafe().
  • 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 PCA (model switch conversion)

Switching contrastModel runs every stored contrast number through src/lib/contrast-bridge.ts. The bridge is a measurement-based anchor table — for each WCAG ratio we compute the APCA Lc that the engine actually reads at the tones that achieve that ratio against a white background, so conversion preserves the visible token tones across model switches:

WCAGAPCA LcNotes
1.00identical
1.524subtle surface separation (tone ~84 vs 100)
3.057AA large / UI components (tone ~61 vs 100)
4.572AA body (tone ~50 vs 100)
7.085AAA body (tone ~37 vs 100)
14101near-max body
21106max — tone 0 vs 100

Between anchors — piecewise-linear interpolation. Anchors are derived by the scripts/calibrate-anchors.mjs script which feeds Contrast.darkerUnsafe(100, wcag) into the local APCA forward formula and rounds to the nearest integer Lc.

Why measurement instead of use-case anchors: an earlier table mapped use-case names directly (WCAG 4.5 ↔ APCA Lc 75 — both labelled "AA body" in their respective specs). That's intuitive for tier names but pushes the conversion ~7 Lc above the perceptually-equivalent value, so switching WCAG → APCA visibly darkened the tones (the engine targeted a more aggressive Lc than the source theme had). The measurement-based table preserves perceived contrast: a token rendered at WCAG 4.5 against white reads as Lc ~72 in APCA, and that's what the conversion now stores.

Direction caveat: APCA is asymmetric — text-on-white (down) and text-on-black (up) at the same WCAG ratio measure different Lc values. The anchors above use the down-direction (text on light surface) measurements, which is the dominant UI case. Dark-mode tokens (up direction) read slightly lower in Lc after conversion — that's APCA-correct, not a bug.

contrastStep (stepped-pattern multiplier) delegates to the same table — it's semantically a contrast ratio between adjacent levels, not a separate "multiplier vs additive" concept.

Tests

Round-trip (WCAG → APCA → WCAG) is stable within 0.05 rounding. APCA math 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 → 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 Simple / Silver:
    • Bronze Simple (6 rows, Lc 15 / 30 / 45 / 60 / 75 / 90) — use-case-oriented thresholds from the APCA readability criterion.
    • Silver (5 rows, per font-size × weight matrix) — e.g. Body 14-18px regular: Lc 75+, Body 16px bold / 500+: Lc 60+, Display 48px+: Lc 45+.
  • 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).

All rights reserved.