diff --git a/.changeset/metal-hands-chew.md b/.changeset/metal-hands-chew.md new file mode 100644 index 0000000000..336c40581a --- /dev/null +++ b/.changeset/metal-hands-chew.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/design": patch +--- + +Add submodule for color utilities for automatic hover and press coloring diff --git a/AGENTS.md b/AGENTS.md index 6faf590026..86f9b759bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ This is the **FluentUI React Native** repository, a monorepo containing React Na **Tokens**: Design tokens handle styling and customization. Tokens are design-time values set via theme or component customization (e.g., "brandColor"). Tokens can also be props (specified via "TokensThatAreAlsoProps"). This system enables simpler customization and better memoization. For Flex token authoring, use `packages/agentic/design/src/tokens/mappings/flex-token-map.yaml` as the canonical mapping from generic CSS and Fluent token sources to grouped React Native Flex token paths. When mapping an agentic component to an existing V1 component, use the Win32, macOS, or Windows implementation as the canonical compatibility reference; do not use iOS as the cross-platform canonical value unless the work explicitly targets iOS. +**Color utilities**: Runtime-safe parsing, conversion, compositing, contrast, and interaction generation live behind the opt-in `@fluentui-react-native/design/color-lib` subpath. Keep its public exports intentionally narrow and add APIs only for demonstrated production needs. Color validation policy, semantic pairings, audit helpers, and conformance fixtures belong under `packages/agentic/design/src/testing/` and are exported, when generally useful to tests, from `@fluentui-react-native/design/testing`. Production code must not depend on the testing subpath, and testing-only validation does not need bundle-size scenarios. + **Theme-specific styles**: Cache `StyleSheet.create` results that depend only on `ThemeState` with a module-scoped getter created by `themedStyleSheetFactory`. Treat them as immutable, and apply props, interaction state, and user styles separately so the cached sheet is safe to share between component instances. **Platform-Specific Files**: Components use platform-specific files with extensions like `.ios.ts`, `.android.ts`, `.win32.ts`, `.macos.ts` for platform-specific implementations. diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json index 559832dc88..abf8eb3ce7 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -67,6 +67,21 @@ "@fluentui-react-native/framework-base": 3413 } }, + { + "platform": "macos", + "scenario": "design-color-lib", + "rawBytes": 827767, + "gzipBytes": 239469, + "moduleCount": 503, + "metroModuleCount": 501, + "metafileInputCount": 503, + "workspaceModules": { + "@fluentui-react-native/design": 6 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 7097 + } + }, { "platform": "macos", "scenario": "design-theme-state-legacy-source", @@ -167,6 +182,21 @@ "@fluentui-react-native/framework-base": 3413 } }, + { + "platform": "windows", + "scenario": "design-color-lib", + "rawBytes": 836902, + "gzipBytes": 240953, + "moduleCount": 508, + "metroModuleCount": 506, + "metafileInputCount": 508, + "workspaceModules": { + "@fluentui-react-native/design": 6 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 7097 + } + }, { "platform": "windows", "scenario": "design-theme-state-legacy-source", diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json index 11bb13a385..280a192da9 100644 --- a/apps/bundle-size/scenarios.json +++ b/apps/bundle-size/scenarios.json @@ -18,7 +18,13 @@ "name": "design-theme-state", "module": "@fluentui-react-native/design", "exports": ["FlexThemeReference", "ThemeProvider", "useThemeState"], - "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/"] + "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/", "/color-lib/"] + }, + { + "name": "design-color-lib", + "module": "@fluentui-react-native/design/color-lib", + "exports": ["getContrastRatio", "getHoverColor", "getPressColor"], + "requiredInputPatterns": ["/color-lib/compositing.", "/color-lib/conversion.", "/color-lib/interaction.", "/color-lib/parsing."] }, { "name": "design-theme-state-legacy-source", diff --git a/packages/agentic/design/package.json b/packages/agentic/design/package.json index 2d9e063b8a..2b4ea2016b 100644 --- a/packages/agentic/design/package.json +++ b/packages/agentic/design/package.json @@ -33,6 +33,12 @@ "import": "./lib/tokens/legacy/index.js", "default": "./src/tokens/legacy/index.ts" }, + "./color-lib": { + "types": "./lib/color-lib/index.d.ts", + "react-native": "./src/color-lib/index.ts", + "import": "./lib/color-lib/index.js", + "default": "./src/color-lib/index.ts" + }, "./theming": { "types": "./lib/theming/index.d.ts", "react-native": "./src/theming/index.ts", diff --git a/packages/agentic/design/src/color-lib/README.md b/packages/agentic/design/src/color-lib/README.md new file mode 100644 index 0000000000..71dc193552 --- /dev/null +++ b/packages/agentic/design/src/color-lib/README.md @@ -0,0 +1,37 @@ +# Color library + +`@fluentui-react-native/design/color-lib` is the opt-in runtime color engine. +It groups public utilities by responsibility: + +- `parsing.ts` parses literal React Native colors and formats RGBA values; +- `conversion.ts` converts between normalized sRGB and OKLCH; +- `compositing.ts` composites alpha colors and calculates WCAG contrast; +- `interaction.ts` generates hover and pressed colors with the default or + Bebop Warm algorithm. + +Native and dynamic color objects cannot be resolved in JavaScript, so parsing +and interaction APIs return structured diagnostics instead of fabricated +colors. Direct compositing and contrast operations throw descriptive errors +for unsupported values. + +The algorithms are ported from +[`x3-design/fluent-design@d334acf5cbad813f2b7cd554da942b09a7ff8f10`](https://github.com/x3-design/fluent-design/tree/d334acf5cbad813f2b7cd554da942b09a7ff8f10/dev/web/flex-themes). +The default variant preserves chroma. The `bebopWarm` variant adds the pinned +opacity-aware lightness, chroma, bounds, and translucent reference-backdrop +solve. + +```ts +import { getContrastRatio, getHoverColor } from '@fluentui-react-native/design/color-lib'; + +const hover = getHoverColor('#c02e56', 'backgroundDangerLoud', 'light', { + variant: 'bebopWarm', +}); +const ratio = getContrastRatio('#000000db', '#ffffff'); +``` + +The public interaction surface is intentionally limited to generating one +hover or pressed value at a time. Bulk fallback helpers and algorithm constants +remain internal until a production consumer needs them. + +The submodule is absent from the design root barrel and does not import the +test-only `testing` submodule. diff --git a/packages/agentic/design/src/color-lib/colorLib.test.ts b/packages/agentic/design/src/color-lib/colorLib.test.ts new file mode 100644 index 0000000000..6c6f4f2757 --- /dev/null +++ b/packages/agentic/design/src/color-lib/colorLib.test.ts @@ -0,0 +1,264 @@ +import { PlatformColor } from 'react-native'; + +import { + x3BebopWarmInteractionConformance, + x3InteractionConformance, + x3WarmVariantDeviations, +} from '../testing/color-validation/x3InteractionConformance'; +import { defaultFlexTokens } from '../tokens/defaultTokens'; +import type { SemanticColorTokenValues } from '../tokens/flex.types'; +import { + compositeColor, + compositeRgba, + getContrastRatio, + getHoverColor, + getInteractionColor, + getPressColor, + isInverseInteractionToken, + oklchToRgb, + parseColorValue, + resolveContrastColors, + rgbaToHex, + rgbToOklch, +} from './index'; +import type { ColorMode, InteractionColorState } from './index'; +import { getInteractionColorOverrides, getInteractionColors, INTERACTIVE_COLOR_TOKENS } from './interaction'; + +const modeIndexes = { + light: { rest: 0, hover: 1, pressed: 2 }, + dark: { rest: 3, hover: 4, pressed: 5 }, +} as const; + +function conformanceColors(mode: ColorMode): SemanticColorTokenValues { + const { hover: _hover, pressed: _pressed, ...colors } = defaultFlexTokens.color; + for (const token of INTERACTIVE_COLOR_TOKENS) { + colors[token] = x3InteractionConformance[token][modeIndexes[mode].rest]; + } + return colors; +} + +function getStateColor( + state: InteractionColorState, + color: string, + token: (typeof INTERACTIVE_COLOR_TOKENS)[number], + mode: ColorMode, + variant: 'default' | 'bebopWarm', +) { + return state === 'hover' ? getHoverColor(color, token, mode, { variant }) : getPressColor(color, token, mode, { variant }); +} + +describe('color parsing and conversion', () => { + it('parses supported literal formats', () => { + expect(parseColorValue('#1238')).toEqual({ + status: 'resolved', + color: { + r: 0x11 / 255, + g: 0x22 / 255, + b: 0x33 / 255, + a: 0x88 / 255, + }, + }); + expect(parseColorValue('rgb(100% 0% 50% / 25%)')).toEqual({ + status: 'resolved', + color: { r: 1, g: 0, b: 0.5, a: 0.25 }, + }); + }); + + it.each([ + ['RGB(255, 128, 0)', { r: 1, g: 128 / 255, b: 0, a: 1 }], + ['rgba(255, 128, 0, .5)', { r: 1, g: 128 / 255, b: 0, a: 0.5 }], + ['rgba(255 128 0 / 50%)', { r: 1, g: 128 / 255, b: 0, a: 0.5 }], + ['rgb(255, 128, 0 / 50%)', { r: 1, g: 128 / 255, b: 0, a: 0.5 }], + ['rgb(1. 2.5 .5)', { r: 1 / 255, g: 2.5 / 255, b: 0.5 / 255, a: 1 }], + ])('parses %s without regular expressions', (value, color) => { + expect(parseColorValue(value)).toEqual({ status: 'resolved', color }); + }); + + it.each(['rgba(0 0 0)', 'rgb(0 0 0 / 50% / 25%)', 'rgb(0, 0 0)', 'rgb(256 0 0)', 'rgb(0 0 0 / 101%)'])( + 'rejects malformed literal %s', + (value) => { + expect(parseColorValue(value)).toMatchObject({ + status: 'unresolvable', + diagnostic: { reason: 'unsupported-color-format' }, + }); + }, + ); + + it('rejects long invalid input without a backtracking expression', () => { + const value = `rgb(${' '.repeat(100_000)}x)`; + + expect(parseColorValue(value)).toMatchObject({ + status: 'unresolvable', + diagnostic: { reason: 'unsupported-color-format' }, + }); + }); + + it('preserves native numeric conversion at byte-rounding boundaries', () => { + const result = parseColorValue('rgba(0, 0, 0, 0.00588235294117647)'); + + expect(result.status).toBe('resolved'); + if (result.status === 'resolved') { + expect(result.color.a).toBe(0.00588235294117647); + expect(rgbaToHex(result.color)).toBe('#00000001'); + } + }); + + it('formats RGBA and round-trips RGB through OKLCH', () => { + expect(rgbaToHex({ r: 1, g: 0.5, b: 0, a: 0.5 })).toBe('#ff800080'); + + const source = { r: 0.1, g: 0.4, b: 0.8 }; + const roundTrip = oklchToRgb(rgbToOklch(source)); + expect(roundTrip.r).toBeCloseTo(source.r, 6); + expect(roundTrip.g).toBeCloseTo(source.g, 6); + expect(roundTrip.b).toBeCloseTo(source.b, 6); + }); + + it('returns diagnostics for unsupported and native colors', () => { + expect(parseColorValue('red')).toMatchObject({ + status: 'unresolvable', + diagnostic: { reason: 'unsupported-color-format' }, + }); + expect(parseColorValue(PlatformColor('labelColor'))).toMatchObject({ + status: 'unresolvable', + diagnostic: { reason: 'non-literal-color' }, + }); + }); +}); + +describe('color compositing and contrast', () => { + it('composites normalized and literal colors', () => { + expect(compositeRgba({ r: 0, g: 0, b: 0, a: 0.5 }, { r: 1, g: 1, b: 1, a: 1 })).toEqual({ + r: 0.5, + g: 0.5, + b: 0.5, + a: 1, + }); + expect(compositeColor('#0000000d', '#ffffff')).toBe('#f2f2f2'); + expect(compositeColor('rgba(255 0 0 / 50%)', '#000000')).toBe('#800000'); + }); + + it('resolves effective colors and their WCAG ratio', () => { + expect(resolveContrastColors('#00000080', '#ffffff')).toEqual({ + ratio: expect.closeTo(4.0041, 4), + foregroundResolved: '#7f7f7f', + backgroundResolved: '#ffffff', + }); + expect(getContrastRatio('#000000', '#ffffff')).toBe(21); + }); + + it('rejects unsupported direct operations', () => { + expect(() => compositeColor('red', '#ffffff')).toThrow('Unsupported color literal'); + expect(() => compositeColor('#00000080', '#ffffff80')).toThrow('background color must be opaque'); + expect(() => getContrastRatio('#000000', '#ffffff', '#ffffff80')).toThrow('fallback surface must be opaque'); + }); +}); + +describe.each(['light', 'dark'] as const)('%s interaction colors', (mode) => { + it.each(INTERACTIVE_COLOR_TOKENS)('matches default and Bebop Warm output for %s', (token) => { + const vector = x3InteractionConformance[token]; + const warmVector = x3BebopWarmInteractionConformance[token]; + for (const state of ['hover', 'pressed'] as const) { + const deviationKey = `${token}.${state}.${mode}` as keyof typeof x3WarmVariantDeviations; + const deviation = x3WarmVariantDeviations[deviationKey]; + const upstreamValue = vector[modeIndexes[mode][state]]; + const warmValue = warmVector[modeIndexes[mode][state]]; + + expect(getStateColor(state, vector[modeIndexes[mode].rest], token, mode, 'default')).toEqual({ + status: 'derived', + value: deviation?.defaultValue ?? upstreamValue, + }); + expect(getStateColor(state, warmVector[modeIndexes[mode].rest], token, mode, 'bebopWarm')).toEqual({ + status: 'derived', + value: warmValue, + }); + if (deviation) { + expect(upstreamValue).toBe(deviation.warmValue); + } + } + }); + + it('generates a complete override map', () => { + const colors = conformanceColors(mode); + for (const token of INTERACTIVE_COLOR_TOKENS) { + colors[token] = x3BebopWarmInteractionConformance[token][modeIndexes[mode].rest]; + } + const result = getInteractionColorOverrides(colors, mode, 'hover', { variant: 'bebopWarm' }); + + expect(result.diagnostics).toEqual([]); + expect(Object.keys(result.overrides)).toHaveLength(59); + }); +}); + +describe('interaction color API', () => { + it('classifies every inverse token with the upstream heavy/loud/onloud rule', () => { + const expected = [ + 'backgroundNeutralHeavy', + 'backgroundNeutralLoud', + 'backgroundBrandHeavy', + 'backgroundBrandLoud', + 'backgroundDangerLoud', + 'backgroundWarningLoud', + 'backgroundSuccessLoud', + 'strokeNeutralHeavy', + 'strokeNeutralLoud', + 'strokeNeutralOnloud', + 'strokeBrandLoud', + 'strokeBrandOnloud', + 'strokeDangerLoud', + 'strokeDangerOnloud', + 'strokeWarningLoud', + 'strokeWarningOnloud', + 'strokeSuccessLoud', + 'strokeSuccessOnloud', + 'foregroundNeutralOnloud', + 'foregroundBrandOnloud', + 'foregroundDangerOnloud', + 'foregroundWarningOnloud', + 'foregroundSuccessOnloud', + ]; + + expect(INTERACTIVE_COLOR_TOKENS.filter(isInverseInteractionToken)).toEqual(expected); + expect(INTERACTIVE_COLOR_TOKENS).toHaveLength(59); + expect(Object.keys(x3WarmVariantDeviations)).toHaveLength(10); + }); + + it('fills missing values without replacing authored interaction colors', () => { + const input = { + ...conformanceColors('light'), + hover: { surfaceNeutralFarther: '#123456' }, + }; + const result = getInteractionColors(input, 'light'); + + expect(result.colors.hover.surfaceNeutralFarther).toBe('#123456'); + expect(result.colors.hover.surfaceNeutralFar).toBe('#e8e8e8'); + expect(result.colors.pressed.surfaceNeutralFarther).toBe('#d7d7d7'); + expect(result.diagnostics).toEqual([]); + }); + + it('preserves identity when every interaction value is authored', () => { + const colors = conformanceColors('light'); + const hover = getInteractionColorOverrides(colors, 'light', 'hover').overrides; + const pressed = getInteractionColorOverrides(colors, 'light', 'pressed').overrides; + const input = { ...colors, hover, pressed }; + + expect(getInteractionColors(input, 'light').colors).toBe(input); + }); + + it('reports opaque platform colors instead of deriving a wrong value', () => { + expect(getInteractionColor(PlatformColor('labelColor'), 'foregroundNeutralPrimary', 'light', 'hover')).toMatchObject({ + status: 'unresolvable', + diagnostic: { + reason: 'non-literal-color', + token: 'foregroundNeutralPrimary', + state: 'hover', + }, + }); + }); + + it('reports malformed literals', () => { + expect(getPressColor('rgb(12oops 0 0)', 'foregroundDangerPrimary', 'light')).toMatchObject({ + status: 'unresolvable', + diagnostic: { reason: 'unsupported-color-format' }, + }); + }); +}); diff --git a/packages/agentic/design/src/color-lib/compositing.ts b/packages/agentic/design/src/color-lib/compositing.ts new file mode 100644 index 0000000000..8b96c82f77 --- /dev/null +++ b/packages/agentic/design/src/color-lib/compositing.ts @@ -0,0 +1,88 @@ +import type { ColorValue } from 'react-native'; + +import { requireLiteralColor, rgbaToHex } from './parsing'; +import type { ResolvedContrastColors, RgbaColor } from './types'; + +/** + * Alpha-composite normalized foreground channels over normalized background + * channels and retain the resulting alpha. + */ +export function compositeRgba(foreground: RgbaColor, background: RgbaColor): RgbaColor { + const outputAlpha = foreground.a + background.a * (1 - foreground.a); + if (outputAlpha === 0) { + return { r: 0, g: 0, b: 0, a: 0 }; + } + + return { + r: (foreground.r * foreground.a + background.r * background.a * (1 - foreground.a)) / outputAlpha, + g: (foreground.g * foreground.a + background.g * background.a * (1 - foreground.a)) / outputAlpha, + b: (foreground.b * foreground.a + background.b * background.a * (1 - foreground.a)) / outputAlpha, + a: outputAlpha, + }; +} + +function opaqueComposite(foreground: RgbaColor, background: RgbaColor): RgbaColor { + const composite = compositeRgba(foreground, background); + return { + r: Math.round(composite.r * 255) / 255, + g: Math.round(composite.g * 255) / 255, + b: Math.round(composite.b * 255) / 255, + a: 1, + }; +} + +/** + * Alpha-composite a literal foreground color over an opaque literal + * background and return the resolved hex color. + */ +export function compositeColor(foreground: ColorValue, background: ColorValue): string { + const foregroundColor = requireLiteralColor(foreground, 'compositeColor'); + const backgroundColor = requireLiteralColor(background, 'compositeColor'); + if (backgroundColor.a !== 1) { + throw new TypeError('compositeColor: the background color must be opaque.'); + } + return rgbaToHex(opaqueComposite(foregroundColor, backgroundColor)); +} + +function relativeLuminance({ r, g, b }: RgbaColor): number { + const linearize = (channel: number): number => (channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)); + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); +} + +/** + * Resolve translucent foreground and background literals against an opaque + * fallback surface and return both effective colors with their WCAG ratio. + */ +export function resolveContrastColors( + foreground: ColorValue, + background: ColorValue, + fallbackSurface: ColorValue = '#ffffff', +): ResolvedContrastColors { + const foregroundColor = requireLiteralColor(foreground, 'resolveContrastColors'); + const backgroundColor = requireLiteralColor(background, 'resolveContrastColors'); + const surfaceColor = requireLiteralColor(fallbackSurface, 'resolveContrastColors'); + if (surfaceColor.a !== 1) { + throw new TypeError('resolveContrastColors: the fallback surface must be opaque.'); + } + + const backgroundResolved = backgroundColor.a === 1 ? backgroundColor : opaqueComposite(backgroundColor, surfaceColor); + const foregroundResolved = foregroundColor.a === 1 ? foregroundColor : opaqueComposite(foregroundColor, backgroundResolved); + const foregroundLuminance = relativeLuminance(foregroundResolved); + const backgroundLuminance = relativeLuminance(backgroundResolved); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + + return { + ratio: (lighter + 0.05) / (darker + 0.05), + foregroundResolved: rgbaToHex(foregroundResolved), + backgroundResolved: rgbaToHex(backgroundResolved), + }; +} + +/** + * Calculate the WCAG contrast ratio for two literal colors, compositing alpha + * values against the supplied opaque fallback surface. + */ +export function getContrastRatio(foreground: ColorValue, background: ColorValue, fallbackSurface: ColorValue = '#ffffff'): number { + return resolveContrastColors(foreground, background, fallbackSurface).ratio; +} diff --git a/packages/agentic/design/src/color-lib/conversion.ts b/packages/agentic/design/src/color-lib/conversion.ts new file mode 100644 index 0000000000..fe6767b743 --- /dev/null +++ b/packages/agentic/design/src/color-lib/conversion.ts @@ -0,0 +1,61 @@ +import type { OklchColor, RgbColor } from './types'; + +function srgbToLinear(channel: number): number { + return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4); +} + +function linearToSrgb(channel: number): number { + return channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055; +} + +/** + * Convert normalized sRGB channels to OKLCH using the standard sRGB to linear + * RGB to LMS to OKLab pipeline. + */ +export function rgbToOklch({ r, g, b }: RgbColor): OklchColor { + const red = srgbToLinear(r); + const green = srgbToLinear(g); + const blue = srgbToLinear(b); + + const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue; + const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue; + const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue; + + const lRoot = Math.cbrt(l); + const mRoot = Math.cbrt(m); + const sRoot = Math.cbrt(s); + + const L = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot; + const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot; + const bLab = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot; + const h = (Math.atan2(bLab, a) * 180) / Math.PI; + + return { + L, + C: Math.sqrt(a * a + bLab * bLab), + h: (h + 360) % 360, + }; +} + +/** + * Convert an OKLCH color to normalized sRGB channels. Callers that emit an + * sRGB value should clamp out-of-gamut channels when formatting. + */ +export function oklchToRgb({ L, C, h }: OklchColor): RgbColor { + const radians = (h * Math.PI) / 180; + const a = C * Math.cos(radians); + const b = C * Math.sin(radians); + + const lRoot = L + 0.3963377774 * a + 0.2158037573 * b; + const mRoot = L - 0.1055613458 * a - 0.0638541728 * b; + const sRoot = L - 0.0894841775 * a - 1.291485548 * b; + const l = lRoot * lRoot * lRoot; + const m = mRoot * mRoot * mRoot; + const s = sRoot * sRoot * sRoot; + + return { + r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + }; +} diff --git a/packages/agentic/design/src/color-lib/index.ts b/packages/agentic/design/src/color-lib/index.ts new file mode 100644 index 0000000000..fd24af8f2e --- /dev/null +++ b/packages/agentic/design/src/color-lib/index.ts @@ -0,0 +1,23 @@ +export type { + ColorDiagnostic, + ColorDiagnosticReason, + ColorMode, + InteractionColorDiagnostic, + InteractionColorInput, + InteractionColorOptions, + InteractionColorOverridesResult, + InteractionColorResult, + InteractionColorsResult, + InteractionColorState, + InteractionColorVariant, + InteractiveColorToken, + OklchColor, + ParsedColorValue, + ResolvedContrastColors, + RgbColor, + RgbaColor, +} from './types'; +export { parseColorValue, rgbaToHex } from './parsing'; +export { oklchToRgb, rgbToOklch } from './conversion'; +export { compositeColor, compositeRgba, getContrastRatio, resolveContrastColors } from './compositing'; +export { getHoverColor, getInteractionColor, getPressColor, isInverseInteractionToken } from './interaction'; diff --git a/packages/agentic/design/src/color-lib/interaction.ts b/packages/agentic/design/src/color-lib/interaction.ts new file mode 100644 index 0000000000..6a824c9b72 --- /dev/null +++ b/packages/agentic/design/src/color-lib/interaction.ts @@ -0,0 +1,310 @@ +import type { ColorValue } from 'react-native'; + +import { rgbToOklch, oklchToRgb } from './conversion'; +import { channelToByte, clamp } from './math'; +import { parseColorValue, rgbaToHex } from './parsing'; +import type { + ColorMode, + InteractionColorDiagnostic, + InteractionColorInput, + InteractionColorOptions, + InteractionColorOverridesResult, + InteractionColorResult, + InteractionColorsResult, + InteractionColorState, + InteractiveColorToken, + RgbColor, + RgbaColor, +} from './types'; +import type { InteractiveColorOverrides, SemanticColors, SemanticColorTokenValues } from '../tokens/flex.types'; + +/** + * Interactive subset of the Flex semantic color tokens. + * + * Source: x3-design/fluent-design@d334acf5cbad813f2b7cd554da942b09a7ff8f10. + */ +export const INTERACTIVE_COLOR_TOKENS = [ + 'surfaceNeutralFarther', + 'surfaceNeutralFar', + 'surfaceNeutralNear', + 'surfaceNeutralNearer', + 'surfaceNeutralTranslucent', + 'backgroundNeutralHeavy', + 'backgroundNeutralLoud', + 'backgroundNeutralSoft', + 'backgroundNeutralSubtle', + 'backgroundNeutralTransparent', + 'backgroundNeutralTranslucent', + 'backgroundBrandHeavy', + 'backgroundBrandLoud', + 'backgroundBrandSoft', + 'backgroundBrandSubtle', + 'backgroundBrandTransparent', + 'backgroundDangerLoud', + 'backgroundDangerSoft', + 'backgroundDangerSubtle', + 'backgroundWarningLoud', + 'backgroundWarningSoft', + 'backgroundWarningSubtle', + 'backgroundSuccessLoud', + 'backgroundSuccessSoft', + 'backgroundSuccessSubtle', + 'strokeNeutralHeavy', + 'strokeNeutralLoud', + 'strokeNeutralSoft', + 'strokeNeutralSubtle', + 'strokeNeutralTransparent', + 'strokeNeutralOnloud', + 'strokeBrandLoud', + 'strokeBrandSoft', + 'strokeBrandSubtle', + 'strokeBrandOnloud', + 'strokeDangerLoud', + 'strokeDangerSoft', + 'strokeDangerSubtle', + 'strokeDangerOnloud', + 'strokeWarningLoud', + 'strokeWarningSoft', + 'strokeWarningSubtle', + 'strokeWarningOnloud', + 'strokeSuccessLoud', + 'strokeSuccessSoft', + 'strokeSuccessSubtle', + 'strokeSuccessOnloud', + 'foregroundNeutralPrimary', + 'foregroundNeutralSecondary', + 'foregroundNeutralTertiary', + 'foregroundNeutralOnloud', + 'foregroundBrandPrimary', + 'foregroundBrandOnloud', + 'foregroundDangerPrimary', + 'foregroundDangerOnloud', + 'foregroundWarningPrimary', + 'foregroundWarningOnloud', + 'foregroundSuccessPrimary', + 'foregroundSuccessOnloud', +] as const satisfies readonly InteractiveColorToken[]; + +/** Default interaction lightness and alpha shifts for each state and mode. */ +export const INTERACTION_COLOR_DELTAS = { + hover: { + light: { lightness: -0.03, alpha: 0.04 }, + dark: { lightness: 0.03, alpha: 0.03 }, + }, + pressed: { + light: { lightness: -0.06, alpha: 0.08 }, + dark: { lightness: 0.06, alpha: 0.06 }, + }, +} as const; + +/** Reference surfaces used to solve translucent Bebop Warm source colors. */ +export const BEBOP_WARM_REFERENCE_BACKDROP = { + light: '#ffffff', + dark: '#292929', +} as const; + +/** + * Return whether a semantic interaction token shifts opposite the standard + * lightness direction. + */ +export function isInverseInteractionToken(token: InteractiveColorToken): boolean { + return /heavy|loud|onloud/i.test(token); +} + +function interactionCurveMultiplier(lightness: number): number { + return clamp((0.5 - lightness) / 0.1, 1, 3); +} + +function defaultInteractionColor(color: RgbaColor, token: InteractiveColorToken, mode: ColorMode, state: InteractionColorState): string { + const oklch = rgbToOklch(color); + const delta = INTERACTION_COLOR_DELTAS[state][mode]; + const inverse = isInverseInteractionToken(token); + const rgb = oklchToRgb({ + ...oklch, + L: clamp(oklch.L + (inverse ? -1 : 1) * delta.lightness * interactionCurveMultiplier(oklch.L)), + }); + return rgbaToHex({ + ...rgb, + a: inverse ? color.a : clamp(color.a + delta.alpha), + }); +} + +function compositeChannel(source: number, backdrop: number, alpha: number): number { + return Math.round((source * alpha + backdrop * (255 - alpha)) / 255); +} + +function solveSourceChannel(target: number, backdrop: number, alpha: number): number { + let best = 0; + let bestError = Number.POSITIVE_INFINITY; + let bestDistance = Number.POSITIVE_INFINITY; + const preferred = (target * 255 - backdrop * (255 - alpha)) / alpha; + + for (let source = 0; source <= 255; source += 1) { + const error = Math.abs(compositeChannel(source, backdrop, alpha) - target); + const distance = Math.abs(source - preferred); + if (error < bestError || (error === bestError && distance < bestDistance)) { + best = source; + bestError = error; + bestDistance = distance; + } + } + return best; +} + +function warmInteractionRgb(color: RgbColor, alpha: number, mode: ColorMode, state: InteractionColorState, inverse: boolean): RgbColor { + const { L, C, h } = rgbToOklch(color); + const delta = INTERACTION_COLOR_DELTAS[state][mode].lightness; + const opacityFactor = clamp(alpha + 0.35, 0.35, 1); + const chromaDelta = state === 'pressed' ? Math.min(Math.max(0.006, C * 0.08), 0.012) : Math.min(Math.max(0.003, C * 0.04), 0.006); + let nextLightness = inverse + ? L - delta * interactionCurveMultiplier(L) * opacityFactor + : L + delta * interactionCurveMultiplier(L) * opacityFactor; + + if (!inverse && mode === 'light') { + nextLightness = Math.max(nextLightness, state === 'pressed' ? 0.08 : 0.1); + } + if (!inverse && mode === 'dark') { + nextLightness = Math.min(nextLightness, state === 'pressed' ? 0.992 : 0.985); + } + return oklchToRgb({ L: clamp(nextLightness), C: C + chromaDelta, h }); +} + +function isTranslucentSurfaceToken(token: InteractiveColorToken, alpha: number): boolean { + return (token.startsWith('background') || token.startsWith('stroke')) && alpha > 1e-9 && alpha < 1 - 1e-9; +} + +function bebopWarmInteractionColor(color: RgbaColor, token: InteractiveColorToken, mode: ColorMode, state: InteractionColorState): string { + const inverse = isInverseInteractionToken(token); + const alpha = inverse ? color.a : clamp(color.a + INTERACTION_COLOR_DELTAS[state][mode].alpha); + + if (isTranslucentSurfaceToken(token, color.a)) { + const source = [channelToByte(color.r), channelToByte(color.g), channelToByte(color.b)]; + const backdrop = mode === 'light' ? [255, 255, 255] : [41, 41, 41]; + const sourceAlpha = channelToByte(color.a); + const targetAlpha = channelToByte(alpha); + const apparent = source.map((channel, index) => compositeChannel(channel, backdrop[index], sourceAlpha)); + const shifted = warmInteractionRgb({ r: apparent[0] / 255, g: apparent[1] / 255, b: apparent[2] / 255 }, color.a, mode, state, inverse); + const target = [channelToByte(shifted.r), channelToByte(shifted.g), channelToByte(shifted.b)]; + const solved = target.map((channel, index) => solveSourceChannel(channel, backdrop[index], targetAlpha)); + return rgbaToHex({ r: solved[0] / 255, g: solved[1] / 255, b: solved[2] / 255, a: targetAlpha / 255 }); + } + + return rgbaToHex({ + ...warmInteractionRgb(color, color.a, mode, state, inverse), + a: alpha, + }); +} + +/** + * Generate one interaction-state color with either the default or Bebop Warm + * OKLCH algorithm. + */ +export function getInteractionColor( + colorValue: ColorValue, + token: InteractiveColorToken, + mode: ColorMode, + state: InteractionColorState, + options: InteractionColorOptions = {}, +): InteractionColorResult { + const parsed = parseColorValue(colorValue); + if (parsed.status === 'unresolvable') { + return { + status: 'unresolvable', + value: colorValue, + diagnostic: { + ...parsed.diagnostic, + token, + state, + }, + }; + } + + return { + status: 'derived', + value: + options.variant === 'bebopWarm' + ? bebopWarmInteractionColor(parsed.color, token, mode, state) + : defaultInteractionColor(parsed.color, token, mode, state), + }; +} + +/** Generate a hover color from a semantic token's rest value. */ +export function getHoverColor( + colorValue: ColorValue, + token: InteractiveColorToken, + mode: ColorMode, + options?: InteractionColorOptions, +): InteractionColorResult { + return getInteractionColor(colorValue, token, mode, 'hover', options); +} + +/** Generate a pressed color from a semantic token's rest value. */ +export function getPressColor( + colorValue: ColorValue, + token: InteractiveColorToken, + mode: ColorMode, + options?: InteractionColorOptions, +): InteractionColorResult { + return getInteractionColor(colorValue, token, mode, 'pressed', options); +} + +/** + * Generate every interactive token override for one state and collect + * diagnostics for colors that JavaScript cannot resolve. + */ +export function getInteractionColorOverrides( + colors: SemanticColorTokenValues, + mode: ColorMode, + state: InteractionColorState, + options?: InteractionColorOptions, +): InteractionColorOverridesResult { + const overrides: InteractiveColorOverrides = {}; + const diagnostics: InteractionColorDiagnostic[] = []; + + for (const token of INTERACTIVE_COLOR_TOKENS) { + const result = getInteractionColor(colors[token], token, mode, state, options); + if (result.status === 'derived') { + overrides[token] = result.value; + } else { + diagnostics.push(result.diagnostic); + } + } + + return { overrides, diagnostics }; +} + +/** + * Fill missing hover and pressed colors without replacing authored values. + * Callers should not apply this fallback to high-contrast themes. + */ +export function getInteractionColors( + colors: InteractionColorInput, + mode: ColorMode, + options?: InteractionColorOptions, +): InteractionColorsResult { + const hover: InteractiveColorOverrides = { ...colors.hover }; + const pressed: InteractiveColorOverrides = { ...colors.pressed }; + const diagnostics: InteractionColorDiagnostic[] = []; + let changed = colors.hover === undefined || colors.pressed === undefined; + + for (const state of ['hover', 'pressed'] as const) { + const overrides = state === 'hover' ? hover : pressed; + for (const token of INTERACTIVE_COLOR_TOKENS) { + if (overrides[token] !== undefined) { + continue; + } + const result = getInteractionColor(colors[token], token, mode, state, options); + if (result.status === 'derived') { + overrides[token] = result.value; + changed = true; + } else { + diagnostics.push(result.diagnostic); + } + } + } + + return { + colors: changed ? { ...colors, hover, pressed } : (colors as SemanticColors), + diagnostics, + }; +} diff --git a/packages/agentic/design/src/color-lib/math.ts b/packages/agentic/design/src/color-lib/math.ts new file mode 100644 index 0000000000..8034f7e07e --- /dev/null +++ b/packages/agentic/design/src/color-lib/math.ts @@ -0,0 +1,7 @@ +export function clamp(value: number, minimum = 0, maximum = 1): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +export function channelToByte(value: number): number { + return Math.round(clamp(value) * 255); +} diff --git a/packages/agentic/design/src/color-lib/parsing.ts b/packages/agentic/design/src/color-lib/parsing.ts new file mode 100644 index 0000000000..fb171348f0 --- /dev/null +++ b/packages/agentic/design/src/color-lib/parsing.ts @@ -0,0 +1,254 @@ +import type { ColorValue } from 'react-native'; + +import { channelToByte } from './math'; +import type { ParsedColorValue, RgbaColor } from './types'; + +const HEX_COLOR = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i; + +interface ParsedChannel { + readonly value: number; + readonly nextIndex: number; +} + +function parseHexColor(value: string): RgbaColor | undefined { + const match = HEX_COLOR.exec(value); + if (!match) { + return undefined; + } + + let hex = match[1]; + if (hex.length === 3 || hex.length === 4) { + hex = [...hex].map((channel) => `${channel}${channel}`).join(''); + } + + return { + r: Number.parseInt(hex.slice(0, 2), 16) / 255, + g: Number.parseInt(hex.slice(2, 4), 16) / 255, + b: Number.parseInt(hex.slice(4, 6), 16) / 255, + a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, + }; +} + +function isWhitespace(characterCode: number): boolean { + return characterCode === 0x20 || (characterCode >= 0x09 && characterCode <= 0x0d); +} + +function skipWhitespace(value: string, index: number, endIndex: number): number { + while (index < endIndex && isWhitespace(value.charCodeAt(index))) { + index += 1; + } + return index; +} + +function parseNumericChannel( + value: string, + index: number, + endIndex: number, + numericMaximum: number, + percentageMaximum: number, +): ParsedChannel | undefined { + const startIndex = index; + let digits = 0; + + while (index < endIndex) { + const digit = value.charCodeAt(index) - 0x30; + if (digit < 0 || digit > 9) { + break; + } + digits += 1; + index += 1; + } + + if (index < endIndex && value.charCodeAt(index) === 0x2e) { + index += 1; + while (index < endIndex) { + const digit = value.charCodeAt(index) - 0x30; + if (digit < 0 || digit > 9) { + break; + } + digits += 1; + index += 1; + } + } + + if (digits === 0) { + return undefined; + } + + const numericEndIndex = index; + const percentage = index < endIndex && value.charCodeAt(index) === 0x25; + if (percentage) { + index += 1; + } + const maximum = percentage ? percentageMaximum : numericMaximum; + const parsed = Number.parseFloat(value.slice(startIndex, numericEndIndex)); + if (!Number.isFinite(parsed) || parsed > maximum) { + return undefined; + } + + return { + value: parsed / maximum, + nextIndex: index, + }; +} + +function parseRgbChannel(value: string, index: number, endIndex: number): ParsedChannel | undefined { + return parseNumericChannel(value, index, endIndex, 255, 100); +} + +function parseAlphaChannel(value: string, index: number, endIndex: number): ParsedChannel | undefined { + return parseNumericChannel(value, index, endIndex, 1, 100); +} + +function parseCommaRgbColor(value: string, index: number, endIndex: number, red: number, alphaRequired: boolean): RgbaColor | undefined { + index = skipWhitespace(value, index + 1, endIndex); + const green = parseRgbChannel(value, index, endIndex); + if (!green) { + return undefined; + } + + index = skipWhitespace(value, green.nextIndex, endIndex); + if (value.charCodeAt(index) !== 0x2c) { + return undefined; + } + + index = skipWhitespace(value, index + 1, endIndex); + const blue = parseRgbChannel(value, index, endIndex); + if (!blue) { + return undefined; + } + + index = skipWhitespace(value, blue.nextIndex, endIndex); + let alpha = 1; + if (index < endIndex && (value.charCodeAt(index) === 0x2c || value.charCodeAt(index) === 0x2f)) { + index = skipWhitespace(value, index + 1, endIndex); + const parsedAlpha = parseAlphaChannel(value, index, endIndex); + if (!parsedAlpha) { + return undefined; + } + alpha = parsedAlpha.value; + index = skipWhitespace(value, parsedAlpha.nextIndex, endIndex); + } else if (alphaRequired) { + return undefined; + } + + return index === endIndex ? { r: red, g: green.value, b: blue.value, a: alpha } : undefined; +} + +function parseSpaceRgbColor(value: string, index: number, endIndex: number, red: number, alphaRequired: boolean): RgbaColor | undefined { + const green = parseRgbChannel(value, index, endIndex); + if (!green) { + return undefined; + } + + index = skipWhitespace(value, green.nextIndex, endIndex); + if (index === green.nextIndex) { + return undefined; + } + + const blue = parseRgbChannel(value, index, endIndex); + if (!blue) { + return undefined; + } + + index = skipWhitespace(value, blue.nextIndex, endIndex); + let alpha = 1; + if (index < endIndex && value.charCodeAt(index) === 0x2f) { + index = skipWhitespace(value, index + 1, endIndex); + const parsedAlpha = parseAlphaChannel(value, index, endIndex); + if (!parsedAlpha) { + return undefined; + } + alpha = parsedAlpha.value; + index = skipWhitespace(value, parsedAlpha.nextIndex, endIndex); + } else if (alphaRequired) { + return undefined; + } + + return index === endIndex ? { r: red, g: green.value, b: blue.value, a: alpha } : undefined; +} + +function parseRgbColor(value: string): RgbaColor | undefined { + if (value.charCodeAt(value.length - 1) !== 0x29) { + return undefined; + } + + let index: number; + let alphaRequired: boolean; + if (value.length >= 5 && value.charCodeAt(3) === 0x28 && value.slice(0, 3).toLowerCase() === 'rgb') { + index = 4; + alphaRequired = false; + } else if (value.length >= 6 && value.charCodeAt(4) === 0x28 && value.slice(0, 4).toLowerCase() === 'rgba') { + index = 5; + alphaRequired = true; + } else { + return undefined; + } + + const endIndex = value.length - 1; + index = skipWhitespace(value, index, endIndex); + const red = parseRgbChannel(value, index, endIndex); + if (!red) { + return undefined; + } + + const separatorIndex = red.nextIndex; + index = skipWhitespace(value, separatorIndex, endIndex); + if (value.charCodeAt(index) === 0x2c) { + return parseCommaRgbColor(value, index, endIndex, red.value, alphaRequired); + } + if (index === separatorIndex) { + return undefined; + } + return parseSpaceRgbColor(value, index, endIndex, red.value, alphaRequired); +} + +/** + * Parse a React Native color value when it is a hex, `rgb()`, or `rgba()` + * literal. Native and dynamic color objects return a structured diagnostic. + */ +export function parseColorValue(value: ColorValue): ParsedColorValue { + if (typeof value !== 'string') { + return { + status: 'unresolvable', + diagnostic: { + reason: 'non-literal-color', + value, + message: 'Native and dynamic color objects cannot be resolved in JavaScript.', + }, + }; + } + + const normalized = value.trim(); + const color = parseHexColor(normalized) ?? parseRgbColor(normalized); + return color + ? { status: 'resolved', color } + : { + status: 'unresolvable', + diagnostic: { + reason: 'unsupported-color-format', + value, + message: `Unsupported color literal "${value}". Use hex, rgb(), or rgba().`, + }, + }; +} + +/** + * Format normalized RGBA channels as `#rrggbb` or `#rrggbbaa` when alpha is + * translucent. + */ +export function rgbaToHex(color: RgbaColor): string { + const toHex = (value: number) => channelToByte(value).toString(16).padStart(2, '0'); + const rgb = `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; + const alpha = toHex(color.a); + return alpha === 'ff' ? rgb : `${rgb}${alpha}`; +} + +/** @internal Resolve a literal color or throw a descriptive operation error. */ +export function requireLiteralColor(value: ColorValue, operation: string): RgbaColor { + const result = parseColorValue(value); + if (result.status === 'unresolvable') { + throw new TypeError(`${operation}: ${result.diagnostic.message}`); + } + return result.color; +} diff --git a/packages/agentic/design/src/color-lib/types.ts b/packages/agentic/design/src/color-lib/types.ts new file mode 100644 index 0000000000..74845db0be --- /dev/null +++ b/packages/agentic/design/src/color-lib/types.ts @@ -0,0 +1,88 @@ +import type { ColorValue } from 'react-native'; + +import type { ThemeColorScheme } from '../theming/appearance.types'; +import type { InteractiveColorOverrides, SemanticColors, SemanticColorTokenValues } from '../tokens/flex.types'; + +export type ColorDiagnosticReason = 'non-literal-color' | 'unsupported-color-format'; + +export interface ColorDiagnostic { + readonly reason: ColorDiagnosticReason; + readonly value: ColorValue; + readonly message: string; +} + +/** Normalized red, green, and blue channels in the range 0 through 1. */ +export interface RgbColor { + readonly r: number; + readonly g: number; + readonly b: number; +} + +/** Normalized red, green, blue, and alpha channels in the range 0 through 1. */ +export interface RgbaColor extends RgbColor { + readonly a: number; +} + +/** An OKLCH color with normalized lightness, chroma, and hue in degrees. */ +export interface OklchColor { + readonly L: number; + readonly C: number; + readonly h: number; +} + +export type ParsedColorValue = + | { + readonly status: 'resolved'; + readonly color: RgbaColor; + } + | { + readonly status: 'unresolvable'; + readonly diagnostic: ColorDiagnostic; + }; + +export interface ResolvedContrastColors { + readonly ratio: number; + readonly foregroundResolved: string; + readonly backgroundResolved: string; +} + +export type InteractionColorState = 'hover' | 'pressed'; +export type InteractionColorVariant = 'default' | 'bebopWarm'; +export type InteractiveColorToken = keyof InteractiveColorOverrides; + +export interface InteractionColorOptions { + readonly variant?: InteractionColorVariant; +} + +export interface InteractionColorDiagnostic extends ColorDiagnostic { + readonly token: InteractiveColorToken; + readonly state: InteractionColorState; +} + +export type InteractionColorResult = + | { + readonly status: 'derived'; + readonly value: string; + } + | { + readonly status: 'unresolvable'; + readonly value: ColorValue; + readonly diagnostic: InteractionColorDiagnostic; + }; + +export interface InteractionColorOverridesResult { + readonly overrides: InteractiveColorOverrides; + readonly diagnostics: readonly InteractionColorDiagnostic[]; +} + +export type InteractionColorInput = SemanticColorTokenValues & { + readonly hover?: InteractiveColorOverrides; + readonly pressed?: InteractiveColorOverrides; +}; + +export interface InteractionColorsResult { + readonly colors: SemanticColors; + readonly diagnostics: readonly InteractionColorDiagnostic[]; +} + +export type ColorMode = ThemeColorScheme; diff --git a/packages/agentic/design/src/testing/README.md b/packages/agentic/design/src/testing/README.md new file mode 100644 index 0000000000..f8e8080223 --- /dev/null +++ b/packages/agentic/design/src/testing/README.md @@ -0,0 +1,26 @@ +# Testing helpers + +`@fluentui-react-native/design/testing` is the test-only entry point for +fixtures and validation helpers associated with the design package. It exports: + +- the generated `defaultFlexTokens` fixture and default resolved appearance; +- `mockTheme` for tests that need a legacy-compatible theme; +- WCAG thresholds and canonical semantic color pairings; +- structured contrast audits and filters for failed or unresolvable pairs. + +The color validation helpers build on `color-lib`, but are not designed for +component render paths or other production runtime code. Production consumers +that need parsing, conversion, interaction generation, compositing, or a direct +contrast ratio should import `@fluentui-react-native/design/color-lib`. +Validation code belongs under `testing` and is intentionally omitted from +production bundle-size scenarios. + +```ts +import { defaultFlexTokens, getContrastFailures, validateContrastPairs } from '@fluentui-react-native/design/testing'; + +const failures = getContrastFailures(validateContrastPairs(defaultFlexTokens.color, 'light')); +``` + +The package's internal validation suite audits rest, hover, and pressed values +for both default and Bebop Warm interaction variants. Its pinned conformance +fixtures are implementation evidence rather than public testing exports. diff --git a/packages/agentic/design/src/testing/color-validation/constants.ts b/packages/agentic/design/src/testing/color-validation/constants.ts new file mode 100644 index 0000000000..aa4a2ec835 --- /dev/null +++ b/packages/agentic/design/src/testing/color-validation/constants.ts @@ -0,0 +1,14 @@ +/** Test fallback surfaces used to resolve translucent validation pairs. */ +export const MODE_SURFACE = { + light: '#ffffff', + dark: '#000000', +} as const; + +/** WCAG 2.1 contrast thresholds used by the canonical color pairings. */ +export const WCAG = { + aaText: 4.5, + aaLargeText: 3, + aaaText: 7, + aaaLargeText: 4.5, + visibleStroke: 3, +} as const; diff --git a/packages/agentic/design/src/testing/color-validation/pairings.ts b/packages/agentic/design/src/testing/color-validation/pairings.ts new file mode 100644 index 0000000000..053d26f32d --- /dev/null +++ b/packages/agentic/design/src/testing/color-validation/pairings.ts @@ -0,0 +1,133 @@ +import { WCAG } from './constants'; +import type { ContrastColorToken, ContrastPairing, ContrastTokenReference } from './types'; + +function upstreamName(token: ContrastColorToken): `--gnrc-color-${string}` { + return `--gnrc-color-${token.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`)}`; +} + +function token(tokenName: ContrastColorToken): ContrastTokenReference { + return { + token: tokenName, + upstream: upstreamName(tokenName), + }; +} + +/** + * Test-only canonical x3 contrast pairings adapted to FURN token names, plus the + * issue-required FURN visible-stroke pairing. + * + * Source: x3-design/fluent-design@d334acf5cbad813f2b7cd554da942b09a7ff8f10. + */ +export const PAIRINGS = [ + { + label: 'neutral-primary on neutral surfaces', + source: 'x3', + foreground: token('foregroundNeutralPrimary'), + backgrounds: [ + token('surfaceNeutralNearer'), + token('surfaceNeutralNear'), + token('backgroundNeutralSubtle'), + token('backgroundNeutralSoft'), + ], + minimumRatio: WCAG.aaText, + }, + { + label: 'neutral-secondary on neutral surfaces', + source: 'x3', + foreground: token('foregroundNeutralSecondary'), + backgrounds: [token('surfaceNeutralNearer'), token('backgroundNeutralSubtle')], + minimumRatio: WCAG.aaText, + }, + { + label: 'neutral-tertiary on neutral surfaces', + source: 'x3', + foreground: token('foregroundNeutralTertiary'), + backgrounds: [token('surfaceNeutralNearer'), token('surfaceNeutralNear')], + minimumRatio: WCAG.aaLargeText, + }, + { + label: 'neutral-onloud on neutral loud/heavy', + source: 'x3', + foreground: token('foregroundNeutralOnloud'), + backgrounds: [token('backgroundNeutralLoud'), token('backgroundNeutralHeavy')], + minimumRatio: WCAG.aaLargeText, + }, + { + label: 'brand-onloud on brand loud/heavy', + source: 'x3', + foreground: token('foregroundBrandOnloud'), + backgrounds: [token('backgroundBrandLoud'), token('backgroundBrandHeavy')], + minimumRatio: WCAG.aaLargeText, + }, + { + label: 'danger-onloud on danger-loud', + source: 'x3', + foreground: token('foregroundDangerOnloud'), + backgrounds: [token('backgroundDangerLoud')], + minimumRatio: WCAG.aaLargeText, + }, + { + label: 'warning-onloud on warning-loud', + source: 'x3', + foreground: token('foregroundWarningOnloud'), + backgrounds: [token('backgroundWarningLoud')], + minimumRatio: WCAG.aaLargeText, + }, + { + label: 'success-onloud on success-loud', + source: 'x3', + foreground: token('foregroundSuccessOnloud'), + backgrounds: [token('backgroundSuccessLoud')], + minimumRatio: WCAG.aaLargeText, + }, + { + label: 'brand-primary on neutral surfaces', + source: 'x3', + foreground: token('foregroundBrandPrimary'), + backgrounds: [token('surfaceNeutralNearer'), token('backgroundNeutralSubtle')], + minimumRatio: WCAG.aaText, + }, + { + label: 'danger-primary on neutral + danger soft/subtle', + source: 'x3', + foreground: token('foregroundDangerPrimary'), + backgrounds: [ + token('surfaceNeutralNearer'), + token('backgroundNeutralSubtle'), + token('backgroundDangerSubtle'), + token('backgroundDangerSoft'), + ], + minimumRatio: WCAG.aaText, + }, + { + label: 'warning-primary on neutral + warning soft/subtle', + source: 'x3', + foreground: token('foregroundWarningPrimary'), + backgrounds: [ + token('surfaceNeutralNearer'), + token('backgroundNeutralSubtle'), + token('backgroundWarningSubtle'), + token('backgroundWarningSoft'), + ], + minimumRatio: WCAG.aaText, + }, + { + label: 'success-primary on neutral + success soft/subtle', + source: 'x3', + foreground: token('foregroundSuccessPrimary'), + backgrounds: [ + token('surfaceNeutralNearer'), + token('backgroundNeutralSubtle'), + token('backgroundSuccessSubtle'), + token('backgroundSuccessSoft'), + ], + minimumRatio: WCAG.aaText, + }, + { + label: 'neutral-heavy stroke on neutral-soft background', + source: 'furn', + foreground: token('strokeNeutralHeavy'), + backgrounds: [token('backgroundNeutralSoft')], + minimumRatio: WCAG.visibleStroke, + }, +] as const satisfies readonly ContrastPairing[]; diff --git a/packages/agentic/design/src/testing/color-validation/types.ts b/packages/agentic/design/src/testing/color-validation/types.ts new file mode 100644 index 0000000000..794f082ede --- /dev/null +++ b/packages/agentic/design/src/testing/color-validation/types.ts @@ -0,0 +1,51 @@ +import type { ColorValue } from 'react-native'; + +import type { ColorDiagnostic, ColorMode } from '../../color-lib'; +import type { SemanticColorTokenValues } from '../../tokens/flex.types'; + +export type ContrastColorToken = keyof SemanticColorTokenValues; + +export interface ContrastTokenReference { + readonly token: ContrastColorToken; + readonly upstream: `--gnrc-color-${string}`; +} + +export interface ContrastPairing { + readonly label: string; + readonly source: 'x3' | 'furn'; + readonly foreground: ContrastTokenReference; + readonly backgrounds: readonly ContrastTokenReference[]; + readonly minimumRatio: number; +} + +export interface ContrastColorDiagnostic extends ColorDiagnostic { + readonly token: ContrastColorToken; + readonly role: 'foreground' | 'background'; +} + +interface ContrastPairResultBase { + readonly pairing: ContrastPairing; + readonly foreground: ContrastTokenReference; + readonly background: ContrastTokenReference; + readonly foregroundValue: ColorValue; + readonly backgroundValue: ColorValue; + readonly mode: ColorMode; +} + +export type ResolvedContrastPairResult = ContrastPairResultBase & { + readonly status: 'pass' | 'fail'; + readonly ratio: number; + readonly foregroundResolved: string; + readonly backgroundResolved: string; + readonly diagnostics: readonly []; +}; + +export type UnresolvableContrastPairResult = ContrastPairResultBase & { + readonly status: 'unresolvable'; + readonly ratio: null; + readonly foregroundResolved: null; + readonly backgroundResolved: null; + readonly diagnostics: readonly ContrastColorDiagnostic[]; +}; + +export type ContrastPairResult = ResolvedContrastPairResult | UnresolvableContrastPairResult; diff --git a/packages/agentic/design/src/testing/color-validation/validation.test.ts b/packages/agentic/design/src/testing/color-validation/validation.test.ts new file mode 100644 index 0000000000..fe9bca69f7 --- /dev/null +++ b/packages/agentic/design/src/testing/color-validation/validation.test.ts @@ -0,0 +1,157 @@ +import { PlatformColor } from 'react-native'; + +import { getInteractionColorOverrides, INTERACTIVE_COLOR_TOKENS } from '../../color-lib/interaction'; +import type { ColorMode, InteractionColorState, InteractionColorVariant } from '../../color-lib/types'; +import { defaultFlexTokens } from '../../tokens/defaultTokens'; +import type { SemanticColorTokenValues } from '../../tokens/flex.types'; +import { getContrastFailures, getUnresolvableContrastPairs, MODE_SURFACE, PAIRINGS, validateContrastPairs, WCAG } from '../index'; +import { x3BebopWarmInteractionConformance, x3InteractionConformance } from './x3InteractionConformance'; + +function conformanceColors( + mode: ColorMode, + conformance: typeof x3InteractionConformance | typeof x3BebopWarmInteractionConformance = x3InteractionConformance, +): SemanticColorTokenValues { + const restIndex = mode === 'light' ? 0 : 3; + const { hover: _hover, pressed: _pressed, ...colors } = defaultFlexTokens.color; + for (const token of INTERACTIVE_COLOR_TOKENS) { + colors[token] = conformance[token][restIndex]; + } + return colors; +} + +function interactionConformanceColors( + mode: ColorMode, + state: InteractionColorState, + variant: InteractionColorVariant, +): SemanticColorTokenValues { + const colors = conformanceColors(mode, variant === 'bebopWarm' ? x3BebopWarmInteractionConformance : x3InteractionConformance); + return { + ...colors, + ...getInteractionColorOverrides(colors, mode, state, { variant }).overrides, + }; +} + +const expectedPairs = [ + 'foregroundNeutralPrimary/surfaceNeutralNearer', + 'foregroundNeutralPrimary/surfaceNeutralNear', + 'foregroundNeutralPrimary/backgroundNeutralSubtle', + 'foregroundNeutralPrimary/backgroundNeutralSoft', + 'foregroundNeutralSecondary/surfaceNeutralNearer', + 'foregroundNeutralSecondary/backgroundNeutralSubtle', + 'foregroundNeutralTertiary/surfaceNeutralNearer', + 'foregroundNeutralTertiary/surfaceNeutralNear', + 'foregroundNeutralOnloud/backgroundNeutralLoud', + 'foregroundNeutralOnloud/backgroundNeutralHeavy', + 'foregroundBrandOnloud/backgroundBrandLoud', + 'foregroundBrandOnloud/backgroundBrandHeavy', + 'foregroundDangerOnloud/backgroundDangerLoud', + 'foregroundWarningOnloud/backgroundWarningLoud', + 'foregroundSuccessOnloud/backgroundSuccessLoud', + 'foregroundBrandPrimary/surfaceNeutralNearer', + 'foregroundBrandPrimary/backgroundNeutralSubtle', + 'foregroundDangerPrimary/surfaceNeutralNearer', + 'foregroundDangerPrimary/backgroundNeutralSubtle', + 'foregroundDangerPrimary/backgroundDangerSubtle', + 'foregroundDangerPrimary/backgroundDangerSoft', + 'foregroundWarningPrimary/surfaceNeutralNearer', + 'foregroundWarningPrimary/backgroundNeutralSubtle', + 'foregroundWarningPrimary/backgroundWarningSubtle', + 'foregroundWarningPrimary/backgroundWarningSoft', + 'foregroundSuccessPrimary/surfaceNeutralNearer', + 'foregroundSuccessPrimary/backgroundNeutralSubtle', + 'foregroundSuccessPrimary/backgroundSuccessSubtle', + 'foregroundSuccessPrimary/backgroundSuccessSoft', + 'strokeNeutralHeavy/backgroundNeutralSoft', +] as const; + +describe('color validation constants', () => { + it('matches the upstream values', () => { + expect(MODE_SURFACE).toEqual({ light: '#ffffff', dark: '#000000' }); + expect(WCAG).toEqual({ + aaText: 4.5, + aaLargeText: 3, + aaaText: 7, + aaaLargeText: 4.5, + visibleStroke: 3, + }); + }); + + it('records source names for every pairing token', () => { + for (const pairing of PAIRINGS) { + expect(pairing.foreground.upstream).toMatch(/^--gnrc-color-/); + for (const background of pairing.backgrounds) { + expect(background.upstream).toMatch(/^--gnrc-color-/); + } + } + }); +}); + +describe.each(['light', 'dark'] as const)('%s contrast validation', (mode) => { + it('passes every canonical rest pairing', () => { + const results = validateContrastPairs(conformanceColors(mode), mode); + + expect(results.map((result) => `${result.foreground.token}/${result.background.token}`)).toEqual(expectedPairs); + expect(getContrastFailures(results)).toEqual([]); + expect(getUnresolvableContrastPairs(results)).toEqual([]); + }); + + it.each(['default', 'bebopWarm'] as const)('audits %s interaction values', (variant) => { + for (const state of ['hover', 'pressed'] as const) { + const failures = getContrastFailures(validateContrastPairs(interactionConformanceColors(mode, state, variant), mode)).map( + (result) => `${result.foreground.token}/${result.background.token}`, + ); + const expectedFailures = + mode === 'dark' && variant === 'bebopWarm' && state === 'pressed' + ? [ + 'foregroundDangerPrimary/surfaceNeutralNearer', + 'foregroundDangerPrimary/backgroundDangerSubtle', + 'foregroundDangerPrimary/backgroundDangerSoft', + 'foregroundWarningPrimary/backgroundWarningSoft', + ] + : mode === 'dark' && state === 'pressed' + ? ['foregroundDangerPrimary/backgroundDangerSoft', 'foregroundWarningPrimary/backgroundWarningSoft'] + : []; + + expect(failures).toEqual(expectedFailures); + } + }); +}); + +describe('unresolvable color validation', () => { + it('identifies native foreground colors', () => { + const colors = conformanceColors('light'); + colors.foregroundNeutralPrimary = PlatformColor('labelColor'); + const results = getUnresolvableContrastPairs(validateContrastPairs(colors, 'light')).filter( + (result) => result.foreground.token === 'foregroundNeutralPrimary', + ); + + expect(results).toHaveLength(4); + expect(results[0].diagnostics).toEqual([ + expect.objectContaining({ + reason: 'non-literal-color', + role: 'foreground', + token: 'foregroundNeutralPrimary', + }), + ]); + }); + + it('identifies native background colors', () => { + const colors = conformanceColors('dark'); + colors.backgroundSuccessSoft = PlatformColor('windowBackgroundColor'); + const result = getUnresolvableContrastPairs(validateContrastPairs(colors, 'dark')).find( + (pair) => pair.background.token === 'backgroundSuccessSoft', + ); + + expect(result).toMatchObject({ + status: 'unresolvable', + ratio: null, + diagnostics: [ + { + reason: 'non-literal-color', + role: 'background', + token: 'backgroundSuccessSoft', + }, + ], + }); + }); +}); diff --git a/packages/agentic/design/src/testing/color-validation/validation.ts b/packages/agentic/design/src/testing/color-validation/validation.ts new file mode 100644 index 0000000000..b33cd1df6a --- /dev/null +++ b/packages/agentic/design/src/testing/color-validation/validation.ts @@ -0,0 +1,94 @@ +import { parseColorValue, resolveContrastColors } from '../../color-lib'; +import type { ColorDiagnostic, ColorMode } from '../../color-lib'; +import type { SemanticColorTokenValues } from '../../tokens/flex.types'; +import { MODE_SURFACE } from './constants'; +import { PAIRINGS } from './pairings'; +import type { + ContrastColorDiagnostic, + ContrastColorToken, + ContrastPairing, + ContrastPairResult, + ResolvedContrastPairResult, + UnresolvableContrastPairResult, +} from './types'; + +function diagnosticFor( + diagnostic: ColorDiagnostic, + tokenName: ContrastColorToken, + role: ContrastColorDiagnostic['role'], +): ContrastColorDiagnostic { + return { + ...diagnostic, + token: tokenName, + role, + }; +} + +/** + * Audit semantic colors against canonical or caller-supplied contrast + * pairings. This helper is intended for tests and design validation tooling, + * not component render paths. + */ +export function validateContrastPairs( + colors: SemanticColorTokenValues, + mode: ColorMode, + pairings: readonly ContrastPairing[] = PAIRINGS, +): ContrastPairResult[] { + const results: ContrastPairResult[] = []; + + for (const pairing of pairings) { + const foregroundValue = colors[pairing.foreground.token]; + const foreground = parseColorValue(foregroundValue); + for (const backgroundReference of pairing.backgrounds) { + const backgroundValue = colors[backgroundReference.token]; + const background = parseColorValue(backgroundValue); + const base = { + pairing, + foreground: pairing.foreground, + background: backgroundReference, + foregroundValue, + backgroundValue, + mode, + }; + const diagnostics: ContrastColorDiagnostic[] = []; + if (foreground.status === 'unresolvable') { + diagnostics.push(diagnosticFor(foreground.diagnostic, pairing.foreground.token, 'foreground')); + } + if (background.status === 'unresolvable') { + diagnostics.push(diagnosticFor(background.diagnostic, backgroundReference.token, 'background')); + } + + if (diagnostics.length > 0 || foreground.status === 'unresolvable' || background.status === 'unresolvable') { + results.push({ + ...base, + status: 'unresolvable', + ratio: null, + foregroundResolved: null, + backgroundResolved: null, + diagnostics, + }); + continue; + } + + const resolved = resolveContrastColors(foregroundValue, backgroundValue, MODE_SURFACE[mode]); + results.push({ + ...base, + status: resolved.ratio >= pairing.minimumRatio ? 'pass' : 'fail', + diagnostics: [], + ...resolved, + }); + } + } + + return results; +} + +/** Return only resolved contrast pairs that do not meet their threshold. */ +export function getContrastFailures(results: readonly ContrastPairResult[]): ResolvedContrastPairResult[] { + return results.filter((result): result is ResolvedContrastPairResult => result.status === 'fail'); +} + +/** Return only contrast pairs whose native or unsupported colors cannot be audited in JavaScript. */ +export function getUnresolvableContrastPairs(results: readonly ContrastPairResult[]): UnresolvableContrastPairResult[] { + return results.filter((result): result is UnresolvableContrastPairResult => result.status === 'unresolvable'); +} diff --git a/packages/agentic/design/src/testing/color-validation/x3InteractionConformance.ts b/packages/agentic/design/src/testing/color-validation/x3InteractionConformance.ts new file mode 100644 index 0000000000..71cb11f9ce --- /dev/null +++ b/packages/agentic/design/src/testing/color-validation/x3InteractionConformance.ts @@ -0,0 +1,198 @@ +import type { InteractiveColorToken } from '../../color-lib'; + +export type InteractionConformanceVector = readonly [ + lightRest: string, + lightHover: string, + lightPressed: string, + darkRest: string, + darkHover: string, + darkPressed: string, +]; + +/** + * Test fixture with base and interaction fallback values from the pinned x3 source commit. + * Each row covers hover and pressed in both light and dark modes. + */ +export const x3InteractionConformance = { + surfaceNeutralFarther: ['#ebebeb', '#e1e1e1', '#d7d7d7', '#1b1b1b', '#2f2f2f', '#454545'], + surfaceNeutralFar: ['#f2f2f2', '#e8e8e8', '#dedede', '#1f1f1f', '#323232', '#464646'], + surfaceNeutralNear: ['#fcfcfc', '#f2f2f2', '#e8e8e8', '#181818', '#2d2d2d', '#434343'], + surfaceNeutralNearer: ['#ffffff', '#f5f5f5', '#ebebeb', '#292929', '#3a3a3a', '#4b4b4b'], + surfaceNeutralTranslucent: ['#ffffff80', '#f5f5f58a', '#ebebeb94', '#3c3c3c80', '#47474788', '#5353538f'], + backgroundNeutralHeavy: ['#242424', '#363636', '#494949', '#ffffff', '#f5f5f5', '#ebebeb'], + backgroundNeutralLoud: ['#6f6f6f', '#787878', '#818181', '#929292', '#898989', '#808080'], + backgroundNeutralSoft: ['#00000014', '#0000001e', '#00000028', '#ffffff1f', '#ffffff27', '#ffffff2e'], + backgroundNeutralSubtle: ['#0000000d', '#00000017', '#00000021', '#ffffff14', '#ffffff1c', '#ffffff23'], + backgroundNeutralTransparent: ['#24242400', '#1313130a', '#05050514', '#ffffff00', '#ffffff08', '#ffffff0f'], + backgroundNeutralTranslucent: ['#e9e9e980', '#d8d8d88a', '#cacaca94', '#3c3c3c80', '#56565688', '#6d6d6d8f'], + backgroundBrandHeavy: ['#242424', '#363636', '#494949', '#ffffff', '#f5f5f5', '#ebebeb'], + backgroundBrandLoud: ['#6f6f6f', '#787878', '#818181', '#929292', '#898989', '#808080'], + backgroundBrandSoft: ['#00000014', '#0000001e', '#00000028', '#ffffff1f', '#ffffff27', '#ffffff2e'], + backgroundBrandSubtle: ['#0000000a', '#00000014', '#0000001e', '#ffffff14', '#ffffff1c', '#ffffff23'], + backgroundBrandTransparent: ['#24242400', '#1313130a', '#05050514', '#ffffff00', '#ffffff08', '#ffffff0f'], + backgroundDangerLoud: ['#c02e56', '#cb395e', '#d54467', '#e55e7a', '#da5471', '#d04b69'], + backgroundDangerSoft: ['#ffe3e6', '#f5d9dc', '#ebcfd2', '#5a2932', '#67353e', '#754149'], + backgroundDangerSubtle: ['#fff1f3', '#f5e7e9', '#ebdddf', '#402529', '#503438', '#614448'], + backgroundWarningLoud: ['#bb4001', '#c54a14', '#d05321', '#e56738', '#da5d2e', '#d05422'], + backgroundWarningSoft: ['#ffe5dc', '#f5dbd2', '#ebd1c9', '#592c1c', '#663828', '#744434'], + backgroundWarningSubtle: ['#fff2ee', '#f5e8e4', '#ebdeda', '#40261d', '#51352c', '#62453b'], + backgroundSuccessLoud: ['#008455', '#188d5d', '#279666', '#05ad72', '#00a369', '#009a60'], + backgroundSuccessSoft: ['#c9f8dd', '#bfeed3', '#b6e4ca', '#0c462d', '#1c5238', '#295f44'], + backgroundSuccessSubtle: ['#e3fcee', '#d9f2e4', '#cfe8da', '#193426', '#294435', '#385445'], + strokeNeutralHeavy: ['#242424', '#363636', '#494949', '#dedede', '#d4d4d4', '#cbcbcb'], + strokeNeutralLoud: ['#6f6f6f', '#787878', '#818181', '#929292', '#898989', '#808080'], + strokeNeutralSoft: ['#0000006d', '#02020277', '#04040481', '#ffffff61', '#ffffff69', '#ffffff70'], + strokeNeutralSubtle: ['#00000021', '#0000002b', '#05050535', '#ffffff48', '#ffffff50', '#ffffff57'], + strokeNeutralTransparent: ['#24242400', '#1313130a', '#05050514', '#ffffff00', '#ffffff08', '#ffffff0f'], + strokeNeutralOnloud: ['#ffffffff', '#ffffff', '#ffffff', '#000000db', '#000000db', '#000000db'], + strokeBrandLoud: ['#6f6f6f', '#787878', '#818181', '#929292', '#898989', '#808080'], + strokeBrandSoft: ['#0000006d', '#02020277', '#04040481', '#ffffff61', '#ffffff69', '#ffffff70'], + strokeBrandSubtle: ['#00000021', '#0000002b', '#05050535', '#ffffff48', '#ffffff50', '#ffffff57'], + strokeBrandOnloud: ['#ffffff', '#ffffff', '#ffffff', '#242424', '#131313', '#050505'], + strokeDangerLoud: ['#c02e56', '#cb395e', '#d54467', '#e55e7a', '#da5471', '#d04b69'], + strokeDangerSoft: ['#e55e7a', '#da5471', '#d04b69', '#d14164', '#dc4b6d', '#e65575'], + strokeDangerSubtle: ['#ffd0d6', '#f5c6cc', '#ebbdc3', '#76293a', '#803242', '#893a4a'], + strokeDangerOnloud: ['#ffffff', '#ffffff', '#ffffff', '#242424', '#131313', '#050505'], + strokeWarningLoud: ['#cd4808', '#d8521a', '#e25c27', '#e56738', '#da5d2e', '#d05422'], + strokeWarningSoft: ['#fc9571', '#f28c68', '#e7825f', '#cd4808', '#d8521a', '#e25c27'], + strokeWarningSubtle: ['#ffd3c4', '#f5c9ba', '#ebc0b1', '#762e12', '#7f371b', '#893f24'], + strokeWarningOnloud: ['#ffffff', '#ffffff', '#ffffff', '#242424', '#131313', '#050505'], + strokeSuccessLoud: ['#008455', '#188d5d', '#279666', '#05ad72', '#00a369', '#009a60'], + strokeSuccessSoft: ['#5dce97', '#52c48e', '#47bb85', '#008e5c', '#1a9765', '#2aa16d'], + strokeSuccessSubtle: ['#b1efcc', '#a7e5c2', '#9edbb9', '#025636', '#125f3e', '#1e6746'], + strokeSuccessOnloud: ['#ffffff', '#ffffff', '#ffffff', '#242424', '#131313', '#050505'], + foregroundNeutralPrimary: ['#000000db', '#000000e5', '#000000ef', '#ffffffde', '#ffffffe6', '#ffffffed'], + foregroundNeutralSecondary: ['#000000a2', '#000000ac', '#000000b6', '#ffffffa1', '#ffffffa9', '#ffffffb0'], + foregroundNeutralTertiary: ['#00000090', '#0000009a', '#000000a4', '#ffffff80', '#ffffff88', '#ffffff8f'], + foregroundNeutralOnloud: ['#ffffffff', '#ffffff', '#ffffff', '#000000db', '#000000db', '#000000db'], + foregroundBrandPrimary: ['#242424', '#131313', '#050505', '#dedede', '#e8e8e8', '#f2f2f2'], + foregroundBrandOnloud: ['#ffffff', '#ffffff', '#ffffff', '#000000db', '#000000db', '#000000db'], + foregroundDangerPrimary: ['#a62147', '#9c133f', '#910037', '#fd8ea1', '#ff97aa', '#ffa1b4'], + foregroundDangerOnloud: ['#ffffff', '#ffffff', '#ffffff', '#000000db', '#000000db', '#000000db'], + foregroundWarningPrimary: ['#a93901', '#9f2f00', '#952500', '#fc9571', '#ff9f7a', '#ffa884'], + foregroundWarningOnloud: ['#ffffff', '#ffffff', '#ffffff', '#000000db', '#000000db', '#000000db'], + foregroundSuccessPrimary: ['#017048', '#006740', '#005f38', '#5dce97', '#67d8a0', '#72e2aa'], + foregroundSuccessOnloud: ['#ffffff', '#ffffff', '#ffffff', '#000000db', '#000000db', '#000000db'], +} as const satisfies Record; + +/** Base and generated interaction values for the pinned Bebop Warm theme. */ +export const x3BebopWarmInteractionConformance = { + surfaceNeutralFarther: ['#f0eae5', '#e8e0d9', '#dfd6ce', '#1e1916', '#342d29', '#4b423d'], + surfaceNeutralFar: ['#faf4f0', '#f2eae4', '#e9dfd9', '#231e1a', '#38312b', '#4d453e'], + surfaceNeutralNear: ['#f6f1ec', '#ede7e0', '#e5ddd4', '#1c1714', '#322b27', '#4b413c'], + surfaceNeutralNearer: ['#ffffff', '#f6f5f3', '#edebe7', '#2e2823', '#403832', '#534941'], + surfaceNeutralTranslucent: ['#ffffff80', '#f7f6f48a', '#f0eeea94', '#00000080', '#02010188', '#0e0b0c8f'], + backgroundNeutralHeavy: ['#28231e', '#3b352e', '#50473f', '#ffffff', '#f6f5f3', '#edebe7'], + backgroundNeutralLoud: ['#1e0d009a', '#2e1a0a9a', '#3f28169a', '#ffeee39a', '#f3dfd29a', '#e6d0c09a'], + backgroundNeutralSoft: ['#4d200028', '#61330f32', '#6a3b193c', '#ffe3cb40', '#fde1c548', '#ffdec44f'], + backgroundNeutralSubtle: ['#6c31001a', '#7f471c24', '#8b53272e', '#fff4ef14', '#ffe8e81c', '#ffdfdf23'], + backgroundNeutralTransparent: ['#28231e00', '#231d170a', '#1e170f14', '#ffffff00', '#fbfaf808', '#fefcf80f'], + backgroundNeutralTranslucent: ['#e9e9e980', '#dcdcd68a', '#d2cfc894', '#3c3c3c80', '#52524e88', '#67665e8f'], + backgroundBrandHeavy: ['#28231e', '#3b352e', '#50473f', '#ffffff', '#f6f5f3', '#edebe7'], + backgroundBrandLoud: ['#1e0d009a', '#2e1a0a9a', '#3f28169a', '#ffeee39a', '#f3dfd29a', '#e6d0c09a'], + backgroundBrandSoft: ['#4d200028', '#61330f32', '#6a3b193c', '#ffe3cb40', '#fde1c548', '#ffdec44f'], + backgroundBrandSubtle: ['#6c31001a', '#7f471c24', '#8b53272e', '#fff4ef14', '#ffe8e81c', '#ffdfdf23'], + backgroundBrandTransparent: ['#28231e00', '#231d170a', '#1e170f14', '#ffffff00', '#fbfaf808', '#fefcf80f'], + backgroundDangerLoud: ['#c02e56', '#cd355e', '#da3c65', '#e55e7a', '#dd5170', '#d54467'], + backgroundDangerSoft: ['#ffe3e6', '#f7d8dc', '#efced1', '#5a2932', '#68343d', '#773f49'], + backgroundDangerSubtle: ['#fff1f3', '#f7e6e9', '#efdcde', '#402529', '#523338', '#644247'], + backgroundWarningLoud: ['#bb4001', '#c84709', '#d54e11', '#e56738', '#dd5b28', '#d54f13'], + backgroundWarningSoft: ['#ffe5dc', '#f7dbd1', '#eed0c6', '#592c1c', '#673726', '#764331'], + backgroundWarningSubtle: ['#fff2ee', '#f7e7e3', '#efddd7', '#40261d', '#52352b', '#644439'], + backgroundSuccessLoud: ['#008455', '#088e5c', '#119864', '#05ad72', '#00a468', '#009c5e'], + backgroundSuccessSoft: ['#c9f8dd', '#bdefd3', '#b2e5c8', '#0c462d', '#185338', '#246043'], + backgroundSuccessSubtle: ['#e3fcee', '#d7f3e3', '#cce9d9', '#193426', '#274435', '#355544'], + strokeNeutralHeavy: ['#28231e', '#3b352e', '#50473f', '#e3dcd7', '#dbd2cb', '#d3c8c0'], + strokeNeutralLoud: ['#776d65', '#81756c', '#8c7e73', '#9a9089', '#92877e', '#8b7d74'], + strokeNeutralSoft: ['#250f0076', '#2a120280', '#2e16048a', '#ffe6d35c', '#ffe6cf64', '#ffe8d06b'], + strokeNeutralSubtle: ['#4d200028', '#61330f32', '#6a3b193c', '#ffe3cb40', '#fde1c548', '#ffdec44f'], + strokeNeutralTransparent: ['#28231e00', '#231d170a', '#1e170f14', '#ffffff00', '#fbfaf808', '#fefcf80f'], + strokeNeutralOnloud: ['#ffffffff', '#fffffd', '#fffffb', '#0b0600e1', '#000000e1', '#000000e1'], + strokeBrandLoud: ['#776d65', '#81756c', '#8c7e73', '#9a9089', '#92877e', '#8b7d74'], + strokeBrandSoft: ['#250f0076', '#2a120280', '#2e16048a', '#ffe6d35c', '#ffe6cf64', '#ffe8d06b'], + strokeBrandSubtle: ['#4d200028', '#61330f32', '#6a3b193c', '#ffe3cb40', '#fde1c548', '#ffdec44f'], + strokeBrandOnloud: ['#ffffffff', '#fffffd', '#fffffb', '#0b0600e1', '#000000e1', '#000000e1'], + strokeDangerLoud: ['#c02e56', '#cd355e', '#da3c65', '#e55e7a', '#dd5170', '#d54467'], + strokeDangerSoft: ['#e55e7a', '#dd5170', '#d54467', '#d14164', '#de486c', '#eb4f74'], + strokeDangerSubtle: ['#ffd0d6', '#f7c5cc', '#eebbc2', '#76293a', '#813041', '#8c3749'], + strokeDangerOnloud: ['#ffffff', '#fffffd', '#fffffb', '#242424', '#141312', '#060504'], + strokeWarningLoud: ['#cd4808', '#da4f11', '#e75618', '#e56738', '#dd5b28', '#d54f13'], + strokeWarningSoft: ['#fc9571', '#f48a65', '#ec7f58', '#cd4808', '#da4f11', '#e75618'], + strokeWarningSubtle: ['#ffd3c4', '#f7c9b9', '#eebeae', '#762e12', '#813518', '#8c3c1e'], + strokeWarningOnloud: ['#ffffff', '#fffffd', '#fffffb', '#242424', '#141312', '#060504'], + strokeSuccessLoud: ['#008455', '#088e5c', '#119864', '#05ad72', '#00a468', '#009c5e'], + strokeSuccessSoft: ['#5dce97', '#4cc58d', '#39bc82', '#008e5c', '#089863', '#11a26b'], + strokeSuccessSubtle: ['#b1efcc', '#a5e6c2', '#9adcb7', '#025636', '#0a5f3d', '#136844'], + strokeSuccessOnloud: ['#ffffff', '#fffffd', '#fffffb', '#242424', '#141312', '#060504'], + foregroundNeutralPrimary: ['#0b0600e1', '#060300eb', '#040100f5', '#fff7f2e3', '#fff8f2eb', '#fffaf2f2'], + foregroundNeutralSecondary: ['#190c00ab', '#0a0100b5', '#060000bf', '#fff2e8b5', '#fff7ebbd', '#fff9ecc4'], + foregroundNeutralTertiary: ['#1e0d009a', '#0c0100a4', '#080000ae', '#ffeee39a', '#fff6eaa2', '#fff8eaa9'], + foregroundNeutralOnloud: ['#ffffffff', '#fffffd', '#fffffb', '#0b0600e1', '#010000e1', '#000000e1'], + foregroundBrandPrimary: ['#0b0600e1', '#060300eb', '#040100f5', '#fff7f2e3', '#fff8f2eb', '#fffaf2f2'], + foregroundBrandOnloud: ['#ffffffff', '#fffffd', '#fffffb', '#0b0600e1', '#010000e1', '#000000e1'], + foregroundDangerPrimary: ['#a62147', '#9e0b3e', '#950035', '#fd8ea1', '#ff96aa', '#ff9db2'], + foregroundDangerOnloud: ['#ffffff', '#fffffd', '#fffffb', '#000000db', '#000000db', '#000000db'], + foregroundWarningPrimary: ['#a93901', '#a12c00', '#991c00', '#fc9571', '#ff9d77', '#ffa57d'], + foregroundWarningOnloud: ['#ffffff', '#fffffd', '#fffffb', '#000000db', '#000000db', '#000000db'], + foregroundSuccessPrimary: ['#017048', '#00683f', '#006036', '#5dce97', '#62d99f', '#67e4a7'], + foregroundSuccessOnloud: ['#ffffff', '#fffffd', '#fffffb', '#000000db', '#000000db', '#000000db'], +} as const satisfies Record; + +/** + * The generated x3 table routes these entries through its separately scoped + * Bebop Warm path. `defaultValue` is the result of the default algorithm + * requested by #4268; `warmValue` is retained to make every table deviation + * explicit. + */ +export const x3WarmVariantDeviations = { + 'backgroundNeutralTranslucent.hover.light': { + warmValue: '#d8d8d88a', + defaultValue: '#dfdfdf8a', + reason: 'The warm path composites this translucent background against a reference surface before shifting it.', + }, + 'backgroundNeutralTranslucent.pressed.light': { + warmValue: '#cacaca94', + defaultValue: '#d5d5d594', + reason: 'The warm path composites this translucent background against a reference surface before shifting it.', + }, + 'backgroundNeutralTranslucent.hover.dark': { + warmValue: '#56565688', + defaultValue: '#47474788', + reason: 'The warm path composites this translucent background against a reference surface before shifting it.', + }, + 'backgroundNeutralTranslucent.pressed.dark': { + warmValue: '#6d6d6d8f', + defaultValue: '#5353538f', + reason: 'The warm path composites this translucent background against a reference surface before shifting it.', + }, + 'strokeNeutralSoft.hover.light': { + warmValue: '#02020277', + defaultValue: '#00000077', + reason: 'The warm path adds chroma while the default algorithm preserves chroma.', + }, + 'strokeNeutralSoft.pressed.light': { + warmValue: '#04040481', + defaultValue: '#00000081', + reason: 'The warm path adds chroma while the default algorithm preserves chroma.', + }, + 'strokeNeutralSubtle.pressed.light': { + warmValue: '#05050535', + defaultValue: '#00000035', + reason: 'The warm path adds chroma while the default algorithm preserves chroma.', + }, + 'strokeBrandSoft.hover.light': { + warmValue: '#02020277', + defaultValue: '#00000077', + reason: 'The warm path adds chroma while the default algorithm preserves chroma.', + }, + 'strokeBrandSoft.pressed.light': { + warmValue: '#04040481', + defaultValue: '#00000081', + reason: 'The warm path adds chroma while the default algorithm preserves chroma.', + }, + 'strokeBrandSubtle.pressed.light': { + warmValue: '#05050535', + defaultValue: '#00000035', + reason: 'The warm path adds chroma while the default algorithm preserves chroma.', + }, +} as const; diff --git a/packages/agentic/design/src/testing/index.ts b/packages/agentic/design/src/testing/index.ts index 78dfb57889..7ee6cf4c01 100644 --- a/packages/agentic/design/src/testing/index.ts +++ b/packages/agentic/design/src/testing/index.ts @@ -1,3 +1,15 @@ export { defaultResolvedThemeAppearance } from '../theming/appearance'; export { defaultFlexTokens } from '../tokens/defaultTokens'; +export type { + ContrastColorDiagnostic, + ContrastColorToken, + ContrastPairing, + ContrastPairResult, + ContrastTokenReference, + ResolvedContrastPairResult, + UnresolvableContrastPairResult, +} from './color-validation/types'; +export { MODE_SURFACE, WCAG } from './color-validation/constants'; +export { PAIRINGS } from './color-validation/pairings'; +export { getContrastFailures, getUnresolvableContrastPairs, validateContrastPairs } from './color-validation/validation'; export { mockTheme } from './mockTheme';