You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/gradient-finish-guard.test.ts

158 lines
5.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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'])
})
})

Powered by TurnKey Linux.