Appearance
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.tsavoids the licence constraints of theapca-w3npm 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 ≈ +106direction: "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:
- Clamp magnitude to direction-specific cap (106 for down, 108 for up)
- Apply sign by direction
- 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:
| WCAG | APCA Lc | Notes |
|---|---|---|
| 1.0 | 0 | identical |
| 1.5 | 24 | subtle surface separation (tone ~84 vs 100) |
| 3.0 | 57 | AA large / UI components (tone ~61 vs 100) |
| 4.5 | 72 | AA body (tone ~50 vs 100) |
| 7.0 | 85 | AAA body (tone ~37 vs 100) |
| 14 | 101 | near-max body |
| 21 | 106 | max — 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. Thresholds7,4.5,3are from the WCAG 2.2 spec;14+and1.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".allmeans 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).