|
|
import { apcaLc, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color'
|
|
|
import { PALETTE_SCALES } from './types'
|
|
|
import type { EidosConfig } from './config-types'
|
|
|
|
|
|
/**
|
|
|
* On-solid legibility criterion — THE single source for the "which ink goes
|
|
|
* on a solid fill" decision (rfc-color-engine §6.1 named this `pickOnSolid`;
|
|
|
* materialized 2026-07-06, user decision A.7).
|
|
|
*
|
|
|
* Canonical semantics (ratified 2026-07-06): **light-ink-first, unless it
|
|
|
* fails BOTH floors** — the theme's `onSolid` (white in base) stays on every
|
|
|
* solid unless it is illegible there; only then the slot flips to
|
|
|
* `onSolidContrast` (dark ink). This is deliberately NOT the RFC's earlier
|
|
|
* "pick the larger |Lc|" draft: pick-larger would flip half the palette
|
|
|
* (teal 60.5 · grass 60.2 · jade/green/bronze 61.7 · blue 62.6 · the mid
|
|
|
* grays 63–64) to dark ink — a completely different look, against the field
|
|
|
* (Radix keeps white on its mid 9s). White-first keeps the Radix look with
|
|
|
* the M3-class computed guarantee.
|
|
|
*
|
|
|
* Floors: APCA |Lc| ≥ 60 (perceptual, WCAG-3 draft) AND WCAG 2 ≥ 3:1 (the
|
|
|
* normative AA minimum for UI/large text) — the light ink must clear BOTH.
|
|
|
*/
|
|
|
export const ON_SOLID_APCA_FLOOR = 60
|
|
|
export const ON_SOLID_WCAG_FLOOR = 3
|
|
|
|
|
|
/**
|
|
|
* Does the light ink clear BOTH legibility floors over this solid?
|
|
|
* Returns `null` when either color can't be parsed (a `var()` reference, an
|
|
|
* empty slot…) — the caller decides the fallback.
|
|
|
*/
|
|
|
export function onSolidClearsFloors(onSolidCss: string, solidCss: string): boolean | null {
|
|
|
const solid = safeParseColor(solidCss)
|
|
|
const ink = safeParseColor(onSolidCss)
|
|
|
if (!solid || !ink) return null
|
|
|
const solidRgb = oklchToGammaRgb(solid)
|
|
|
const inkRgb = oklchToGammaRgb(ink)
|
|
|
return (
|
|
|
Math.abs(apcaLc(inkRgb, solidRgb)) >= ON_SOLID_APCA_FLOOR &&
|
|
|
wcagContrastRatio(inkRgb, solidRgb) >= ON_SOLID_WCAG_FLOOR
|
|
|
)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Parse-fallback for scales whose step-9 can't be evaluated (non-hex values,
|
|
|
* exotic configs): Radix's documented bright-9 set + this palette's light
|
|
|
* gold. Everything evaluable is COMPUTED — this list never decides for a
|
|
|
* parseable color (the hand-curated `LIGHT_SOLID_SCALES` it replaces drifted
|
|
|
* exactly here: orange shipped white at 2.97:1 sub-AA while the risk ROLE —
|
|
|
* the same hex — computed and flipped).
|
|
|
*/
|
|
|
const KNOWN_BRIGHT_SOLID_SCALES: ReadonlySet<string> = new Set([
|
|
|
'amber',
|
|
|
'yellow',
|
|
|
'lime',
|
|
|
'mint',
|
|
|
'sky',
|
|
|
'gold'
|
|
|
])
|
|
|
|
|
|
/**
|
|
|
* Compute, from the config's own data, the set of palette scales whose
|
|
|
* step-9 solid rejects the light ink (→ the per-instance `palette-contrast`
|
|
|
* cascade flips them to `onSolidContrast`).
|
|
|
*
|
|
|
* The cascade lives in the STATIC foundation (one answer for every theme),
|
|
|
* so the polarity is computed per configured theme and resolved:
|
|
|
* - unanimous across themes → that answer (today: the 33 scales agree);
|
|
|
* - themes disagree on a scale → the `*-light` theme's answer wins (the
|
|
|
* documented rule; a genuinely diverging theme is the trigger for
|
|
|
* per-theme cascade emission — rfc-color-engine §8 note).
|
|
|
* - unparseable step-9 → `KNOWN_BRIGHT_SOLID_SCALES` membership.
|
|
|
*/
|
|
|
export function computeLightSolidScales(options: EidosConfig): ReadonlySet<string> {
|
|
|
const themes = Object.entries(options.themes ?? {})
|
|
|
const result = new Set<string>()
|
|
|
for (const scaleName of PALETTE_SCALES) {
|
|
|
const votes: { light: boolean | null; fails: boolean }[] = []
|
|
|
for (const [themeId, theme] of themes) {
|
|
|
const scales = { ...options.primitives.color?.scales, ...theme.color?.scales }
|
|
|
const solid = scales[scaleName]?.['9']
|
|
|
const onSolid =
|
|
|
theme.color?.content?.onSolid ?? options.semantics.color.content?.onSolid
|
|
|
if (!solid || !onSolid) continue
|
|
|
const clears = onSolidClearsFloors(onSolid, solid)
|
|
|
if (clears === null) continue
|
|
|
votes.push({ light: themeId.includes('light'), fails: !clears })
|
|
|
}
|
|
|
if (votes.length === 0) {
|
|
|
if (KNOWN_BRIGHT_SOLID_SCALES.has(scaleName)) result.add(scaleName)
|
|
|
continue
|
|
|
}
|
|
|
const unanimous = votes.every((v) => v.fails === votes[0].fails)
|
|
|
if (unanimous) {
|
|
|
if (votes[0].fails) result.add(scaleName)
|
|
|
continue
|
|
|
}
|
|
|
const lightVote = votes.find((v) => v.light) ?? votes[0]
|
|
|
if (lightVote.fails) result.add(scaleName)
|
|
|
}
|
|
|
return result
|
|
|
}
|