diff --git a/packages/@luke-ui/react/src/theme/semantic-map.test.ts b/packages/@luke-ui/react/src/theme/semantic-map.test.ts new file mode 100644 index 00000000..4b6053d3 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/semantic-map.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { Oklch } from './color.js'; +import { formatOklch, parseColor } from './color.js'; +import { flattenThemeContract } from './contract.js'; +import { generateSurfaces } from './elevation.js'; +import { defaultSourceColors } from './foundation.js'; +import type { FamilyRole, ScaleFamily } from './scale.js'; +import { generateFamily } from './scale.js'; +import { mapSemanticColors } from './semantic-map.js'; + +type ColorMode = 'light' | 'dark'; + +const BACKGROUND: Record = { + dark: parseColor('oklch(0.18 0.004 250)'), + light: parseColor('oklch(0.99 0.003 250)'), +}; + +const MODES: ReadonlyArray = ['light', 'dark']; +const ACTION_ROLES = ['neutral', 'accent', 'danger'] as const; +const FEEDBACK_ROLES = ['info', 'success', 'warning'] as const; + +// A representative source per role and mode. `info`/`success`/`warning`/`danger` reuse Luke UI's +// curated defaults, which are chosen to clear the on-solid gate on near-white/near-black canvases; +// `accent` reuses the vibrant blue scale.test.ts exercises without adaptation in either mode; +// `neutral` mirrors the canvas so the neutral solid search stays in its curated band. +const SOURCE: Record> = { + dark: { + accent: '#0090ff', + danger: defaultSourceColors.dark.danger, + info: defaultSourceColors.dark.info, + neutral: 'oklch(0.18 0.004 250)', + success: defaultSourceColors.dark.success, + warning: defaultSourceColors.dark.warning, + }, + light: { + accent: '#0090ff', + danger: defaultSourceColors.light.danger, + info: defaultSourceColors.light.info, + neutral: 'oklch(0.99 0.003 250)', + success: defaultSourceColors.light.success, + warning: defaultSourceColors.light.warning, + }, +}; + +function buildFamilies(mode: ColorMode, background: Oklch): Record { + const family = (role: FamilyRole) => { + return generateFamily({ background, mode, role, source: parseColor(SOURCE[mode][role]) }); + }; + return { + accent: family('accent'), + danger: family('danger'), + info: family('info'), + neutral: family('neutral'), + success: family('success'), + warning: family('warning'), + }; +} + +describe('mapSemanticColors', () => { + describe('correctness', () => { + for (const mode of MODES) { + it(`resolves every leaf to its mapped family step / surface / passthrough (${mode})`, () => { + const background = BACKGROUND[mode]; + const families = buildFamilies(mode, background); + const surfaces = generateSurfaces({ background, mode }); + const scrim = 'oklch(0 0 0 / 0.45)'; + const focus = parseColor('oklch(0.6 0.2 260)'); + + const result = mapSemanticColors({ families, focus, mode, scrim, surfaces }); + + // Surfaces: canvas IS the background. + expect(result['color.surface.canvas']).toBe(formatOklch(surfaces.canvas)); + expect(result['color.surface.recessed']).toBe(formatOklch(surfaces.recessed)); + expect(result['color.surface.floating']).toBe(formatOklch(surfaces.floating)); + expect(result['color.surface.overlay']).toBe(formatOklch(surfaces.overlay)); + expect(result['color.scrim']).toBe(scrim); + expect(result['color.loadingSkeleton']).toBe(formatOklch(families.neutral[3])); + + // Global text / borders: neutral only. + expect(result['color.text.primary']).toBe(formatOklch(families.neutral[12])); + expect(result['color.text.secondary']).toBe(formatOklch(families.neutral[11])); + expect(result['color.text.disabled']).toBe(formatOklch(families.neutral[8])); + expect(result['color.border.decorative']).toBe(formatOklch(families.neutral[6])); + expect(result['color.border.control']).toBe(formatOklch(families.neutral[7])); + expect(result['color.border.focus']).toBe(formatOklch(focus)); + + // Action intents: full ramp, keyed to the intent's own family. + for (const role of ACTION_ROLES) { + const family = families[role]; + expect(result[`color.intent.${role}.surface.subtle`]).toBe(formatOklch(family[3])); + expect(result[`color.intent.${role}.surface.subtleHover`]).toBe(formatOklch(family[4])); + expect(result[`color.intent.${role}.surface.subtlePressed`]).toBe(formatOklch(family[5])); + expect(result[`color.intent.${role}.surface.solid`]).toBe(formatOklch(family[9])); + expect(result[`color.intent.${role}.surface.solidHover`]).toBe(formatOklch(family[10])); + // Deliberate dup: pressed reuses the hover value. + expect(result[`color.intent.${role}.surface.solidPressed`]).toBe(formatOklch(family[10])); + expect(result[`color.intent.${role}.onSolid`]).toBe(formatOklch(family.contrast)); + } + for (const role of ['accent', 'danger'] as const) { + const family = families[role]; + expect(result[`color.intent.${role}.border`]).toBe(formatOklch(family[7])); + expect(result[`color.intent.${role}.text`]).toBe(formatOklch(family[11])); + } + expect(result['color.intent.accent.textHover']).toBe(formatOklch(families.accent[12])); + + // Feedback intents: reduced kit only. + for (const role of FEEDBACK_ROLES) { + const family = families[role]; + expect(result[`color.intent.${role}.surface.subtle`]).toBe(formatOklch(family[3])); + expect(result[`color.intent.${role}.border`]).toBe(formatOklch(family[7])); + expect(result[`color.intent.${role}.text`]).toBe(formatOklch(family[11])); + } + }); + + it(`defaults border.focus to the accent family's step 8 when focus is omitted (${mode})`, () => { + const background = BACKGROUND[mode]; + const families = buildFamilies(mode, background); + const surfaces = generateSurfaces({ background, mode }); + + const result = mapSemanticColors({ + families, + mode, + scrim: 'oklch(0 0 0 / 0.45)', + surfaces, + }); + + expect(result['color.border.focus']).toBe(formatOklch(families.accent[8])); + }); + } + }); + + describe('completeness', () => { + // Every generated colour leaf (every `color.*` path except the passed-through `color.scrim`) + // must receive a value. + const generatedColourPaths = flattenThemeContract().filter( + ([path]) => path.startsWith('color.') && path !== 'color.scrim', + ); + + for (const mode of MODES) { + it(`assigns every generated colour leaf a value (${mode})`, () => { + const background = BACKGROUND[mode]; + const families = buildFamilies(mode, background); + const surfaces = generateSurfaces({ background, mode }); + + const result = mapSemanticColors({ + families, + mode, + scrim: 'oklch(0 0 0 / 0.45)', + surfaces, + }); + + for (const [path] of generatedColourPaths) { + expect(typeof result[path]).toBe('string'); + } + }); + } + }); + + describe('scrim', () => { + it('passes the authored scrim value through verbatim, alpha channel included', () => { + const background = BACKGROUND.light; + const families = buildFamilies('light', background); + const surfaces = generateSurfaces({ background, mode: 'light' }); + const scrim = 'oklch(0 0 0 / 0.5)'; + + const result = mapSemanticColors({ families, mode: 'light', scrim, surfaces }); + + expect(result['color.scrim']).toBe(scrim); + }); + }); +}); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts new file mode 100644 index 00000000..33d3845f --- /dev/null +++ b/packages/@luke-ui/react/src/theme/semantic-map.ts @@ -0,0 +1,101 @@ +/** + * The one default semantic colour mapping. `mapSemanticColors` aliases every generated colour + * contract leaf onto a private scale family's step or a generated surface, per the locked mapping + * table. It is a pure lookup: no colour math happens here, and it never distorts a family or + * surface to make a leaf fit. + * + * Isolated by design: nothing here is wired into `buildTheme` yet (that is a later stage). Values + * are formatted with `formatOklch`, the same representation the current pipeline emits, so the + * result can be dropped straight into the mode value record `buildModeColors` produces today. + */ + +import type { Oklch } from './color.js'; +import { formatOklch } from './color.js'; +import type { GeneratedSurfaces } from './elevation.js'; +import type { FamilyRole, ScaleFamily } from './scale.js'; + +type ColorMode = 'light' | 'dark'; + +/** Every generated colour contract leaf's CSS value, keyed by its dotted path (for example + * `'color.text.primary'`), plus the passed-through `'color.scrim'`. */ +export type SemanticColorValues = Record; + +/** The inputs to {@link mapSemanticColors}. */ +interface MapSemanticColorsRequest { + /** The generated scale family for each role, already resolved for `mode`. */ + families: Record; + /** The generated elevation surface set, already resolved for `mode`. */ + surfaces: GeneratedSurfaces; + /** The authored scrim value, passed through verbatim (it may carry an alpha channel). */ + scrim: string; + /** The authored keyboard-focus source colour. Defaults to the accent family's step 8. */ + focus?: Oklch; + /** The colour mode the families and surfaces were resolved for. */ + mode: ColorMode; +} + +// Action intents render the full interactive ramp; feedback intents are static and expose only the +// soft kit (subtle surface + border + text). Mirrors ACTION_INTENTS/FEEDBACK_INTENTS in +// build-theme.ts. +const ACTION_INTENTS = ['neutral', 'accent', 'danger'] as const; +// Accent and danger additionally expose a border and low-contrast text; neutral does not (its +// borders/text are the global neutral leaves instead). +const BORDER_AND_TEXT_INTENTS = ['accent', 'danger'] as const; +const FEEDBACK_INTENTS = ['info', 'success', 'warning'] as const; + +/** + * Resolves every colour contract leaf onto the private families and surfaces, per the locked + * semantic mapping table. `families`/`surfaces` are already mode-resolved; `scrim` passes through + * verbatim; `focus` defaults to the accent family's step 8 when the theme author omits it. + */ +export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticColorValues { + const { families, surfaces, scrim, focus } = request; + const neutral = families.neutral; + const values: Record = {}; + + // Surfaces: canvas IS the background, so it is aliased here rather than recomputed. + values['color.surface.canvas'] = formatOklch(surfaces.canvas); + values['color.surface.recessed'] = formatOklch(surfaces.recessed); + values['color.surface.floating'] = formatOklch(surfaces.floating); + values['color.surface.overlay'] = formatOklch(surfaces.overlay); + values['color.scrim'] = scrim; + values['color.loadingSkeleton'] = formatOklch(neutral[3]); + + // Global text / borders: neutral only. + values['color.text.primary'] = formatOklch(neutral[12]); + values['color.text.secondary'] = formatOklch(neutral[11]); + values['color.text.disabled'] = formatOklch(neutral[8]); + values['color.border.decorative'] = formatOklch(neutral[6]); + values['color.border.control'] = formatOklch(neutral[7]); + values['color.border.focus'] = formatOklch(focus ?? families.accent[8]); + + // Action intents: full ramp, keyed to the intent's own family. + for (const role of ACTION_INTENTS) { + const family = families[role]; + values[`color.intent.${role}.surface.subtle`] = formatOklch(family[3]); + values[`color.intent.${role}.surface.subtleHover`] = formatOklch(family[4]); + values[`color.intent.${role}.surface.subtlePressed`] = formatOklch(family[5]); + values[`color.intent.${role}.surface.solid`] = formatOklch(family[9]); + values[`color.intent.${role}.surface.solidHover`] = formatOklch(family[10]); + // Deliberate dup: pressed is carried by depth.recessed / actionControlFinish.recessed / + // transform, not a distinct colour. + values[`color.intent.${role}.surface.solidPressed`] = formatOklch(family[10]); + values[`color.intent.${role}.onSolid`] = formatOklch(family.contrast); + } + for (const role of BORDER_AND_TEXT_INTENTS) { + const family = families[role]; + values[`color.intent.${role}.border`] = formatOklch(family[7]); + values[`color.intent.${role}.text`] = formatOklch(family[11]); + } + values['color.intent.accent.textHover'] = formatOklch(families.accent[12]); + + // Feedback intents: reduced kit only (subtle surface + border + text). + for (const role of FEEDBACK_INTENTS) { + const family = families[role]; + values[`color.intent.${role}.surface.subtle`] = formatOklch(family[3]); + values[`color.intent.${role}.border`] = formatOklch(family[7]); + values[`color.intent.${role}.text`] = formatOklch(family[11]); + } + + return values; +}