diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index e9ed4242..034795c0 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -493,7 +493,7 @@ describe('bundled themes meet WCAG 2.2 AA', () => { ); expect(lightCanvas.c).toBe(0); - expect(lightRecessed).toEqual({ c: 0, h: 0, l: 1 }); + expect(lightRecessed).toEqual({ l: 1, c: 0, h: 0 }); expect(darkCanvas.l - darkRecessed.l).toBeGreaterThanOrEqual(0.02); }); diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 8883b7a9..ebee613a 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -209,7 +209,7 @@ const LIGHTNESS_WINDOWS: Record = { // Surface roles encode usage directly: light wells use neutral white, dark wells sit below the // canvas, and detached surfaces separate more strongly without exposing generated palette steps. -const LIGHT_RECESSED_SURFACE = { c: 0, h: 0, l: 1 } as const satisfies Oklch; +const LIGHT_RECESSED_SURFACE = { l: 1, c: 0, h: 0 } as const satisfies Oklch; const DARK_RECESSED_SURFACE_LIGHTNESS_DELTA = -0.025; const SURFACE_LIGHTNESS_DELTAS = { dark: { floating: 0.07, overlay: 0.09, resting: 0.04 }, @@ -317,9 +317,9 @@ function buildModeColors( window: windows.borderControl, }); colors['color.border.decorative'] = gamutMapOklch({ + l: clampUnit(canvas.l + (isLight ? -0.08 : 0.1)), c: neutralChroma, h: neutral.h, - l: clampUnit(canvas.l + (isLight ? -0.08 : 0.1)), }); colors['color.border.focus'] = source.focus; @@ -327,9 +327,9 @@ function buildModeColors( // surfaces/borders are gone from the contract; interactive controls fade with opacity instead. const disabledChroma = Math.min(neutral.c, 0.01); colors['color.text.disabled'] = gamutMapOklch({ + l: clampUnit(canvas.l + (isLight ? -0.32 : 0.33)), c: disabledChroma, h: neutral.h, - l: clampUnit(canvas.l + (isLight ? -0.32 : 0.33)), }); for (const intent of FULL_KIT_INTENTS) { @@ -353,9 +353,9 @@ function buildModeColors( } const neutralSolid = gamutMapOklch({ + l: isLight ? 0.32 : 0.85, c: neutralChroma, h: neutral.h, - l: isLight ? 0.32 : 0.85, }); const neutralSolidAt = (delta: number) => { return gamutMapOklch({ @@ -457,8 +457,8 @@ function buildSubtleTrio( } function chooseOnSolid(hue: number, solids: Array): Oklch { - const nearWhite = gamutMapOklch({ c: 0, h: hue, l: 0.985 }); - const nearBlack = gamutMapOklch({ c: 0.01, h: hue, l: 0.18 }); + const nearWhite = gamutMapOklch({ l: 0.985, c: 0, h: hue }); + const nearBlack = gamutMapOklch({ l: 0.18, c: 0.01, h: hue }); const whiteMinimum = minimumRatio(nearWhite, solids); const blackMinimum = minimumRatio(nearBlack, solids); if (whiteMinimum >= TEXT_RATIO + RATIO_HEADROOM) return nearWhite; @@ -483,7 +483,7 @@ interface LightnessSolveRequest { */ function solveLightness(request: LightnessSolveRequest): Oklch { const [low, high] = request.window; - const makeColor = (l: number) => gamutMapOklch({ c: request.chroma, h: request.hue, l }); + const makeColor = (l: number) => gamutMapOklch({ l, c: request.chroma, h: request.hue }); const target = request.ratio + RATIO_HEADROOM; const passes = (l: number) => minimumRatio(makeColor(l), request.backgrounds) >= target; const start = clamp(request.startLightness, low, high); diff --git a/packages/@luke-ui/react/src/theme/color.test.ts b/packages/@luke-ui/react/src/theme/color.test.ts index 2c2a3141..991230df 100644 --- a/packages/@luke-ui/react/src/theme/color.test.ts +++ b/packages/@luke-ui/react/src/theme/color.test.ts @@ -38,7 +38,7 @@ describe('contrastRatio', () => { describe('gamutMapOklch', () => { it('reduces chroma until the colour fits in sRGB while preserving lightness and hue', () => { - const outOfGamut = { c: 0.4, h: 150, l: 0.6 }; + const outOfGamut = { l: 0.6, c: 0.4, h: 150 }; const mapped = gamutMapOklch(outOfGamut); expect(mapped.l).toBe(0.6); expect(mapped.h).toBe(150); @@ -47,8 +47,8 @@ describe('gamutMapOklch', () => { }); it('leaves in-gamut colours unchanged and clamps extreme lightness', () => { - const inGamut = { c: 0.05, h: 30, l: 0.5 }; + const inGamut = { l: 0.5, c: 0.05, h: 30 }; expect(gamutMapOklch(inGamut)).toEqual(inGamut); - expect(gamutMapOklch({ c: 0.2, h: 30, l: 1.2 })).toEqual({ c: 0, h: 30, l: 1 }); + expect(gamutMapOklch({ l: 1.2, c: 0.2, h: 30 })).toEqual({ l: 1, c: 0, h: 30 }); }); }); diff --git a/packages/@luke-ui/react/src/theme/color.ts b/packages/@luke-ui/react/src/theme/color.ts index 93488cf6..2dfc1fd6 100644 --- a/packages/@luke-ui/react/src/theme/color.ts +++ b/packages/@luke-ui/react/src/theme/color.ts @@ -30,7 +30,7 @@ export function parseColor(input: string): Oklch { if (l < 0 || l > 1) { throw new Error(`cannot parse colour "${input}"; oklch lightness must be 0-1 or 0%-100%`); } - return { c, h: normalizeHue(h), l }; + return { l, c, h: normalizeHue(h) }; } throw new Error(`cannot parse colour "${input}"; expected #rgb, #rrggbb, or oklch( )`); } @@ -52,21 +52,21 @@ export function contrastRatio(a: Oklch, b: Oklch): number { */ export function gamutMapOklch(color: Oklch): Oklch { const h = normalizeHue(color.h); - if (color.l <= 0) return { c: 0, h, l: 0 }; - if (color.l >= 1) return { c: 0, h, l: 1 }; - const candidate = { c: Math.max(color.c, 0), h, l: color.l }; + if (color.l <= 0) return { l: 0, c: 0, h }; + if (color.l >= 1) return { l: 1, c: 0, h }; + const candidate = { l: color.l, c: Math.max(color.c, 0), h }; if (isInSrgbGamut(candidate)) return candidate; let inGamutChroma = 0; let outOfGamutChroma = candidate.c; for (let iteration = 0; iteration < 32; iteration++) { const mid = (inGamutChroma + outOfGamutChroma) / 2; - if (isInSrgbGamut({ c: mid, h, l: color.l })) { + if (isInSrgbGamut({ l: color.l, c: mid, h })) { inGamutChroma = mid; } else { outOfGamutChroma = mid; } } - return { c: inGamutChroma, h, l: color.l }; + return { l: color.l, c: inGamutChroma, h }; } /** Formats an OKLCH colour as a CSS `oklch()` value, with an optional alpha channel. */ @@ -157,5 +157,5 @@ function linearSrgbToOklch(rgb: SrgbTriple): Oklch { const labB = 0.0259040371 * lCubeRoot + 0.7827717662 * mCubeRoot - 0.808675766 * sCubeRoot; const c = Math.hypot(labA, labB); const h = c < 0.000001 ? 0 : normalizeHue((Math.atan2(labB, labA) * 180) / Math.PI); - return { c, h, l }; + return { l, c, h }; } diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index 48c3ea00..42e54cd0 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -299,12 +299,12 @@ function resolveNeutral(color: ThemeInput['color'], mode: ColorMode): string { if (other !== undefined) return adaptNeutralString(gamutMapOklch(parseColor(other)), mode); } const style = NEUTRAL_STYLE[color.neutralStyle ?? 'neutral']; - return formatOklch(gamutMapOklch({ c: style.chroma, h: style.hue, l: NEUTRAL_LIGHTNESS[mode] })); + return formatOklch(gamutMapOklch({ l: NEUTRAL_LIGHTNESS[mode], c: style.chroma, h: style.hue })); } /** Adapts a single neutral source to the mode canvas lightness, preserving hue and chroma. */ function adaptNeutralString(source: Oklch, mode: ColorMode): string { - return formatOklch(gamutMapOklch({ c: source.c, h: source.h, l: NEUTRAL_LIGHTNESS[mode] })); + return formatOklch(gamutMapOklch({ l: NEUTRAL_LIGHTNESS[mode], c: source.c, h: source.h })); } /** @@ -357,7 +357,7 @@ function sideOf(input: ColorInput, mode: ColorMode): string | undefined { function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { const target = ACCENT_TARGET[mode]; const [low, high] = ACCENT_BAND[mode]; - const makeSolid = (l: number) => gamutMapOklch({ c: source.c, h: source.h, l }); + const makeSolid = (l: number) => gamutMapOklch({ l, c: source.c, h: source.h }); const passes = (l: number) => onSolidGatePasses(mode, makeSolid(l)); if (passes(target)) return makeSolid(target); @@ -395,8 +395,8 @@ function onSolidGatePasses(mode: ColorMode, solid: Oklch): boolean { gamutMapOklch({ ...solid, l: clampUnit(solid.l + 0.05 * hoverDirection) }), gamutMapOklch({ ...solid, l: clampUnit(solid.l + 0.09 * hoverDirection) }), ]; - const nearWhite = gamutMapOklch({ c: 0, h: solid.h, l: 0.985 }); - const nearBlack = gamutMapOklch({ c: 0.01, h: solid.h, l: 0.18 }); + const nearWhite = gamutMapOklch({ l: 0.985, c: 0, h: solid.h }); + const nearBlack = gamutMapOklch({ l: 0.18, c: 0.01, h: solid.h }); const whiteMinimum = Math.min(...solids.map((state) => contrastRatio(nearWhite, state))); const blackMinimum = Math.min(...solids.map((state) => contrastRatio(nearBlack, state))); return Math.max(whiteMinimum, blackMinimum) >= TEXT_RATIO; diff --git a/packages/@luke-ui/react/src/theme/elevation.test.ts b/packages/@luke-ui/react/src/theme/elevation.test.ts new file mode 100644 index 00000000..e9529867 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/elevation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { Oklch } from './color.js'; +import { generateSurfaces } from './elevation.js'; + +const lightBackground: Oklch = { l: 0.985, c: 0.006, h: 90 }; +const darkBackground: Oklch = { l: 0.18, c: 0.01, h: 260 }; + +describe('generateSurfaces', () => { + it('sets canvas to exactly the background input, in both modes', () => { + const light = generateSurfaces({ background: lightBackground, mode: 'light' }); + expect(light.canvas).toEqual(lightBackground); + + const dark = generateSurfaces({ background: darkBackground, mode: 'dark' }); + expect(dark.canvas).toEqual(darkBackground); + }); + + it('does not produce a resting surface', () => { + const surfaces = generateSurfaces({ background: lightBackground, mode: 'light' }); + expect(Object.keys(surfaces).sort()).toEqual(['canvas', 'floating', 'overlay', 'recessed']); + expect('resting' in surfaces).toBe(false); + }); + + describe('dark mode', () => { + const surfaces = generateSurfaces({ background: darkBackground, mode: 'dark' }); + + it('sinks recessed below the canvas', () => { + expect(surfaces.recessed.l).toBeLessThan(surfaces.canvas.l); + }); + + it('lifts floating and overlay above the canvas, with overlay the most separated', () => { + expect(surfaces.floating.l).toBeGreaterThan(surfaces.canvas.l); + expect(surfaces.overlay.l).toBeGreaterThan(surfaces.canvas.l); + expect(surfaces.overlay.l).toBeGreaterThan(surfaces.floating.l); + }); + }); + + describe('light mode', () => { + const surfaces = generateSurfaces({ background: lightBackground, mode: 'light' }); + + it('pins recessed to neutral white', () => { + expect(surfaces.recessed).toEqual({ l: 1, c: 0, h: 0 }); + }); + + it('separates floating and overlay from the canvas only slightly, with overlay further', () => { + expect(surfaces.floating.l).toBeGreaterThan(surfaces.canvas.l); + expect(surfaces.overlay.l).toBeGreaterThan(surfaces.canvas.l); + expect(surfaces.overlay.l).toBeGreaterThan(surfaces.floating.l); + expect(surfaces.overlay.l - surfaces.canvas.l).toBeLessThan(0.05); + }); + }); + + it('keeps hue and chroma from the background on the mode-derived surfaces', () => { + const surfaces = generateSurfaces({ background: darkBackground, mode: 'dark' }); + expect(surfaces.floating.h).toBeCloseTo(darkBackground.h, 5); + expect(surfaces.overlay.h).toBeCloseTo(darkBackground.h, 5); + }); +}); diff --git a/packages/@luke-ui/react/src/theme/elevation.ts b/packages/@luke-ui/react/src/theme/elevation.ts new file mode 100644 index 00000000..f43c6393 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/elevation.ts @@ -0,0 +1,66 @@ +import type { Oklch } from './color.js'; +import { gamutMapOklch } from './color.js'; + +/** The colour mode a set of surfaces is generated for. */ +type ElevationMode = 'light' | 'dark'; + +/** + * The four surface colours a theme mode emits, keyed by elevation. `canvas` is the resolved + * background anchor itself; the remaining three are derived from it. + */ +export interface GeneratedSurfaces { + /** The page canvas. Always equal to the `background` input — canvas IS the background. */ + canvas: Oklch; + /** A well recessed below the canvas (dark: darker than canvas; light: neutral white). */ + recessed: Oklch; + /** A surface that lifts off the canvas, such as a card or menu. */ + floating: Oklch; + /** The most separated surface, such as a modal or popover. */ + overlay: Oklch; +} + +/** Input to {@link generateSurfaces}. */ +export interface GenerateSurfacesRequest { + /** The resolved canvas anchor colour for this mode. */ + background: Oklch; + /** The colour mode being generated. */ + mode: ElevationMode; +} + +// Surface roles encode usage directly: light wells use neutral white, dark wells sit below the +// canvas, and detached surfaces separate more strongly without exposing generated palette steps. +// Mirrors the deltas `buildModeColors` in build-theme.ts uses today (minus the hidden `resting` +// rung, which has no public semantic meaning and is dropped here). +const LIGHT_RECESSED_SURFACE = { l: 1, c: 0, h: 0 } as const satisfies Oklch; +const DARK_RECESSED_SURFACE_LIGHTNESS_DELTA = -0.025; +const SURFACE_LIGHTNESS_DELTAS = { + dark: { floating: 0.07, overlay: 0.09 }, + light: { floating: 0.012, overlay: 0.015 }, +} as const satisfies Record>; + +/** + * Derives the mode-aware elevation surface set from a resolved background canvas anchor. + * `surfaces.canvas` is always exactly the `background` input. `recessed`, `floating`, and + * `overlay` are mode-aware lightness offsets from the canvas, preserving today's relationships: + * dark mode separates surfaces by lightening them above the canvas (recessed is the one + * exception, sitting slightly darker); light mode keeps `recessed` at neutral white and + * separates `floating`/`overlay` from the canvas only slightly. Colour-only: does not read or + * emit `depth`/`actionControlFinish` shadow strings, and does not alias onto any functional + * colour scale. + */ +export function generateSurfaces(request: GenerateSurfacesRequest): GeneratedSurfaces { + const { background: canvas, mode } = request; + const isLight = mode === 'light'; + const surfaceAt = (delta: number) => gamutMapOklch({ ...canvas, l: clampUnit(canvas.l + delta) }); + const deltas = SURFACE_LIGHTNESS_DELTAS[mode]; + return { + canvas, + floating: surfaceAt(deltas.floating), + overlay: surfaceAt(deltas.overlay), + recessed: isLight ? LIGHT_RECESSED_SURFACE : surfaceAt(DARK_RECESSED_SURFACE_LIGHTNESS_DELTA), + }; +} + +function clampUnit(value: number): number { + return Math.min(1, Math.max(0, value)); +}