|
|
/**
|
|
|
* Gradient-finish guard (§39) — the dial's contract, executable.
|
|
|
*
|
|
|
* The ANCHORED ramp mixes both stops toward the ink's shadow side (white ink
|
|
|
* → #000, downward; dark ink → #fff, upward), so the label's contrast can
|
|
|
* only improve over the base solid pairing. This guard pins that invariant
|
|
|
* numerically — NO-REGRESSION: every role/scale × mode whose BASE solid
|
|
|
* clears the framework floors with its own ink must still clear them at any
|
|
|
* dial value up to 40% (probed at the ramp's worst sample).
|
|
|
*
|
|
|
* Colors whose base solid cannot clear the floors with EITHER ink (the
|
|
|
* mid-tone reality: cyan / orange) are the on-solid machinery's pre-existing
|
|
|
* scope, not the finish's — the guard asserts the finish does not GROW that
|
|
|
* set, and pins its membership so any change is a conscious decision.
|
|
|
*
|
|
|
* Floors mirror `lib/on-solid.ts`: APCA |Lc| >= 60 AND WCAG >= 3.
|
|
|
*/
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import { apcaLc, wcagContrastRatio } from '$color'
|
|
|
import { oklchToGammaRgb, parseColor } from '$color'
|
|
|
import type { Oklch } from '$color'
|
|
|
import {
|
|
|
THEME_BASE_COLOR_ROLES,
|
|
|
THEME_BASE_LIGHT_COLOR_SCALES,
|
|
|
THEME_BASE_DARK_COLOR_SCALES
|
|
|
} from './lib/themes/base'
|
|
|
import { completeColorRoleMap } from './lib/config-types'
|
|
|
import {
|
|
|
RADIX_EXTRA_LIGHT_SCALES,
|
|
|
RADIX_EXTRA_DARK_SCALES
|
|
|
} from './lib/themes/color-scales'
|
|
|
import { STATIC_GRADIENTS, STATIC_GRADIENT_FINISH } from './lib/primitives/static'
|
|
|
|
|
|
const WHITE = parseColor('#ffffff')
|
|
|
const DARK = parseColor('#1c1917')
|
|
|
const BLACK = parseColor('#000000')
|
|
|
const MAX_DIAL = 0.4
|
|
|
|
|
|
const floorsPass = (ink: Oklch, bg: Oklch): boolean => {
|
|
|
const lc = Math.abs(apcaLc(oklchToGammaRgb(ink), oklchToGammaRgb(bg)))
|
|
|
const wcag = wcagContrastRatio(oklchToGammaRgb(ink), oklchToGammaRgb(bg))
|
|
|
return lc >= 60 && wcag >= 3
|
|
|
}
|
|
|
// color-mix(in oklch, …) over the tuple [L, c, h].
|
|
|
const lerp = (a: Oklch, b: Oklch, t: number): Oklch => [
|
|
|
a[0] * (1 - t) + b[0] * t,
|
|
|
a[1] * (1 - t) + b[1] * t,
|
|
|
a[2] * (1 - t) + b[2] * t
|
|
|
]
|
|
|
|
|
|
type Combo = { name: string; mode: 'light' | 'dark'; s9: Oklch; s10: Oklch }
|
|
|
|
|
|
function collectCombos(): Combo[] {
|
|
|
const combos: Combo[] = []
|
|
|
const roleMap = completeColorRoleMap(THEME_BASE_COLOR_ROLES)
|
|
|
const EXTRA = { light: RADIX_EXTRA_LIGHT_SCALES, dark: RADIX_EXTRA_DARK_SCALES } as const
|
|
|
for (const [mode, scales] of [
|
|
|
['light', THEME_BASE_LIGHT_COLOR_SCALES],
|
|
|
['dark', THEME_BASE_DARK_COLOR_SCALES]
|
|
|
] as const) {
|
|
|
const all: Record<string, Record<string, string>> = {
|
|
|
...(scales as Record<string, Record<string, string>>),
|
|
|
...(EXTRA[mode] as unknown as Record<string, Record<string, string>>)
|
|
|
}
|
|
|
for (const [role, def] of Object.entries(roleMap)) {
|
|
|
const scaleName = typeof def === 'string' ? def : def.scale
|
|
|
const src = all[scaleName]
|
|
|
if (src) {
|
|
|
combos.push({
|
|
|
name: `role:${role}(${scaleName})`,
|
|
|
mode,
|
|
|
s9: parseColor(src['9']),
|
|
|
s10: parseColor(src['10'])
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
for (const [n, s] of Object.entries(all)) {
|
|
|
combos.push({ name: `scale:${n}`, mode, s9: parseColor(s['9']), s10: parseColor(s['10']) })
|
|
|
}
|
|
|
}
|
|
|
return combos
|
|
|
}
|
|
|
|
|
|
/** Worst-sample check of the anchored ramp at dial value `p`. */
|
|
|
function anchoredRampPasses(c: Combo, p: number): boolean {
|
|
|
const inkIsWhite = floorsPass(WHITE, c.s9)
|
|
|
const ink = inkIsWhite ? WHITE : DARK
|
|
|
const anchor = inkIsWhite ? BLACK : WHITE
|
|
|
const weak = lerp(c.s9, anchor, p / 3)
|
|
|
const strong = lerp(c.s10, anchor, p)
|
|
|
return [0, 0.5, 1].every((t) => floorsPass(ink, lerp(weak, strong, t)))
|
|
|
}
|
|
|
|
|
|
describe('gradient finish — anchored-ramp guard (§39)', () => {
|
|
|
const combos = collectCombos()
|
|
|
const basePasses = (c: Combo) =>
|
|
|
floorsPass(WHITE, c.s9) || floorsPass(DARK, c.s9)
|
|
|
|
|
|
it('covers the full color axis (9 roles + 33 scales × light/dark)', () => {
|
|
|
expect(combos.length).toBeGreaterThanOrEqual(84)
|
|
|
})
|
|
|
|
|
|
it('NO-REGRESSION: every base-passing color keeps its inherited ink legible at any dial value ≤ 40%', () => {
|
|
|
const offenders = combos
|
|
|
.filter(basePasses)
|
|
|
.filter((c) => !anchoredRampPasses(c, MAX_DIAL))
|
|
|
.map((c) => `${c.mode} ${c.name}`)
|
|
|
expect(offenders).toEqual([])
|
|
|
})
|
|
|
|
|
|
it("NO-REGRESSION ('spread'): the anchored hue rotation keeps the inherited ink legible up to ±45°", () => {
|
|
|
// The `'spread'` kind rotates hue at constant L/C — but OKLCH L is NOT
|
|
|
// relative luminance: pure constant-L rotation broke grass at ±4° and
|
|
|
// gold at ±27° (measured). The rescue is the doctrine itself: both
|
|
|
// rotated stops take the ramp's WEAK anchor mix (lift/3). This pins it.
|
|
|
const LIFT = 0.26
|
|
|
const rotate = ([l, c, h]: Oklch, deg: number): Oklch => [
|
|
|
l,
|
|
|
c,
|
|
|
((((h ?? 0) + deg) % 360) + 360) % 360
|
|
|
]
|
|
|
const offenders = combos
|
|
|
.filter(basePasses)
|
|
|
.filter((c) => {
|
|
|
const inkIsWhite = floorsPass(WHITE, c.s9)
|
|
|
const ink = inkIsWhite ? WHITE : DARK
|
|
|
const anchor = inkIsWhite ? BLACK : WHITE
|
|
|
const S = 45
|
|
|
return ![-S, -S / 2, 0, S / 2, S].every((d) =>
|
|
|
floorsPass(ink, lerp(rotate(c.s9, d), anchor, LIFT / 3))
|
|
|
)
|
|
|
})
|
|
|
.map((c) => `${c.mode} ${c.name}`)
|
|
|
expect(offenders).toEqual([])
|
|
|
})
|
|
|
|
|
|
it('named finishes (D11): every opted-in name exists in the open cage and carries its authored ink', () => {
|
|
|
// A named multi-role image cannot inherit an ink — the author declares
|
|
|
// it. This pins the config sanity: named ⊆ gradients, ink non-empty.
|
|
|
const names = Object.keys(STATIC_GRADIENT_FINISH.named ?? {})
|
|
|
expect(names.length).toBeGreaterThan(0)
|
|
|
for (const name of names) {
|
|
|
expect(Object.keys(STATIC_GRADIENTS)).toContain(name)
|
|
|
expect(
|
|
|
(STATIC_GRADIENT_FINISH.named as Record<string, { ink: string }>)[name].ink
|
|
|
).toBeTruthy()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
it('does not grow the pre-existing flat-fail set (mid-tone solids: cyan / orange)', () => {
|
|
|
const flatFails = [...new Set(combos.filter((c) => !basePasses(c)).map((c) => c.name))]
|
|
|
.sort()
|
|
|
// Pinned membership — growing this set is an on-solid regression, and
|
|
|
// must be a conscious decision, never drift.
|
|
|
expect(flatFails).toEqual(['role:risk(orange)', 'scale:cyan', 'scale:orange'])
|
|
|
})
|
|
|
})
|