From 1a965fc360834a21c5f7c465c2d9e467ae5f6408 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 31 Aug 2026 14:01:21 -0700 Subject: [PATCH 1/6] add dynamic color and contrast utilities --- .changeset/tidy-colors-audit.md | 5 + apps/bundle-size/baseline.json | 60 ++++ apps/bundle-size/scenarios.json | 14 +- packages/agentic/design/package.json | 12 + packages/agentic/design/src/color/README.md | 45 +++ .../agentic/design/src/color/colorValue.ts | 170 ++++++++++ .../x3InteractionConformance.test.fixture.ts | 135 ++++++++ .../design/src/contrast/contrast.test.ts | 152 +++++++++ packages/agentic/design/src/contrast/index.ts | 317 ++++++++++++++++++ .../design/src/runtime-colors/index.ts | 283 ++++++++++++++++ .../src/runtime-colors/runtimeColors.test.ts | 151 +++++++++ 11 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-colors-audit.md create mode 100644 packages/agentic/design/src/color/README.md create mode 100644 packages/agentic/design/src/color/colorValue.ts create mode 100644 packages/agentic/design/src/color/x3InteractionConformance.test.fixture.ts create mode 100644 packages/agentic/design/src/contrast/contrast.test.ts create mode 100644 packages/agentic/design/src/contrast/index.ts create mode 100644 packages/agentic/design/src/runtime-colors/index.ts create mode 100644 packages/agentic/design/src/runtime-colors/runtimeColors.test.ts diff --git a/.changeset/tidy-colors-audit.md b/.changeset/tidy-colors-audit.md new file mode 100644 index 0000000000..6458708257 --- /dev/null +++ b/.changeset/tidy-colors-audit.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/design": minor +--- + +Add opt-in runtime interaction color derivation and contrast auditing submodules. diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json index 559832dc88..09b8327ebc 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -67,6 +67,36 @@ "@fluentui-react-native/framework-base": 3413 } }, + { + "platform": "macos", + "scenario": "design-runtime-colors", + "rawBytes": 825935, + "gzipBytes": 238683, + "moduleCount": 499, + "metroModuleCount": 497, + "metafileInputCount": 499, + "workspaceModules": { + "@fluentui-react-native/design": 2 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 5327 + } + }, + { + "platform": "macos", + "scenario": "design-contrast", + "rawBytes": 827111, + "gzipBytes": 238439, + "moduleCount": 499, + "metroModuleCount": 497, + "metafileInputCount": 499, + "workspaceModules": { + "@fluentui-react-native/design": 2 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 6502 + } + }, { "platform": "macos", "scenario": "design-theme-state-legacy-source", @@ -167,6 +197,36 @@ "@fluentui-react-native/framework-base": 3413 } }, + { + "platform": "windows", + "scenario": "design-runtime-colors", + "rawBytes": 835070, + "gzipBytes": 240083, + "moduleCount": 504, + "metroModuleCount": 502, + "metafileInputCount": 504, + "workspaceModules": { + "@fluentui-react-native/design": 2 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 5327 + } + }, + { + "platform": "windows", + "scenario": "design-contrast", + "rawBytes": 836244, + "gzipBytes": 239875, + "moduleCount": 504, + "metroModuleCount": 502, + "metafileInputCount": 504, + "workspaceModules": { + "@fluentui-react-native/design": 2 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 6502 + } + }, { "platform": "windows", "scenario": "design-theme-state-legacy-source", diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json index 11bb13a385..427e961747 100644 --- a/apps/bundle-size/scenarios.json +++ b/apps/bundle-size/scenarios.json @@ -18,7 +18,19 @@ "name": "design-theme-state", "module": "@fluentui-react-native/design", "exports": ["FlexThemeReference", "ThemeProvider", "useThemeState"], - "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/"] + "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/", "/runtime-colors/", "/contrast/", "/color/colorValue."] + }, + { + "name": "design-runtime-colors", + "module": "@fluentui-react-native/design/runtime-colors", + "exports": ["deriveInteractionColors"], + "requiredInputPatterns": ["/runtime-colors/index.", "/color/colorValue."] + }, + { + "name": "design-contrast", + "module": "@fluentui-react-native/design/contrast", + "exports": ["resolvedPairs"], + "requiredInputPatterns": ["/contrast/index.", "/color/colorValue."] }, { "name": "design-theme-state-legacy-source", diff --git a/packages/agentic/design/package.json b/packages/agentic/design/package.json index 2d9e063b8a..5328c5b88d 100644 --- a/packages/agentic/design/package.json +++ b/packages/agentic/design/package.json @@ -33,6 +33,18 @@ "import": "./lib/tokens/legacy/index.js", "default": "./src/tokens/legacy/index.ts" }, + "./runtime-colors": { + "types": "./lib/runtime-colors/index.d.ts", + "react-native": "./src/runtime-colors/index.ts", + "import": "./lib/runtime-colors/index.js", + "default": "./src/runtime-colors/index.ts" + }, + "./contrast": { + "types": "./lib/contrast/index.d.ts", + "react-native": "./src/contrast/index.ts", + "import": "./lib/contrast/index.js", + "default": "./src/contrast/index.ts" + }, "./theming": { "types": "./lib/theming/index.d.ts", "react-native": "./src/theming/index.ts", diff --git a/packages/agentic/design/src/color/README.md b/packages/agentic/design/src/color/README.md new file mode 100644 index 0000000000..f219fb6498 --- /dev/null +++ b/packages/agentic/design/src/color/README.md @@ -0,0 +1,45 @@ +# Optional color utilities + +`@fluentui-react-native/design/runtime-colors` ports the default OKLCH +interaction algorithm, and `@fluentui-react-native/design/contrast` ports the +WCAG audit model, from +[`x3-design/fluent-design@d334acf5cbad813f2b7cd554da942b09a7ff8f10`](https://github.com/x3-design/fluent-design/tree/d334acf5cbad813f2b7cd554da942b09a7ff8f10/dev/web/flex-themes). +The implementation source, rather than the stale `contrast.d.ts` declaration, +is authoritative for the dark fallback surface (`#000000`). + +The pinned generated interaction table has ten values that do not come from +the default algorithm: four `backgroundNeutralTranslucent` values use the +Bebop Warm reference-backdrop solve, and six light-mode soft/subtle stroke +values use its chroma boost. They are listed with their default and warm values +in `x3InteractionConformance.test.fixture.ts`. The conformance gate verifies +all other generated values exactly and verifies each documented deviation +against both algorithms' expected value. + +Both capabilities are optional subpaths and are intentionally absent from the +design root barrel. The `design-theme-state` bundle-size scenario forbids their +source paths, while the `design-runtime-colors` and `design-contrast` scenarios +require their respective paths: + +```sh +yarn workspace @fluentui-react-native/bundle-size measure --platform macos +``` + +The utilities resolve hex, `rgb()`, and `rgba()` literals in JavaScript. +Opaque native values such as `PlatformColor` and `DynamicColorMacOS` produce +structured `unresolvable` diagnostics. Interaction derivation is a standard +contrast fallback only; callers must not apply it to high-contrast themes. + +The package Jest suite includes the pinned 118-value interaction fixture and +the contrast conformance gate: + +```sh +yarn workspace @fluentui-react-native/design test --runInBand +``` + +The combined interaction audit also records two pinned upstream failures in +dark pressed state: danger-primary on danger-soft and warning-primary on +warning-soft are below AA text contrast after both values shift. The test locks +those known failures and rejects any additional regression. FURN adds the +issue-required neutral-heavy-stroke pairing at `visibleStroke` (3:1) and +identifies it with `source: 'furn'`; the remaining pairings retain +`source: 'x3'`. diff --git a/packages/agentic/design/src/color/colorValue.ts b/packages/agentic/design/src/color/colorValue.ts new file mode 100644 index 0000000000..1a06db6546 --- /dev/null +++ b/packages/agentic/design/src/color/colorValue.ts @@ -0,0 +1,170 @@ +import type { ColorValue } from 'react-native'; + +export type ColorDiagnosticReason = 'non-literal-color' | 'unsupported-color-format'; + +export interface ColorDiagnostic { + readonly reason: ColorDiagnosticReason; + readonly value: ColorValue; + readonly message: string; +} + +export interface RgbaColor { + readonly r: number; + readonly g: number; + readonly b: number; + readonly a: number; +} + +export type ParsedColorValue = + | { + readonly status: 'resolved'; + readonly color: RgbaColor; + } + | { + readonly status: 'unresolvable'; + readonly diagnostic: ColorDiagnostic; + }; + +const HEX_COLOR = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i; +const RGB_COLOR = /^(rgba?)\((.*)\)$/i; +const NUMERIC_CHANNEL = /^(?:\d+(?:\.\d*)?|\.\d+)%?$/; + +export function clamp(value: number, minimum = 0, maximum = 1): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +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 parseRgbChannel(value: string): number | undefined { + if (!NUMERIC_CHANNEL.test(value)) { + return undefined; + } + const percentage = value.endsWith('%'); + const parsed = Number.parseFloat(value); + const maximum = percentage ? 100 : 255; + if (!Number.isFinite(parsed) || parsed < 0 || parsed > maximum) { + return undefined; + } + return parsed / maximum; +} + +function parseAlphaChannel(value: string): number | undefined { + if (!NUMERIC_CHANNEL.test(value)) { + return undefined; + } + const percentage = value.endsWith('%'); + const parsed = Number.parseFloat(value); + const maximum = percentage ? 100 : 1; + if (!Number.isFinite(parsed) || parsed < 0 || parsed > maximum) { + return undefined; + } + return parsed / maximum; +} + +function parseRgbColor(value: string): RgbaColor | undefined { + const match = RGB_COLOR.exec(value); + if (!match) { + return undefined; + } + + const functionName = match[1].toLowerCase(); + const slashParts = match[2].trim().split(/\s*\/\s*/); + if (slashParts.length > 2) { + return undefined; + } + + const commaSyntax = slashParts[0].includes(','); + const parts = commaSyntax ? slashParts[0].split(/\s*,\s*/) : slashParts[0].trim().split(/\s+/); + let alphaPart = slashParts[1]; + if (commaSyntax && parts.length === 4) { + if (alphaPart !== undefined) { + return undefined; + } + alphaPart = parts.pop(); + } + if (parts.length !== 3 || (functionName === 'rgba' && alphaPart === undefined)) { + return undefined; + } + + const channels = parts.map(parseRgbChannel); + const alpha = alphaPart === undefined ? 1 : parseAlphaChannel(alphaPart); + if (channels.some((channel) => channel === undefined) || alpha === undefined) { + return undefined; + } + + return { + r: channels[0], + g: channels[1], + b: channels[2], + a: alpha, + }; +} + +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().`, + }, + }; +} + +function channelToHex(value: number): string { + return Math.round(clamp(value) * 255) + .toString(16) + .padStart(2, '0'); +} + +export function rgbaToHex(color: RgbaColor): string { + const rgb = `#${channelToHex(color.r)}${channelToHex(color.g)}${channelToHex(color.b)}`; + const alpha = channelToHex(color.a); + return alpha === 'ff' ? rgb : `${rgb}${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, + }; +} diff --git a/packages/agentic/design/src/color/x3InteractionConformance.test.fixture.ts b/packages/agentic/design/src/color/x3InteractionConformance.test.fixture.ts new file mode 100644 index 0000000000..df3ee49f55 --- /dev/null +++ b/packages/agentic/design/src/color/x3InteractionConformance.test.fixture.ts @@ -0,0 +1,135 @@ +import type { InteractiveColorToken } from '../runtime-colors'; + +export type InteractionConformanceVector = readonly [ + lightRest: string, + lightHover: string, + lightPressed: string, + darkRest: string, + darkHover: string, + darkPressed: string, +]; + +/** + * 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; + +/** + * 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/contrast/contrast.test.ts b/packages/agentic/design/src/contrast/contrast.test.ts new file mode 100644 index 0000000000..24bf25b03d --- /dev/null +++ b/packages/agentic/design/src/contrast/contrast.test.ts @@ -0,0 +1,152 @@ +import { PlatformColor } from 'react-native'; + +import { x3InteractionConformance } from '../color/x3InteractionConformance.test.fixture'; +import { deriveInteractionColorOverrides, INTERACTIVE_COLOR_TOKENS } from '../runtime-colors'; +import { defaultFlexTokens } from '../tokens/defaultTokens'; +import type { SemanticColorTokenValues } from '../tokens/flex.types'; +import { composite, contrastRatio, MODE_SURFACE, PAIRINGS, resolvedPairs, WCAG } from './index'; + +function conformanceColors(mode: 'light' | 'dark'): SemanticColorTokenValues { + const restIndex = mode === 'light' ? 0 : 3; + const { hover: _hover, pressed: _pressed, ...colors } = defaultFlexTokens.color; + for (const token of INTERACTIVE_COLOR_TOKENS) { + colors[token] = x3InteractionConformance[token][restIndex]; + } + return colors; +} + +function interactionConformanceColors(mode: 'light' | 'dark', state: 'hover' | 'pressed'): SemanticColorTokenValues { + const colors = conformanceColors(mode); + return { + ...colors, + ...deriveInteractionColorOverrides(colors, mode, state).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('contrast primitives', () => { + it('matches the upstream constants', () => { + expect(MODE_SURFACE).toEqual({ light: '#ffffff', dark: '#000000' }); + expect(WCAG).toEqual({ + aaText: 4.5, + aaLargeText: 3, + aaaText: 7, + aaaLargeText: 4.5, + visibleStroke: 3, + }); + }); + + it('composites alpha-channel literals over an opaque surface', () => { + expect(composite('#0000000d', '#ffffff')).toBe('#f2f2f2'); + expect(composite('#ffffff14', '#000000')).toBe('#141414'); + expect(composite('rgba(255 0 0 / 50%)', '#000000')).toBe('#800000'); + }); + + it('computes WCAG contrast after compositing', () => { + expect(contrastRatio('#000000', '#ffffff')).toBe(21); + expect(contrastRatio('#00000080', '#ffffff')).toBeCloseTo(4, 2); + }); + + it('rejects unsupported direct inputs', () => { + expect(() => composite('red', '#ffffff')).toThrow('Unsupported color literal'); + expect(() => composite('#00000080', '#ffffff80')).toThrow('background color must be opaque'); + }); +}); + +describe.each(['light', 'dark'] as const)('%s contrast conformance', (mode) => { + it('passes every canonical x3 pairing', () => { + const results = resolvedPairs(conformanceColors(mode), mode); + + expect(results.map((result) => `${result.foreground.token}/${result.background.token}`)).toEqual(expectedPairs); + expect(results.filter((result) => result.status !== 'pass')).toEqual([]); + }); + + it.each(['hover', 'pressed'] as const)('audits %s interaction values', (state) => { + const failures = resolvedPairs(interactionConformanceColors(mode, state), mode) + .filter((result) => result.status === 'fail') + .map((result) => `${result.foreground.token}/${result.background.token}`); + const expectedFailures = + mode === 'dark' && state === 'pressed' + ? ['foregroundDangerPrimary/backgroundDangerSoft', 'foregroundWarningPrimary/backgroundWarningSoft'] + : []; + + expect(failures).toEqual(expectedFailures); + }); +}); + +describe('contrast diagnostics', () => { + it('records the upstream name for every FURN 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-/); + } + } + }); + + it('returns unresolvable results for native foreground colors', () => { + const colors = conformanceColors('light'); + colors.foregroundNeutralPrimary = PlatformColor('labelColor'); + const results = resolvedPairs(colors, 'light').filter((result) => result.foreground.token === 'foregroundNeutralPrimary'); + + expect(results).toHaveLength(4); + expect(results.every((result) => result.status === 'unresolvable' && result.ratio === null)).toBe(true); + expect(results[0].diagnostics).toEqual([ + expect.objectContaining({ + reason: 'non-literal-color', + role: 'foreground', + token: 'foregroundNeutralPrimary', + }), + ]); + }); + + it('returns unresolvable results for native background colors', () => { + const colors = conformanceColors('dark'); + colors.backgroundSuccessSoft = PlatformColor('windowBackgroundColor'); + const result = resolvedPairs(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/contrast/index.ts b/packages/agentic/design/src/contrast/index.ts new file mode 100644 index 0000000000..0a11a208b1 --- /dev/null +++ b/packages/agentic/design/src/contrast/index.ts @@ -0,0 +1,317 @@ +import type { ColorValue } from 'react-native'; + +import { compositeRgba, parseColorValue, rgbaToHex } from '../color/colorValue'; +import type { ColorDiagnostic, RgbaColor } from '../color/colorValue'; +import type { ThemeColorScheme } from '../theming/appearance.types'; +import type { SemanticColorTokenValues } from '../tokens/flex.types'; + +export type { ColorDiagnostic, ColorDiagnosticReason } from '../color/colorValue'; + +export const MODE_SURFACE = { + light: '#ffffff', + dark: '#000000', +} as const; + +export const WCAG = { + aaText: 4.5, + aaLargeText: 3, + aaaText: 7, + aaaLargeText: 4.5, + visibleStroke: 3, +} as const; + +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 PairResultBase { + readonly pairing: ContrastPairing; + readonly foreground: ContrastTokenReference; + readonly background: ContrastTokenReference; + readonly foregroundValue: ColorValue; + readonly backgroundValue: ColorValue; + readonly mode: ThemeColorScheme; +} + +export type PairResult = + | (PairResultBase & { + readonly status: 'pass' | 'fail'; + readonly ratio: number; + readonly foregroundResolved: string; + readonly backgroundResolved: string; + readonly diagnostics: readonly []; + }) + | (PairResultBase & { + readonly status: 'unresolvable'; + readonly ratio: null; + readonly foregroundResolved: null; + readonly backgroundResolved: null; + readonly diagnostics: readonly ContrastColorDiagnostic[]; + }); + +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), + }; +} + +/** + * Canonical x3 pairings adapted to FURN's camel-case token names. + * + * 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[]; + +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; +} + +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 over an opaque literal background. */ +export function composite(foreground: ColorValue, background: ColorValue): string { + const foregroundColor = requireLiteralColor(foreground, 'composite'); + const backgroundColor = requireLiteralColor(background, 'composite'); + if (backgroundColor.a !== 1) { + throw new TypeError('composite: 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); +} + +function resolveLiteralPair(foreground: RgbaColor, background: RgbaColor, surface: RgbaColor) { + const backgroundResolved = background.a === 1 ? background : opaqueComposite(background, surface); + const foregroundResolved = foreground.a === 1 ? foreground : opaqueComposite(foreground, 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), + }; +} + +export function contrastRatio(foreground: ColorValue, background: ColorValue, fallbackSurface: ColorValue = '#ffffff'): number { + const foregroundColor = requireLiteralColor(foreground, 'contrastRatio'); + const backgroundColor = requireLiteralColor(background, 'contrastRatio'); + const surfaceColor = requireLiteralColor(fallbackSurface, 'contrastRatio'); + if (surfaceColor.a !== 1) { + throw new TypeError('contrastRatio: the fallback surface must be opaque.'); + } + return resolveLiteralPair(foregroundColor, backgroundColor, surfaceColor).ratio; +} + +function diagnosticFor( + diagnostic: ColorDiagnostic, + tokenName: ContrastColorToken, + role: ContrastColorDiagnostic['role'], +): ContrastColorDiagnostic { + return { + ...diagnostic, + token: tokenName, + role, + }; +} + +export function resolvedPairs(colors: SemanticColorTokenValues, mode: ThemeColorScheme): PairResult[] { + const surface = requireLiteralColor(MODE_SURFACE[mode], 'resolvedPairs'); + const results: PairResult[] = []; + + 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 = resolveLiteralPair(foreground.color, background.color, surface); + results.push({ + ...base, + status: resolved.ratio >= pairing.minimumRatio ? 'pass' : 'fail', + diagnostics: [], + ...resolved, + }); + } + } + + return results; +} diff --git a/packages/agentic/design/src/runtime-colors/index.ts b/packages/agentic/design/src/runtime-colors/index.ts new file mode 100644 index 0000000000..4b40e82ec4 --- /dev/null +++ b/packages/agentic/design/src/runtime-colors/index.ts @@ -0,0 +1,283 @@ +import type { ColorValue } from 'react-native'; + +import { clamp, parseColorValue, rgbaToHex } from '../color/colorValue'; +import type { ColorDiagnostic, RgbaColor } from '../color/colorValue'; +import type { ThemeColorScheme } from '../theming/appearance.types'; +import type { InteractiveColorOverrides, SemanticColors, SemanticColorTokenValues } from '../tokens/flex.types'; + +export type { ColorDiagnostic, ColorDiagnosticReason } from '../color/colorValue'; + +export type InteractionColorState = 'hover' | 'pressed'; +export type InteractiveColorToken = keyof InteractiveColorOverrides; + +export interface RgbColor { + readonly r: number; + readonly g: number; + readonly b: number; +} + +export interface OklchColor { + readonly L: number; + readonly C: number; + readonly h: number; +} + +export interface InteractionColorDiagnostic extends ColorDiagnostic { + readonly token: InteractiveColorToken; + readonly state: InteractionColorState; +} + +export type DerivedInteractionColor = + | { + 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 DerivedInteractionColorsResult { + readonly colors: SemanticColors; + readonly diagnostics: readonly InteractionColorDiagnostic[]; +} + +/** + * 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[]; + +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; + +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; +} + +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, + }; +} + +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), + }; +} + +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); +} + +export function deriveInteractionColor( + colorValue: ColorValue, + token: InteractiveColorToken, + mode: ThemeColorScheme, + state: InteractionColorState, +): DerivedInteractionColor { + const parsed = parseColorValue(colorValue); + if (parsed.status === 'unresolvable') { + return { + status: 'unresolvable', + value: colorValue, + diagnostic: { + ...parsed.diagnostic, + token, + state, + }, + }; + } + + const oklch = rgbToOklch(parsed.color); + const delta = INTERACTION_COLOR_DELTAS[state][mode]; + const direction = isInverseInteractionToken(token) ? -1 : 1; + const rgb = oklchToRgb({ + ...oklch, + L: clamp(oklch.L + direction * delta.lightness * interactionCurveMultiplier(oklch.L)), + }); + const result: RgbaColor = { + ...rgb, + a: isInverseInteractionToken(token) ? parsed.color.a : clamp(parsed.color.a + delta.alpha), + }; + + return { + status: 'derived', + value: rgbaToHex(result), + }; +} + +export function deriveInteractionColorOverrides( + colors: SemanticColorTokenValues, + mode: ThemeColorScheme, + state: InteractionColorState, +): InteractionColorOverridesResult { + const overrides: InteractiveColorOverrides = {}; + const diagnostics: InteractionColorDiagnostic[] = []; + + for (const token of INTERACTIVE_COLOR_TOKENS) { + const result = deriveInteractionColor(colors[token], token, mode, state); + if (result.status === 'derived') { + overrides[token] = result.value; + } else { + diagnostics.push(result.diagnostic); + } + } + + return { overrides, diagnostics }; +} + +/** + * Fill missing interaction states without replacing authored values. + * + * Callers must use the resolved light/dark scheme and should not invoke this + * fallback for high-contrast themes. + */ +export function deriveInteractionColors(colors: InteractionColorInput, mode: ThemeColorScheme): DerivedInteractionColorsResult { + 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 = deriveInteractionColor(colors[token], token, mode, state); + 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/runtime-colors/runtimeColors.test.ts b/packages/agentic/design/src/runtime-colors/runtimeColors.test.ts new file mode 100644 index 0000000000..d2fb3fa86b --- /dev/null +++ b/packages/agentic/design/src/runtime-colors/runtimeColors.test.ts @@ -0,0 +1,151 @@ +import { PlatformColor } from 'react-native'; + +import { x3InteractionConformance, x3WarmVariantDeviations } from '../color/x3InteractionConformance.test.fixture'; +import { defaultFlexTokens } from '../tokens/defaultTokens'; +import type { SemanticColorTokenValues } from '../tokens/flex.types'; +import { + deriveInteractionColor, + deriveInteractionColorOverrides, + deriveInteractionColors, + INTERACTIVE_COLOR_TOKENS, + isInverseInteractionToken, +} from './index'; + +const modeIndexes = { + light: { rest: 0, hover: 1, pressed: 2 }, + dark: { rest: 3, hover: 4, pressed: 5 }, +} as const; + +function conformanceColors(mode: 'light' | 'dark'): 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; +} + +describe.each(['light', 'dark'] as const)('%s interaction colors', (mode) => { + it.each(INTERACTIVE_COLOR_TOKENS)('matches x3 for %s', (token) => { + const vector = x3InteractionConformance[token]; + for (const state of ['hover', 'pressed'] as const) { + const result = deriveInteractionColor(vector[modeIndexes[mode].rest], token, mode, state); + const deviationKey = `${token}.${state}.${mode}` as keyof typeof x3WarmVariantDeviations; + const deviation = x3WarmVariantDeviations[deviationKey]; + const expected = deviation?.defaultValue ?? vector[modeIndexes[mode][state]]; + + expect(result).toEqual({ + status: 'derived', + value: expected, + }); + if (deviation) { + expect(vector[modeIndexes[mode][state]]).toBe(deviation.warmValue); + } + } + }); + + it('derives a complete override map', () => { + const result = deriveInteractionColorOverrides(conformanceColors(mode), mode, 'hover'); + + expect(result.diagnostics).toEqual([]); + expect(Object.keys(result.overrides)).toHaveLength(59); + }); +}); + +describe('interaction color contract', () => { + 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('supports rgb and rgba literals', () => { + expect(deriveInteractionColor('rgb(235 235 235)', 'surfaceNeutralFarther', 'light', 'hover')).toEqual({ + status: 'derived', + value: '#e1e1e1', + }); + expect(deriveInteractionColor('rgba(0, 0, 0, 0.05)', 'backgroundNeutralSubtle', 'light', 'hover')).toEqual({ + status: 'derived', + value: '#00000017', + }); + }); + + it('fills missing values without replacing authored interaction colors', () => { + const input = { + ...conformanceColors('light'), + hover: { surfaceNeutralFarther: '#123456' }, + }; + const result = deriveInteractionColors(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 = deriveInteractionColorOverrides(colors, 'light', 'hover').overrides; + const pressed = deriveInteractionColorOverrides(colors, 'light', 'pressed').overrides; + const input = { ...colors, hover, pressed }; + + expect(deriveInteractionColors(input, 'light').colors).toBe(input); + }); + + it('reports opaque platform colors instead of deriving a wrong value', () => { + const platformColor = PlatformColor('labelColor'); + const result = deriveInteractionColor(platformColor, 'foregroundNeutralPrimary', 'light', 'hover'); + + expect(result).toMatchObject({ + status: 'unresolvable', + diagnostic: { + reason: 'non-literal-color', + token: 'foregroundNeutralPrimary', + state: 'hover', + }, + }); + }); + + it('reports unsupported literal formats', () => { + const result = deriveInteractionColor('red', 'foregroundDangerPrimary', 'light', 'pressed'); + const malformed = deriveInteractionColor('rgb(12oops 0 0)', 'foregroundDangerPrimary', 'light', 'pressed'); + + expect(result).toMatchObject({ + status: 'unresolvable', + diagnostic: { + reason: 'unsupported-color-format', + }, + }); + expect(malformed).toMatchObject({ + status: 'unresolvable', + diagnostic: { + reason: 'unsupported-color-format', + }, + }); + }); +}); From 62993f1c4b6187cac123afd4e8272075da5abd66 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 31 Aug 2026 16:08:00 -0700 Subject: [PATCH 2/6] switch to color-lib and color-validation submodules --- .changeset/tidy-colors-audit.md | 2 +- apps/bundle-size/baseline.json | 64 ++-- apps/bundle-size/scenarios.json | 19 +- packages/agentic/design/package.json | 22 +- .../agentic/design/src/color-lib/README.md | 33 ++ .../design/src/color-lib/colorLib.test.ts | 227 +++++++++++++ .../design/src/color-lib/compositing.ts | 88 +++++ .../design/src/color-lib/conversion.ts | 61 ++++ .../agentic/design/src/color-lib/index.ts | 33 ++ .../design/src/color-lib/interaction.ts | 310 +++++++++++++++++ packages/agentic/design/src/color-lib/math.ts | 7 + .../colorValue.ts => color-lib/parsing.ts} | 66 ++-- .../agentic/design/src/color-lib/types.ts | 88 +++++ .../design/src/color-validation/README.md | 18 + .../design/src/color-validation/constants.ts | 14 + .../design/src/color-validation/index.ts | 12 + .../design/src/color-validation/pairings.ts | 133 ++++++++ .../testing/x3InteractionConformance.ts} | 65 +++- .../design/src/color-validation/types.ts | 51 +++ .../validation.test.ts} | 114 ++++--- .../design/src/color-validation/validation.ts | 94 ++++++ packages/agentic/design/src/color/README.md | 45 --- packages/agentic/design/src/contrast/index.ts | 317 ------------------ .../design/src/runtime-colors/index.ts | 283 ---------------- .../src/runtime-colors/runtimeColors.test.ts | 151 --------- 25 files changed, 1366 insertions(+), 951 deletions(-) create mode 100644 packages/agentic/design/src/color-lib/README.md create mode 100644 packages/agentic/design/src/color-lib/colorLib.test.ts create mode 100644 packages/agentic/design/src/color-lib/compositing.ts create mode 100644 packages/agentic/design/src/color-lib/conversion.ts create mode 100644 packages/agentic/design/src/color-lib/index.ts create mode 100644 packages/agentic/design/src/color-lib/interaction.ts create mode 100644 packages/agentic/design/src/color-lib/math.ts rename packages/agentic/design/src/{color/colorValue.ts => color-lib/parsing.ts} (69%) create mode 100644 packages/agentic/design/src/color-lib/types.ts create mode 100644 packages/agentic/design/src/color-validation/README.md create mode 100644 packages/agentic/design/src/color-validation/constants.ts create mode 100644 packages/agentic/design/src/color-validation/index.ts create mode 100644 packages/agentic/design/src/color-validation/pairings.ts rename packages/agentic/design/src/{color/x3InteractionConformance.test.fixture.ts => color-validation/testing/x3InteractionConformance.ts} (58%) create mode 100644 packages/agentic/design/src/color-validation/types.ts rename packages/agentic/design/src/{contrast/contrast.test.ts => color-validation/validation.test.ts} (52%) create mode 100644 packages/agentic/design/src/color-validation/validation.ts delete mode 100644 packages/agentic/design/src/color/README.md delete mode 100644 packages/agentic/design/src/contrast/index.ts delete mode 100644 packages/agentic/design/src/runtime-colors/index.ts delete mode 100644 packages/agentic/design/src/runtime-colors/runtimeColors.test.ts diff --git a/.changeset/tidy-colors-audit.md b/.changeset/tidy-colors-audit.md index 6458708257..3fb2c358c7 100644 --- a/.changeset/tidy-colors-audit.md +++ b/.changeset/tidy-colors-audit.md @@ -2,4 +2,4 @@ "@fluentui-react-native/design": minor --- -Add opt-in runtime interaction color derivation and contrast auditing submodules. +Add an opt-in runtime color library and a separate test-focused color validation submodule. diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json index 09b8327ebc..f33f6bfc3f 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -69,32 +69,32 @@ }, { "platform": "macos", - "scenario": "design-runtime-colors", - "rawBytes": 825935, - "gzipBytes": 238683, - "moduleCount": 499, - "metroModuleCount": 497, - "metafileInputCount": 499, + "scenario": "design-color-lib", + "rawBytes": 826875, + "gzipBytes": 239247, + "moduleCount": 503, + "metroModuleCount": 501, + "metafileInputCount": 503, "workspaceModules": { - "@fluentui-react-native/design": 2 + "@fluentui-react-native/design": 6 }, "workspaceBytes": { - "@fluentui-react-native/design": 5327 + "@fluentui-react-native/design": 6213 } }, { "platform": "macos", - "scenario": "design-contrast", - "rawBytes": 827111, - "gzipBytes": 238439, - "moduleCount": 499, - "metroModuleCount": 497, - "metafileInputCount": 499, + "scenario": "design-color-validation", + "rawBytes": 827655, + "gzipBytes": 238605, + "moduleCount": 505, + "metroModuleCount": 503, + "metafileInputCount": 507, "workspaceModules": { - "@fluentui-react-native/design": 2 + "@fluentui-react-native/design": 8 }, "workspaceBytes": { - "@fluentui-react-native/design": 6502 + "@fluentui-react-native/design": 7012 } }, { @@ -199,32 +199,32 @@ }, { "platform": "windows", - "scenario": "design-runtime-colors", - "rawBytes": 835070, - "gzipBytes": 240083, - "moduleCount": 504, - "metroModuleCount": 502, - "metafileInputCount": 504, + "scenario": "design-color-lib", + "rawBytes": 836010, + "gzipBytes": 240687, + "moduleCount": 508, + "metroModuleCount": 506, + "metafileInputCount": 508, "workspaceModules": { - "@fluentui-react-native/design": 2 + "@fluentui-react-native/design": 6 }, "workspaceBytes": { - "@fluentui-react-native/design": 5327 + "@fluentui-react-native/design": 6213 } }, { "platform": "windows", - "scenario": "design-contrast", - "rawBytes": 836244, - "gzipBytes": 239875, - "moduleCount": 504, - "metroModuleCount": 502, - "metafileInputCount": 504, + "scenario": "design-color-validation", + "rawBytes": 836790, + "gzipBytes": 240087, + "moduleCount": 510, + "metroModuleCount": 508, + "metafileInputCount": 512, "workspaceModules": { - "@fluentui-react-native/design": 2 + "@fluentui-react-native/design": 8 }, "workspaceBytes": { - "@fluentui-react-native/design": 6502 + "@fluentui-react-native/design": 7012 } }, { diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json index 427e961747..ad7f93703b 100644 --- a/apps/bundle-size/scenarios.json +++ b/apps/bundle-size/scenarios.json @@ -18,19 +18,20 @@ "name": "design-theme-state", "module": "@fluentui-react-native/design", "exports": ["FlexThemeReference", "ThemeProvider", "useThemeState"], - "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/", "/runtime-colors/", "/contrast/", "/color/colorValue."] + "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/", "/color-lib/", "/color-validation/"] }, { - "name": "design-runtime-colors", - "module": "@fluentui-react-native/design/runtime-colors", - "exports": ["deriveInteractionColors"], - "requiredInputPatterns": ["/runtime-colors/index.", "/color/colorValue."] + "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."], + "forbiddenInputPatterns": ["/color-validation/"] }, { - "name": "design-contrast", - "module": "@fluentui-react-native/design/contrast", - "exports": ["resolvedPairs"], - "requiredInputPatterns": ["/contrast/index.", "/color/colorValue."] + "name": "design-color-validation", + "module": "@fluentui-react-native/design/color-validation", + "exports": ["validateContrastPairs"], + "requiredInputPatterns": ["/color-validation/validation.", "/color-lib/compositing.", "/color-lib/parsing."] }, { "name": "design-theme-state-legacy-source", diff --git a/packages/agentic/design/package.json b/packages/agentic/design/package.json index 5328c5b88d..5090ee5b30 100644 --- a/packages/agentic/design/package.json +++ b/packages/agentic/design/package.json @@ -33,17 +33,17 @@ "import": "./lib/tokens/legacy/index.js", "default": "./src/tokens/legacy/index.ts" }, - "./runtime-colors": { - "types": "./lib/runtime-colors/index.d.ts", - "react-native": "./src/runtime-colors/index.ts", - "import": "./lib/runtime-colors/index.js", - "default": "./src/runtime-colors/index.ts" - }, - "./contrast": { - "types": "./lib/contrast/index.d.ts", - "react-native": "./src/contrast/index.ts", - "import": "./lib/contrast/index.js", - "default": "./src/contrast/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" + }, + "./color-validation": { + "types": "./lib/color-validation/index.d.ts", + "react-native": "./src/color-validation/index.ts", + "import": "./lib/color-validation/index.js", + "default": "./src/color-validation/index.ts" }, "./theming": { "types": "./lib/theming/index.d.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..fc0c6bf6c9 --- /dev/null +++ b/packages/agentic/design/src/color-lib/README.md @@ -0,0 +1,33 @@ +# 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 submodule is absent from the design root barrel and does not import +`color-validation`. 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..785edccbe5 --- /dev/null +++ b/packages/agentic/design/src/color-lib/colorLib.test.ts @@ -0,0 +1,227 @@ +import { PlatformColor } from 'react-native'; + +import { + x3BebopWarmInteractionConformance, + x3InteractionConformance, + x3WarmVariantDeviations, +} from '../color-validation/testing/x3InteractionConformance'; +import { defaultFlexTokens } from '../tokens/defaultTokens'; +import type { SemanticColorTokenValues } from '../tokens/flex.types'; +import { + compositeColor, + compositeRgba, + getContrastRatio, + getHoverColor, + getInteractionColor, + getInteractionColorOverrides, + getInteractionColors, + getPressColor, + INTERACTIVE_COLOR_TOKENS, + isInverseInteractionToken, + oklchToRgb, + parseColorValue, + resolveContrastColors, + rgbaToHex, + rgbToOklch, +} from './index'; +import type { ColorMode, InteractionColorState } from './index'; + +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('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..50e35e730b --- /dev/null +++ b/packages/agentic/design/src/color-lib/index.ts @@ -0,0 +1,33 @@ +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 { + BEBOP_WARM_REFERENCE_BACKDROP, + getHoverColor, + getInteractionColor, + getInteractionColorOverrides, + getInteractionColors, + getPressColor, + INTERACTION_COLOR_DELTAS, + INTERACTIVE_COLOR_TOKENS, + 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/colorValue.ts b/packages/agentic/design/src/color-lib/parsing.ts similarity index 69% rename from packages/agentic/design/src/color/colorValue.ts rename to packages/agentic/design/src/color-lib/parsing.ts index 1a06db6546..b46a562dcb 100644 --- a/packages/agentic/design/src/color/colorValue.ts +++ b/packages/agentic/design/src/color-lib/parsing.ts @@ -1,38 +1,12 @@ import type { ColorValue } from 'react-native'; -export type ColorDiagnosticReason = 'non-literal-color' | 'unsupported-color-format'; - -export interface ColorDiagnostic { - readonly reason: ColorDiagnosticReason; - readonly value: ColorValue; - readonly message: string; -} - -export interface RgbaColor { - readonly r: number; - readonly g: number; - readonly b: number; - readonly a: number; -} - -export type ParsedColorValue = - | { - readonly status: 'resolved'; - readonly color: RgbaColor; - } - | { - readonly status: 'unresolvable'; - readonly diagnostic: ColorDiagnostic; - }; +import { channelToByte } from './math'; +import type { ParsedColorValue, RgbaColor } from './types'; const HEX_COLOR = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i; const RGB_COLOR = /^(rgba?)\((.*)\)$/i; const NUMERIC_CHANNEL = /^(?:\d+(?:\.\d*)?|\.\d+)%?$/; -export function clamp(value: number, minimum = 0, maximum = 1): number { - return Math.max(minimum, Math.min(maximum, value)); -} - function parseHexColor(value: string): RgbaColor | undefined { const match = HEX_COLOR.exec(value); if (!match) { @@ -117,6 +91,10 @@ function parseRgbColor(value: string): RgbaColor | undefined { }; } +/** + * 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 { @@ -143,28 +121,22 @@ export function parseColorValue(value: ColorValue): ParsedColorValue { }; } -function channelToHex(value: number): string { - return Math.round(clamp(value) * 255) - .toString(16) - .padStart(2, '0'); -} - +/** + * Format normalized RGBA channels as `#rrggbb` or `#rrggbbaa` when alpha is + * translucent. + */ export function rgbaToHex(color: RgbaColor): string { - const rgb = `#${channelToHex(color.r)}${channelToHex(color.g)}${channelToHex(color.b)}`; - const alpha = channelToHex(color.a); + 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}`; } -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 }; +/** @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 { - 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, - }; + 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/color-validation/README.md b/packages/agentic/design/src/color-validation/README.md new file mode 100644 index 0000000000..f8be972cce --- /dev/null +++ b/packages/agentic/design/src/color-validation/README.md @@ -0,0 +1,18 @@ +# Color validation + +`@fluentui-react-native/design/color-validation` contains test and design-tool +policy built on `color-lib`: WCAG thresholds, canonical semantic token +pairings, structured pair audits, failure filters, unresolvable-color filters, +and pinned conformance fixtures. + +This submodule is intentionally not a component runtime dependency. Product +code that only needs parsing, conversion, interaction generation, compositing, +or a direct contrast ratio should import `color-lib`. Bundle scenarios enforce +that the design root and `color-lib` do not load `color-validation`. + +The validation suite audits rest, hover, and pressed values for both default +and Bebop Warm interaction variants. The pinned default theme has two dark +pressed failures: danger-primary on danger-soft and warning-primary on +warning-soft. The pinned Bebop Warm theme additionally fails danger-primary on +neutral-nearer and danger-subtle in that state. The tests make those existing +results explicit and reject additional regressions. diff --git a/packages/agentic/design/src/color-validation/constants.ts b/packages/agentic/design/src/color-validation/constants.ts new file mode 100644 index 0000000000..9841127ca4 --- /dev/null +++ b/packages/agentic/design/src/color-validation/constants.ts @@ -0,0 +1,14 @@ +/** Fallback surface colors 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/color-validation/index.ts b/packages/agentic/design/src/color-validation/index.ts new file mode 100644 index 0000000000..c80ba5803b --- /dev/null +++ b/packages/agentic/design/src/color-validation/index.ts @@ -0,0 +1,12 @@ +export type { + ContrastColorDiagnostic, + ContrastColorToken, + ContrastPairing, + ContrastPairResult, + ContrastTokenReference, + ResolvedContrastPairResult, + UnresolvableContrastPairResult, +} from './types'; +export { MODE_SURFACE, WCAG } from './constants'; +export { PAIRINGS } from './pairings'; +export { getContrastFailures, getUnresolvableContrastPairs, validateContrastPairs } from './validation'; diff --git a/packages/agentic/design/src/color-validation/pairings.ts b/packages/agentic/design/src/color-validation/pairings.ts new file mode 100644 index 0000000000..0ff983ebd7 --- /dev/null +++ b/packages/agentic/design/src/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), + }; +} + +/** + * 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/color/x3InteractionConformance.test.fixture.ts b/packages/agentic/design/src/color-validation/testing/x3InteractionConformance.ts similarity index 58% rename from packages/agentic/design/src/color/x3InteractionConformance.test.fixture.ts rename to packages/agentic/design/src/color-validation/testing/x3InteractionConformance.ts index df3ee49f55..ad2ac5811c 100644 --- a/packages/agentic/design/src/color/x3InteractionConformance.test.fixture.ts +++ b/packages/agentic/design/src/color-validation/testing/x3InteractionConformance.ts @@ -1,4 +1,4 @@ -import type { InteractiveColorToken } from '../runtime-colors'; +import type { InteractiveColorToken } from '../../color-lib'; export type InteractionConformanceVector = readonly [ lightRest: string, @@ -75,6 +75,69 @@ export const x3InteractionConformance = { 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 diff --git a/packages/agentic/design/src/color-validation/types.ts b/packages/agentic/design/src/color-validation/types.ts new file mode 100644 index 0000000000..64630a0cd3 --- /dev/null +++ b/packages/agentic/design/src/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/contrast/contrast.test.ts b/packages/agentic/design/src/color-validation/validation.test.ts similarity index 52% rename from packages/agentic/design/src/contrast/contrast.test.ts rename to packages/agentic/design/src/color-validation/validation.test.ts index 24bf25b03d..ffcf3a6f06 100644 --- a/packages/agentic/design/src/contrast/contrast.test.ts +++ b/packages/agentic/design/src/color-validation/validation.test.ts @@ -1,25 +1,34 @@ import { PlatformColor } from 'react-native'; -import { x3InteractionConformance } from '../color/x3InteractionConformance.test.fixture'; -import { deriveInteractionColorOverrides, INTERACTIVE_COLOR_TOKENS } from '../runtime-colors'; +import { getInteractionColorOverrides, INTERACTIVE_COLOR_TOKENS } from '../color-lib'; +import type { ColorMode, InteractionColorState, InteractionColorVariant } from '../color-lib'; import { defaultFlexTokens } from '../tokens/defaultTokens'; import type { SemanticColorTokenValues } from '../tokens/flex.types'; -import { composite, contrastRatio, MODE_SURFACE, PAIRINGS, resolvedPairs, WCAG } from './index'; - -function conformanceColors(mode: 'light' | 'dark'): SemanticColorTokenValues { +import { MODE_SURFACE, PAIRINGS, WCAG } from './index'; +import { getContrastFailures, getUnresolvableContrastPairs, validateContrastPairs } from './index'; +import { x3BebopWarmInteractionConformance, x3InteractionConformance } from './testing/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] = x3InteractionConformance[token][restIndex]; + colors[token] = conformance[token][restIndex]; } return colors; } -function interactionConformanceColors(mode: 'light' | 'dark', state: 'hover' | 'pressed'): SemanticColorTokenValues { - const colors = conformanceColors(mode); +function interactionConformanceColors( + mode: ColorMode, + state: InteractionColorState, + variant: InteractionColorVariant, +): SemanticColorTokenValues { + const colors = conformanceColors(mode, variant === 'bebopWarm' ? x3BebopWarmInteractionConformance : x3InteractionConformance); return { ...colors, - ...deriveInteractionColorOverrides(colors, mode, state).overrides, + ...getInteractionColorOverrides(colors, mode, state, { variant }).overrides, }; } @@ -56,8 +65,8 @@ const expectedPairs = [ 'strokeNeutralHeavy/backgroundNeutralSoft', ] as const; -describe('contrast primitives', () => { - it('matches the upstream constants', () => { +describe('color validation constants', () => { + it('matches the upstream values', () => { expect(MODE_SURFACE).toEqual({ light: '#ffffff', dark: '#000000' }); expect(WCAG).toEqual({ aaText: 4.5, @@ -68,61 +77,56 @@ describe('contrast primitives', () => { }); }); - it('composites alpha-channel literals over an opaque surface', () => { - expect(composite('#0000000d', '#ffffff')).toBe('#f2f2f2'); - expect(composite('#ffffff14', '#000000')).toBe('#141414'); - expect(composite('rgba(255 0 0 / 50%)', '#000000')).toBe('#800000'); - }); - - it('computes WCAG contrast after compositing', () => { - expect(contrastRatio('#000000', '#ffffff')).toBe(21); - expect(contrastRatio('#00000080', '#ffffff')).toBeCloseTo(4, 2); - }); - - it('rejects unsupported direct inputs', () => { - expect(() => composite('red', '#ffffff')).toThrow('Unsupported color literal'); - expect(() => composite('#00000080', '#ffffff80')).toThrow('background color must be opaque'); + 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 conformance', (mode) => { - it('passes every canonical x3 pairing', () => { - const results = resolvedPairs(conformanceColors(mode), mode); +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(results.filter((result) => result.status !== 'pass')).toEqual([]); + expect(getContrastFailures(results)).toEqual([]); + expect(getUnresolvableContrastPairs(results)).toEqual([]); }); - it.each(['hover', 'pressed'] as const)('audits %s interaction values', (state) => { - const failures = resolvedPairs(interactionConformanceColors(mode, state), mode) - .filter((result) => result.status === 'fail') - .map((result) => `${result.foreground.token}/${result.background.token}`); - const expectedFailures = - mode === 'dark' && state === 'pressed' - ? ['foregroundDangerPrimary/backgroundDangerSoft', 'foregroundWarningPrimary/backgroundWarningSoft'] - : []; - - expect(failures).toEqual(expectedFailures); - }); -}); - -describe('contrast diagnostics', () => { - it('records the upstream name for every FURN 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-/); - } + 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); } }); +}); - it('returns unresolvable results for native foreground colors', () => { +describe('unresolvable color validation', () => { + it('identifies native foreground colors', () => { const colors = conformanceColors('light'); colors.foregroundNeutralPrimary = PlatformColor('labelColor'); - const results = resolvedPairs(colors, 'light').filter((result) => result.foreground.token === 'foregroundNeutralPrimary'); + const results = getUnresolvableContrastPairs(validateContrastPairs(colors, 'light')).filter( + (result) => result.foreground.token === 'foregroundNeutralPrimary', + ); expect(results).toHaveLength(4); - expect(results.every((result) => result.status === 'unresolvable' && result.ratio === null)).toBe(true); expect(results[0].diagnostics).toEqual([ expect.objectContaining({ reason: 'non-literal-color', @@ -132,10 +136,12 @@ describe('contrast diagnostics', () => { ]); }); - it('returns unresolvable results for native background colors', () => { + it('identifies native background colors', () => { const colors = conformanceColors('dark'); colors.backgroundSuccessSoft = PlatformColor('windowBackgroundColor'); - const result = resolvedPairs(colors, 'dark').find((pair) => pair.background.token === 'backgroundSuccessSoft'); + const result = getUnresolvableContrastPairs(validateContrastPairs(colors, 'dark')).find( + (pair) => pair.background.token === 'backgroundSuccessSoft', + ); expect(result).toMatchObject({ status: 'unresolvable', diff --git a/packages/agentic/design/src/color-validation/validation.ts b/packages/agentic/design/src/color-validation/validation.ts new file mode 100644 index 0000000000..e722d4b067 --- /dev/null +++ b/packages/agentic/design/src/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/color/README.md b/packages/agentic/design/src/color/README.md deleted file mode 100644 index f219fb6498..0000000000 --- a/packages/agentic/design/src/color/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Optional color utilities - -`@fluentui-react-native/design/runtime-colors` ports the default OKLCH -interaction algorithm, and `@fluentui-react-native/design/contrast` ports the -WCAG audit model, from -[`x3-design/fluent-design@d334acf5cbad813f2b7cd554da942b09a7ff8f10`](https://github.com/x3-design/fluent-design/tree/d334acf5cbad813f2b7cd554da942b09a7ff8f10/dev/web/flex-themes). -The implementation source, rather than the stale `contrast.d.ts` declaration, -is authoritative for the dark fallback surface (`#000000`). - -The pinned generated interaction table has ten values that do not come from -the default algorithm: four `backgroundNeutralTranslucent` values use the -Bebop Warm reference-backdrop solve, and six light-mode soft/subtle stroke -values use its chroma boost. They are listed with their default and warm values -in `x3InteractionConformance.test.fixture.ts`. The conformance gate verifies -all other generated values exactly and verifies each documented deviation -against both algorithms' expected value. - -Both capabilities are optional subpaths and are intentionally absent from the -design root barrel. The `design-theme-state` bundle-size scenario forbids their -source paths, while the `design-runtime-colors` and `design-contrast` scenarios -require their respective paths: - -```sh -yarn workspace @fluentui-react-native/bundle-size measure --platform macos -``` - -The utilities resolve hex, `rgb()`, and `rgba()` literals in JavaScript. -Opaque native values such as `PlatformColor` and `DynamicColorMacOS` produce -structured `unresolvable` diagnostics. Interaction derivation is a standard -contrast fallback only; callers must not apply it to high-contrast themes. - -The package Jest suite includes the pinned 118-value interaction fixture and -the contrast conformance gate: - -```sh -yarn workspace @fluentui-react-native/design test --runInBand -``` - -The combined interaction audit also records two pinned upstream failures in -dark pressed state: danger-primary on danger-soft and warning-primary on -warning-soft are below AA text contrast after both values shift. The test locks -those known failures and rejects any additional regression. FURN adds the -issue-required neutral-heavy-stroke pairing at `visibleStroke` (3:1) and -identifies it with `source: 'furn'`; the remaining pairings retain -`source: 'x3'`. diff --git a/packages/agentic/design/src/contrast/index.ts b/packages/agentic/design/src/contrast/index.ts deleted file mode 100644 index 0a11a208b1..0000000000 --- a/packages/agentic/design/src/contrast/index.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { ColorValue } from 'react-native'; - -import { compositeRgba, parseColorValue, rgbaToHex } from '../color/colorValue'; -import type { ColorDiagnostic, RgbaColor } from '../color/colorValue'; -import type { ThemeColorScheme } from '../theming/appearance.types'; -import type { SemanticColorTokenValues } from '../tokens/flex.types'; - -export type { ColorDiagnostic, ColorDiagnosticReason } from '../color/colorValue'; - -export const MODE_SURFACE = { - light: '#ffffff', - dark: '#000000', -} as const; - -export const WCAG = { - aaText: 4.5, - aaLargeText: 3, - aaaText: 7, - aaaLargeText: 4.5, - visibleStroke: 3, -} as const; - -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 PairResultBase { - readonly pairing: ContrastPairing; - readonly foreground: ContrastTokenReference; - readonly background: ContrastTokenReference; - readonly foregroundValue: ColorValue; - readonly backgroundValue: ColorValue; - readonly mode: ThemeColorScheme; -} - -export type PairResult = - | (PairResultBase & { - readonly status: 'pass' | 'fail'; - readonly ratio: number; - readonly foregroundResolved: string; - readonly backgroundResolved: string; - readonly diagnostics: readonly []; - }) - | (PairResultBase & { - readonly status: 'unresolvable'; - readonly ratio: null; - readonly foregroundResolved: null; - readonly backgroundResolved: null; - readonly diagnostics: readonly ContrastColorDiagnostic[]; - }); - -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), - }; -} - -/** - * Canonical x3 pairings adapted to FURN's camel-case token names. - * - * 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[]; - -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; -} - -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 over an opaque literal background. */ -export function composite(foreground: ColorValue, background: ColorValue): string { - const foregroundColor = requireLiteralColor(foreground, 'composite'); - const backgroundColor = requireLiteralColor(background, 'composite'); - if (backgroundColor.a !== 1) { - throw new TypeError('composite: 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); -} - -function resolveLiteralPair(foreground: RgbaColor, background: RgbaColor, surface: RgbaColor) { - const backgroundResolved = background.a === 1 ? background : opaqueComposite(background, surface); - const foregroundResolved = foreground.a === 1 ? foreground : opaqueComposite(foreground, 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), - }; -} - -export function contrastRatio(foreground: ColorValue, background: ColorValue, fallbackSurface: ColorValue = '#ffffff'): number { - const foregroundColor = requireLiteralColor(foreground, 'contrastRatio'); - const backgroundColor = requireLiteralColor(background, 'contrastRatio'); - const surfaceColor = requireLiteralColor(fallbackSurface, 'contrastRatio'); - if (surfaceColor.a !== 1) { - throw new TypeError('contrastRatio: the fallback surface must be opaque.'); - } - return resolveLiteralPair(foregroundColor, backgroundColor, surfaceColor).ratio; -} - -function diagnosticFor( - diagnostic: ColorDiagnostic, - tokenName: ContrastColorToken, - role: ContrastColorDiagnostic['role'], -): ContrastColorDiagnostic { - return { - ...diagnostic, - token: tokenName, - role, - }; -} - -export function resolvedPairs(colors: SemanticColorTokenValues, mode: ThemeColorScheme): PairResult[] { - const surface = requireLiteralColor(MODE_SURFACE[mode], 'resolvedPairs'); - const results: PairResult[] = []; - - 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 = resolveLiteralPair(foreground.color, background.color, surface); - results.push({ - ...base, - status: resolved.ratio >= pairing.minimumRatio ? 'pass' : 'fail', - diagnostics: [], - ...resolved, - }); - } - } - - return results; -} diff --git a/packages/agentic/design/src/runtime-colors/index.ts b/packages/agentic/design/src/runtime-colors/index.ts deleted file mode 100644 index 4b40e82ec4..0000000000 --- a/packages/agentic/design/src/runtime-colors/index.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { ColorValue } from 'react-native'; - -import { clamp, parseColorValue, rgbaToHex } from '../color/colorValue'; -import type { ColorDiagnostic, RgbaColor } from '../color/colorValue'; -import type { ThemeColorScheme } from '../theming/appearance.types'; -import type { InteractiveColorOverrides, SemanticColors, SemanticColorTokenValues } from '../tokens/flex.types'; - -export type { ColorDiagnostic, ColorDiagnosticReason } from '../color/colorValue'; - -export type InteractionColorState = 'hover' | 'pressed'; -export type InteractiveColorToken = keyof InteractiveColorOverrides; - -export interface RgbColor { - readonly r: number; - readonly g: number; - readonly b: number; -} - -export interface OklchColor { - readonly L: number; - readonly C: number; - readonly h: number; -} - -export interface InteractionColorDiagnostic extends ColorDiagnostic { - readonly token: InteractiveColorToken; - readonly state: InteractionColorState; -} - -export type DerivedInteractionColor = - | { - 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 DerivedInteractionColorsResult { - readonly colors: SemanticColors; - readonly diagnostics: readonly InteractionColorDiagnostic[]; -} - -/** - * 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[]; - -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; - -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; -} - -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, - }; -} - -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), - }; -} - -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); -} - -export function deriveInteractionColor( - colorValue: ColorValue, - token: InteractiveColorToken, - mode: ThemeColorScheme, - state: InteractionColorState, -): DerivedInteractionColor { - const parsed = parseColorValue(colorValue); - if (parsed.status === 'unresolvable') { - return { - status: 'unresolvable', - value: colorValue, - diagnostic: { - ...parsed.diagnostic, - token, - state, - }, - }; - } - - const oklch = rgbToOklch(parsed.color); - const delta = INTERACTION_COLOR_DELTAS[state][mode]; - const direction = isInverseInteractionToken(token) ? -1 : 1; - const rgb = oklchToRgb({ - ...oklch, - L: clamp(oklch.L + direction * delta.lightness * interactionCurveMultiplier(oklch.L)), - }); - const result: RgbaColor = { - ...rgb, - a: isInverseInteractionToken(token) ? parsed.color.a : clamp(parsed.color.a + delta.alpha), - }; - - return { - status: 'derived', - value: rgbaToHex(result), - }; -} - -export function deriveInteractionColorOverrides( - colors: SemanticColorTokenValues, - mode: ThemeColorScheme, - state: InteractionColorState, -): InteractionColorOverridesResult { - const overrides: InteractiveColorOverrides = {}; - const diagnostics: InteractionColorDiagnostic[] = []; - - for (const token of INTERACTIVE_COLOR_TOKENS) { - const result = deriveInteractionColor(colors[token], token, mode, state); - if (result.status === 'derived') { - overrides[token] = result.value; - } else { - diagnostics.push(result.diagnostic); - } - } - - return { overrides, diagnostics }; -} - -/** - * Fill missing interaction states without replacing authored values. - * - * Callers must use the resolved light/dark scheme and should not invoke this - * fallback for high-contrast themes. - */ -export function deriveInteractionColors(colors: InteractionColorInput, mode: ThemeColorScheme): DerivedInteractionColorsResult { - 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 = deriveInteractionColor(colors[token], token, mode, state); - 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/runtime-colors/runtimeColors.test.ts b/packages/agentic/design/src/runtime-colors/runtimeColors.test.ts deleted file mode 100644 index d2fb3fa86b..0000000000 --- a/packages/agentic/design/src/runtime-colors/runtimeColors.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { PlatformColor } from 'react-native'; - -import { x3InteractionConformance, x3WarmVariantDeviations } from '../color/x3InteractionConformance.test.fixture'; -import { defaultFlexTokens } from '../tokens/defaultTokens'; -import type { SemanticColorTokenValues } from '../tokens/flex.types'; -import { - deriveInteractionColor, - deriveInteractionColorOverrides, - deriveInteractionColors, - INTERACTIVE_COLOR_TOKENS, - isInverseInteractionToken, -} from './index'; - -const modeIndexes = { - light: { rest: 0, hover: 1, pressed: 2 }, - dark: { rest: 3, hover: 4, pressed: 5 }, -} as const; - -function conformanceColors(mode: 'light' | 'dark'): 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; -} - -describe.each(['light', 'dark'] as const)('%s interaction colors', (mode) => { - it.each(INTERACTIVE_COLOR_TOKENS)('matches x3 for %s', (token) => { - const vector = x3InteractionConformance[token]; - for (const state of ['hover', 'pressed'] as const) { - const result = deriveInteractionColor(vector[modeIndexes[mode].rest], token, mode, state); - const deviationKey = `${token}.${state}.${mode}` as keyof typeof x3WarmVariantDeviations; - const deviation = x3WarmVariantDeviations[deviationKey]; - const expected = deviation?.defaultValue ?? vector[modeIndexes[mode][state]]; - - expect(result).toEqual({ - status: 'derived', - value: expected, - }); - if (deviation) { - expect(vector[modeIndexes[mode][state]]).toBe(deviation.warmValue); - } - } - }); - - it('derives a complete override map', () => { - const result = deriveInteractionColorOverrides(conformanceColors(mode), mode, 'hover'); - - expect(result.diagnostics).toEqual([]); - expect(Object.keys(result.overrides)).toHaveLength(59); - }); -}); - -describe('interaction color contract', () => { - 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('supports rgb and rgba literals', () => { - expect(deriveInteractionColor('rgb(235 235 235)', 'surfaceNeutralFarther', 'light', 'hover')).toEqual({ - status: 'derived', - value: '#e1e1e1', - }); - expect(deriveInteractionColor('rgba(0, 0, 0, 0.05)', 'backgroundNeutralSubtle', 'light', 'hover')).toEqual({ - status: 'derived', - value: '#00000017', - }); - }); - - it('fills missing values without replacing authored interaction colors', () => { - const input = { - ...conformanceColors('light'), - hover: { surfaceNeutralFarther: '#123456' }, - }; - const result = deriveInteractionColors(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 = deriveInteractionColorOverrides(colors, 'light', 'hover').overrides; - const pressed = deriveInteractionColorOverrides(colors, 'light', 'pressed').overrides; - const input = { ...colors, hover, pressed }; - - expect(deriveInteractionColors(input, 'light').colors).toBe(input); - }); - - it('reports opaque platform colors instead of deriving a wrong value', () => { - const platformColor = PlatformColor('labelColor'); - const result = deriveInteractionColor(platformColor, 'foregroundNeutralPrimary', 'light', 'hover'); - - expect(result).toMatchObject({ - status: 'unresolvable', - diagnostic: { - reason: 'non-literal-color', - token: 'foregroundNeutralPrimary', - state: 'hover', - }, - }); - }); - - it('reports unsupported literal formats', () => { - const result = deriveInteractionColor('red', 'foregroundDangerPrimary', 'light', 'pressed'); - const malformed = deriveInteractionColor('rgb(12oops 0 0)', 'foregroundDangerPrimary', 'light', 'pressed'); - - expect(result).toMatchObject({ - status: 'unresolvable', - diagnostic: { - reason: 'unsupported-color-format', - }, - }); - expect(malformed).toMatchObject({ - status: 'unresolvable', - diagnostic: { - reason: 'unsupported-color-format', - }, - }); - }); -}); From 143e64760615cf613ae42534c6e5bc03e5dbfcbf Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 31 Aug 2026 16:38:48 -0700 Subject: [PATCH 3/6] remove extra public exports until needed --- packages/agentic/design/src/color-lib/index.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/agentic/design/src/color-lib/index.ts b/packages/agentic/design/src/color-lib/index.ts index 50e35e730b..fd24af8f2e 100644 --- a/packages/agentic/design/src/color-lib/index.ts +++ b/packages/agentic/design/src/color-lib/index.ts @@ -20,14 +20,4 @@ export type { export { parseColorValue, rgbaToHex } from './parsing'; export { oklchToRgb, rgbToOklch } from './conversion'; export { compositeColor, compositeRgba, getContrastRatio, resolveContrastColors } from './compositing'; -export { - BEBOP_WARM_REFERENCE_BACKDROP, - getHoverColor, - getInteractionColor, - getInteractionColorOverrides, - getInteractionColors, - getPressColor, - INTERACTION_COLOR_DELTAS, - INTERACTIVE_COLOR_TOKENS, - isInverseInteractionToken, -} from './interaction'; +export { getHoverColor, getInteractionColor, getPressColor, isInverseInteractionToken } from './interaction'; From 10e65a9e76c07d830308f78143ea05ef90d79c3f Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 31 Aug 2026 16:58:31 -0700 Subject: [PATCH 4/6] remove validation package scenarios and group validation code under testing --- .changeset/tidy-colors-audit.md | 5 ---- AGENTS.md | 2 ++ apps/bundle-size/baseline.json | 30 ------------------- apps/bundle-size/scenarios.json | 11 ++----- packages/agentic/design/package.json | 6 ---- .../agentic/design/src/color-lib/README.md | 8 +++-- .../design/src/color-lib/colorLib.test.ts | 6 ++-- .../design/src/color-validation/README.md | 18 ----------- .../design/src/color-validation/index.ts | 12 -------- packages/agentic/design/src/testing/README.md | 26 ++++++++++++++++ .../color-validation/constants.ts | 2 +- .../color-validation/pairings.ts | 2 +- .../{ => testing}/color-validation/types.ts | 4 +-- .../color-validation/validation.test.ts | 13 ++++---- .../color-validation/validation.ts | 6 ++-- .../x3InteractionConformance.ts | 2 +- packages/agentic/design/src/testing/index.ts | 12 ++++++++ 17 files changed, 64 insertions(+), 101 deletions(-) delete mode 100644 .changeset/tidy-colors-audit.md delete mode 100644 packages/agentic/design/src/color-validation/README.md delete mode 100644 packages/agentic/design/src/color-validation/index.ts create mode 100644 packages/agentic/design/src/testing/README.md rename packages/agentic/design/src/{ => testing}/color-validation/constants.ts (78%) rename packages/agentic/design/src/{ => testing}/color-validation/pairings.ts (98%) rename packages/agentic/design/src/{ => testing}/color-validation/types.ts (91%) rename packages/agentic/design/src/{ => testing}/color-validation/validation.test.ts (93%) rename packages/agentic/design/src/{ => testing}/color-validation/validation.ts (93%) rename packages/agentic/design/src/{color-validation/testing => testing/color-validation}/x3InteractionConformance.ts (99%) diff --git a/.changeset/tidy-colors-audit.md b/.changeset/tidy-colors-audit.md deleted file mode 100644 index 3fb2c358c7..0000000000 --- a/.changeset/tidy-colors-audit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@fluentui-react-native/design": minor ---- - -Add an opt-in runtime color library and a separate test-focused color validation submodule. 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 f33f6bfc3f..bbd06d7a67 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -82,21 +82,6 @@ "@fluentui-react-native/design": 6213 } }, - { - "platform": "macos", - "scenario": "design-color-validation", - "rawBytes": 827655, - "gzipBytes": 238605, - "moduleCount": 505, - "metroModuleCount": 503, - "metafileInputCount": 507, - "workspaceModules": { - "@fluentui-react-native/design": 8 - }, - "workspaceBytes": { - "@fluentui-react-native/design": 7012 - } - }, { "platform": "macos", "scenario": "design-theme-state-legacy-source", @@ -212,21 +197,6 @@ "@fluentui-react-native/design": 6213 } }, - { - "platform": "windows", - "scenario": "design-color-validation", - "rawBytes": 836790, - "gzipBytes": 240087, - "moduleCount": 510, - "metroModuleCount": 508, - "metafileInputCount": 512, - "workspaceModules": { - "@fluentui-react-native/design": 8 - }, - "workspaceBytes": { - "@fluentui-react-native/design": 7012 - } - }, { "platform": "windows", "scenario": "design-theme-state-legacy-source", diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json index ad7f93703b..280a192da9 100644 --- a/apps/bundle-size/scenarios.json +++ b/apps/bundle-size/scenarios.json @@ -18,20 +18,13 @@ "name": "design-theme-state", "module": "@fluentui-react-native/design", "exports": ["FlexThemeReference", "ThemeProvider", "useThemeState"], - "forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/", "/color-lib/", "/color-validation/"] + "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."], - "forbiddenInputPatterns": ["/color-validation/"] - }, - { - "name": "design-color-validation", - "module": "@fluentui-react-native/design/color-validation", - "exports": ["validateContrastPairs"], - "requiredInputPatterns": ["/color-validation/validation.", "/color-lib/compositing.", "/color-lib/parsing."] + "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 5090ee5b30..2b4ea2016b 100644 --- a/packages/agentic/design/package.json +++ b/packages/agentic/design/package.json @@ -39,12 +39,6 @@ "import": "./lib/color-lib/index.js", "default": "./src/color-lib/index.ts" }, - "./color-validation": { - "types": "./lib/color-validation/index.d.ts", - "react-native": "./src/color-validation/index.ts", - "import": "./lib/color-validation/index.js", - "default": "./src/color-validation/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 index fc0c6bf6c9..71dc193552 100644 --- a/packages/agentic/design/src/color-lib/README.md +++ b/packages/agentic/design/src/color-lib/README.md @@ -29,5 +29,9 @@ const hover = getHoverColor('#c02e56', 'backgroundDangerLoud', 'light', { const ratio = getContrastRatio('#000000db', '#ffffff'); ``` -The submodule is absent from the design root barrel and does not import -`color-validation`. +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 index 785edccbe5..af04395a6c 100644 --- a/packages/agentic/design/src/color-lib/colorLib.test.ts +++ b/packages/agentic/design/src/color-lib/colorLib.test.ts @@ -4,7 +4,7 @@ import { x3BebopWarmInteractionConformance, x3InteractionConformance, x3WarmVariantDeviations, -} from '../color-validation/testing/x3InteractionConformance'; +} from '../testing/color-validation/x3InteractionConformance'; import { defaultFlexTokens } from '../tokens/defaultTokens'; import type { SemanticColorTokenValues } from '../tokens/flex.types'; import { @@ -13,10 +13,7 @@ import { getContrastRatio, getHoverColor, getInteractionColor, - getInteractionColorOverrides, - getInteractionColors, getPressColor, - INTERACTIVE_COLOR_TOKENS, isInverseInteractionToken, oklchToRgb, parseColorValue, @@ -25,6 +22,7 @@ import { 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 }, diff --git a/packages/agentic/design/src/color-validation/README.md b/packages/agentic/design/src/color-validation/README.md deleted file mode 100644 index f8be972cce..0000000000 --- a/packages/agentic/design/src/color-validation/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Color validation - -`@fluentui-react-native/design/color-validation` contains test and design-tool -policy built on `color-lib`: WCAG thresholds, canonical semantic token -pairings, structured pair audits, failure filters, unresolvable-color filters, -and pinned conformance fixtures. - -This submodule is intentionally not a component runtime dependency. Product -code that only needs parsing, conversion, interaction generation, compositing, -or a direct contrast ratio should import `color-lib`. Bundle scenarios enforce -that the design root and `color-lib` do not load `color-validation`. - -The validation suite audits rest, hover, and pressed values for both default -and Bebop Warm interaction variants. The pinned default theme has two dark -pressed failures: danger-primary on danger-soft and warning-primary on -warning-soft. The pinned Bebop Warm theme additionally fails danger-primary on -neutral-nearer and danger-subtle in that state. The tests make those existing -results explicit and reject additional regressions. diff --git a/packages/agentic/design/src/color-validation/index.ts b/packages/agentic/design/src/color-validation/index.ts deleted file mode 100644 index c80ba5803b..0000000000 --- a/packages/agentic/design/src/color-validation/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { - ContrastColorDiagnostic, - ContrastColorToken, - ContrastPairing, - ContrastPairResult, - ContrastTokenReference, - ResolvedContrastPairResult, - UnresolvableContrastPairResult, -} from './types'; -export { MODE_SURFACE, WCAG } from './constants'; -export { PAIRINGS } from './pairings'; -export { getContrastFailures, getUnresolvableContrastPairs, validateContrastPairs } from './validation'; 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/color-validation/constants.ts b/packages/agentic/design/src/testing/color-validation/constants.ts similarity index 78% rename from packages/agentic/design/src/color-validation/constants.ts rename to packages/agentic/design/src/testing/color-validation/constants.ts index 9841127ca4..aa4a2ec835 100644 --- a/packages/agentic/design/src/color-validation/constants.ts +++ b/packages/agentic/design/src/testing/color-validation/constants.ts @@ -1,4 +1,4 @@ -/** Fallback surface colors used to resolve translucent validation pairs. */ +/** Test fallback surfaces used to resolve translucent validation pairs. */ export const MODE_SURFACE = { light: '#ffffff', dark: '#000000', diff --git a/packages/agentic/design/src/color-validation/pairings.ts b/packages/agentic/design/src/testing/color-validation/pairings.ts similarity index 98% rename from packages/agentic/design/src/color-validation/pairings.ts rename to packages/agentic/design/src/testing/color-validation/pairings.ts index 0ff983ebd7..053d26f32d 100644 --- a/packages/agentic/design/src/color-validation/pairings.ts +++ b/packages/agentic/design/src/testing/color-validation/pairings.ts @@ -13,7 +13,7 @@ function token(tokenName: ContrastColorToken): ContrastTokenReference { } /** - * Canonical x3 contrast pairings adapted to FURN token names, plus the + * 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. diff --git a/packages/agentic/design/src/color-validation/types.ts b/packages/agentic/design/src/testing/color-validation/types.ts similarity index 91% rename from packages/agentic/design/src/color-validation/types.ts rename to packages/agentic/design/src/testing/color-validation/types.ts index 64630a0cd3..794f082ede 100644 --- a/packages/agentic/design/src/color-validation/types.ts +++ b/packages/agentic/design/src/testing/color-validation/types.ts @@ -1,7 +1,7 @@ import type { ColorValue } from 'react-native'; -import type { ColorDiagnostic, ColorMode } from '../color-lib'; -import type { SemanticColorTokenValues } from '../tokens/flex.types'; +import type { ColorDiagnostic, ColorMode } from '../../color-lib'; +import type { SemanticColorTokenValues } from '../../tokens/flex.types'; export type ContrastColorToken = keyof SemanticColorTokenValues; diff --git a/packages/agentic/design/src/color-validation/validation.test.ts b/packages/agentic/design/src/testing/color-validation/validation.test.ts similarity index 93% rename from packages/agentic/design/src/color-validation/validation.test.ts rename to packages/agentic/design/src/testing/color-validation/validation.test.ts index ffcf3a6f06..fe9bca69f7 100644 --- a/packages/agentic/design/src/color-validation/validation.test.ts +++ b/packages/agentic/design/src/testing/color-validation/validation.test.ts @@ -1,12 +1,11 @@ import { PlatformColor } from 'react-native'; -import { getInteractionColorOverrides, INTERACTIVE_COLOR_TOKENS } from '../color-lib'; -import type { ColorMode, InteractionColorState, InteractionColorVariant } from '../color-lib'; -import { defaultFlexTokens } from '../tokens/defaultTokens'; -import type { SemanticColorTokenValues } from '../tokens/flex.types'; -import { MODE_SURFACE, PAIRINGS, WCAG } from './index'; -import { getContrastFailures, getUnresolvableContrastPairs, validateContrastPairs } from './index'; -import { x3BebopWarmInteractionConformance, x3InteractionConformance } from './testing/x3InteractionConformance'; +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, diff --git a/packages/agentic/design/src/color-validation/validation.ts b/packages/agentic/design/src/testing/color-validation/validation.ts similarity index 93% rename from packages/agentic/design/src/color-validation/validation.ts rename to packages/agentic/design/src/testing/color-validation/validation.ts index e722d4b067..b33cd1df6a 100644 --- a/packages/agentic/design/src/color-validation/validation.ts +++ b/packages/agentic/design/src/testing/color-validation/validation.ts @@ -1,6 +1,6 @@ -import { parseColorValue, resolveContrastColors } from '../color-lib'; -import type { ColorDiagnostic, ColorMode } from '../color-lib'; -import type { SemanticColorTokenValues } from '../tokens/flex.types'; +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 { diff --git a/packages/agentic/design/src/color-validation/testing/x3InteractionConformance.ts b/packages/agentic/design/src/testing/color-validation/x3InteractionConformance.ts similarity index 99% rename from packages/agentic/design/src/color-validation/testing/x3InteractionConformance.ts rename to packages/agentic/design/src/testing/color-validation/x3InteractionConformance.ts index ad2ac5811c..71cb11f9ce 100644 --- a/packages/agentic/design/src/color-validation/testing/x3InteractionConformance.ts +++ b/packages/agentic/design/src/testing/color-validation/x3InteractionConformance.ts @@ -10,7 +10,7 @@ export type InteractionConformanceVector = readonly [ ]; /** - * Base and interaction fallback values from the pinned x3 source commit. + * 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 = { 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'; From 4eb20550a099400e515ae24d3809c2551cc1faaf Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 31 Aug 2026 17:12:18 -0700 Subject: [PATCH 5/6] add changeset --- .changeset/metal-hands-chew.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/metal-hands-chew.md 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 From 139035f80d78d8ea2a8bcdcd10335c57a299a9f3 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 31 Aug 2026 17:26:08 -0700 Subject: [PATCH 6/6] Harden RGB color parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/bundle-size/baseline.json | 12 +- .../design/src/color-lib/colorLib.test.ts | 39 ++++ .../agentic/design/src/color-lib/parsing.ts | 188 ++++++++++++++---- 3 files changed, 195 insertions(+), 44 deletions(-) diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json index bbd06d7a67..abf8eb3ce7 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -70,8 +70,8 @@ { "platform": "macos", "scenario": "design-color-lib", - "rawBytes": 826875, - "gzipBytes": 239247, + "rawBytes": 827767, + "gzipBytes": 239469, "moduleCount": 503, "metroModuleCount": 501, "metafileInputCount": 503, @@ -79,7 +79,7 @@ "@fluentui-react-native/design": 6 }, "workspaceBytes": { - "@fluentui-react-native/design": 6213 + "@fluentui-react-native/design": 7097 } }, { @@ -185,8 +185,8 @@ { "platform": "windows", "scenario": "design-color-lib", - "rawBytes": 836010, - "gzipBytes": 240687, + "rawBytes": 836902, + "gzipBytes": 240953, "moduleCount": 508, "metroModuleCount": 506, "metafileInputCount": 508, @@ -194,7 +194,7 @@ "@fluentui-react-native/design": 6 }, "workspaceBytes": { - "@fluentui-react-native/design": 6213 + "@fluentui-react-native/design": 7097 } }, { diff --git a/packages/agentic/design/src/color-lib/colorLib.test.ts b/packages/agentic/design/src/color-lib/colorLib.test.ts index af04395a6c..6c6f4f2757 100644 --- a/packages/agentic/design/src/color-lib/colorLib.test.ts +++ b/packages/agentic/design/src/color-lib/colorLib.test.ts @@ -64,6 +64,45 @@ describe('color parsing and conversion', () => { }); }); + 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'); diff --git a/packages/agentic/design/src/color-lib/parsing.ts b/packages/agentic/design/src/color-lib/parsing.ts index b46a562dcb..fb171348f0 100644 --- a/packages/agentic/design/src/color-lib/parsing.ts +++ b/packages/agentic/design/src/color-lib/parsing.ts @@ -4,8 +4,11 @@ import { channelToByte } from './math'; import type { ParsedColorValue, RgbaColor } from './types'; const HEX_COLOR = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i; -const RGB_COLOR = /^(rgba?)\((.*)\)$/i; -const NUMERIC_CHANNEL = /^(?:\d+(?:\.\d*)?|\.\d+)%?$/; + +interface ParsedChannel { + readonly value: number; + readonly nextIndex: number; +} function parseHexColor(value: string): RgbaColor | undefined { const match = HEX_COLOR.exec(value); @@ -26,69 +29,178 @@ function parseHexColor(value: string): RgbaColor | undefined { }; } -function parseRgbChannel(value: string): number | undefined { - if (!NUMERIC_CHANNEL.test(value)) { +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 percentage = value.endsWith('%'); - const parsed = Number.parseFloat(value); - const maximum = percentage ? 100 : 255; - if (!Number.isFinite(parsed) || parsed < 0 || parsed > maximum) { + + 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 parsed / maximum; + + 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 parseAlphaChannel(value: string): number | undefined { - if (!NUMERIC_CHANNEL.test(value)) { +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; } - const percentage = value.endsWith('%'); - const parsed = Number.parseFloat(value); - const maximum = percentage ? 100 : 1; - if (!Number.isFinite(parsed) || parsed < 0 || parsed > maximum) { + + index = skipWhitespace(value, index + 1, endIndex); + const blue = parseRgbChannel(value, index, endIndex); + if (!blue) { return undefined; } - return parsed / maximum; + + 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 parseRgbColor(value: string): RgbaColor | undefined { - const match = RGB_COLOR.exec(value); - if (!match) { +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 functionName = match[1].toLowerCase(); - const slashParts = match[2].trim().split(/\s*\/\s*/); - if (slashParts.length > 2) { + const blue = parseRgbChannel(value, index, endIndex); + if (!blue) { return undefined; } - const commaSyntax = slashParts[0].includes(','); - const parts = commaSyntax ? slashParts[0].split(/\s*,\s*/) : slashParts[0].trim().split(/\s+/); - let alphaPart = slashParts[1]; - if (commaSyntax && parts.length === 4) { - if (alphaPart !== 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; } - alphaPart = parts.pop(); + alpha = parsedAlpha.value; + index = skipWhitespace(value, parsedAlpha.nextIndex, endIndex); + } else if (alphaRequired) { + return undefined; } - if (parts.length !== 3 || (functionName === 'rgba' && alphaPart === 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; } - const channels = parts.map(parseRgbChannel); - const alpha = alphaPart === undefined ? 1 : parseAlphaChannel(alphaPart); - if (channels.some((channel) => channel === undefined) || alpha === 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; } - return { - r: channels[0], - g: channels[1], - b: channels[2], - a: alpha, - }; + 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); } /**