Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metal-hands-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fluentui-react-native/design": patch
---

Add submodule for color utilities for automatic hover and press coloring
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions apps/bundle-size/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@
"@fluentui-react-native/framework-base": 3413
}
},
{
"platform": "macos",
"scenario": "design-color-lib",
"rawBytes": 827767,
"gzipBytes": 239469,
"moduleCount": 503,
"metroModuleCount": 501,
"metafileInputCount": 503,
"workspaceModules": {
"@fluentui-react-native/design": 6
},
"workspaceBytes": {
"@fluentui-react-native/design": 7097
}
},
{
"platform": "macos",
"scenario": "design-theme-state-legacy-source",
Expand Down Expand Up @@ -167,6 +182,21 @@
"@fluentui-react-native/framework-base": 3413
}
},
{
"platform": "windows",
"scenario": "design-color-lib",
"rawBytes": 836902,
"gzipBytes": 240953,
"moduleCount": 508,
"metroModuleCount": 506,
"metafileInputCount": 508,
"workspaceModules": {
"@fluentui-react-native/design": 6
},
"workspaceBytes": {
"@fluentui-react-native/design": 7097
}
},
{
"platform": "windows",
"scenario": "design-theme-state-legacy-source",
Expand Down
8 changes: 7 additions & 1 deletion apps/bundle-size/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@
"name": "design-theme-state",
"module": "@fluentui-react-native/design",
"exports": ["FlexThemeReference", "ThemeProvider", "useThemeState"],
"forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/"]
"forbiddenInputPatterns": ["/theming/compat/", "/tokens/legacy/", "/color-lib/"]
},
{
"name": "design-color-lib",
"module": "@fluentui-react-native/design/color-lib",
"exports": ["getContrastRatio", "getHoverColor", "getPressColor"],
"requiredInputPatterns": ["/color-lib/compositing.", "/color-lib/conversion.", "/color-lib/interaction.", "/color-lib/parsing."]
},
{
"name": "design-theme-state-legacy-source",
Expand Down
6 changes: 6 additions & 0 deletions packages/agentic/design/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
"import": "./lib/tokens/legacy/index.js",
"default": "./src/tokens/legacy/index.ts"
},
"./color-lib": {
"types": "./lib/color-lib/index.d.ts",
"react-native": "./src/color-lib/index.ts",
"import": "./lib/color-lib/index.js",
"default": "./src/color-lib/index.ts"
},
"./theming": {
"types": "./lib/theming/index.d.ts",
"react-native": "./src/theming/index.ts",
Expand Down
37 changes: 37 additions & 0 deletions packages/agentic/design/src/color-lib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Color library

`@fluentui-react-native/design/color-lib` is the opt-in runtime color engine.
It groups public utilities by responsibility:

- `parsing.ts` parses literal React Native colors and formats RGBA values;
- `conversion.ts` converts between normalized sRGB and OKLCH;
- `compositing.ts` composites alpha colors and calculates WCAG contrast;
- `interaction.ts` generates hover and pressed colors with the default or
Bebop Warm algorithm.

Native and dynamic color objects cannot be resolved in JavaScript, so parsing
and interaction APIs return structured diagnostics instead of fabricated
colors. Direct compositing and contrast operations throw descriptive errors
for unsupported values.

The algorithms are ported from
[`x3-design/fluent-design@d334acf5cbad813f2b7cd554da942b09a7ff8f10`](https://github.com/x3-design/fluent-design/tree/d334acf5cbad813f2b7cd554da942b09a7ff8f10/dev/web/flex-themes).
The default variant preserves chroma. The `bebopWarm` variant adds the pinned
opacity-aware lightness, chroma, bounds, and translucent reference-backdrop
solve.

```ts
import { getContrastRatio, getHoverColor } from '@fluentui-react-native/design/color-lib';

const hover = getHoverColor('#c02e56', 'backgroundDangerLoud', 'light', {
variant: 'bebopWarm',
});
const ratio = getContrastRatio('#000000db', '#ffffff');
```

The public interaction surface is intentionally limited to generating one
hover or pressed value at a time. Bulk fallback helpers and algorithm constants
remain internal until a production consumer needs them.

The submodule is absent from the design root barrel and does not import the
test-only `testing` submodule.
264 changes: 264 additions & 0 deletions packages/agentic/design/src/color-lib/colorLib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { PlatformColor } from 'react-native';

import {
x3BebopWarmInteractionConformance,
x3InteractionConformance,
x3WarmVariantDeviations,
} from '../testing/color-validation/x3InteractionConformance';
import { defaultFlexTokens } from '../tokens/defaultTokens';
import type { SemanticColorTokenValues } from '../tokens/flex.types';
import {
compositeColor,
compositeRgba,
getContrastRatio,
getHoverColor,
getInteractionColor,
getPressColor,
isInverseInteractionToken,
oklchToRgb,
parseColorValue,
resolveContrastColors,
rgbaToHex,
rgbToOklch,
} from './index';
import type { ColorMode, InteractionColorState } from './index';
import { getInteractionColorOverrides, getInteractionColors, INTERACTIVE_COLOR_TOKENS } from './interaction';

const modeIndexes = {
light: { rest: 0, hover: 1, pressed: 2 },
dark: { rest: 3, hover: 4, pressed: 5 },
} as const;

function conformanceColors(mode: ColorMode): SemanticColorTokenValues {
const { hover: _hover, pressed: _pressed, ...colors } = defaultFlexTokens.color;
for (const token of INTERACTIVE_COLOR_TOKENS) {
colors[token] = x3InteractionConformance[token][modeIndexes[mode].rest];
}
return colors;
}

function getStateColor(
state: InteractionColorState,
color: string,
token: (typeof INTERACTIVE_COLOR_TOKENS)[number],
mode: ColorMode,
variant: 'default' | 'bebopWarm',
) {
return state === 'hover' ? getHoverColor(color, token, mode, { variant }) : getPressColor(color, token, mode, { variant });
}

describe('color parsing and conversion', () => {
it('parses supported literal formats', () => {
expect(parseColorValue('#1238')).toEqual({
status: 'resolved',
color: {
r: 0x11 / 255,
g: 0x22 / 255,
b: 0x33 / 255,
a: 0x88 / 255,
},
});
expect(parseColorValue('rgb(100% 0% 50% / 25%)')).toEqual({
status: 'resolved',
color: { r: 1, g: 0, b: 0.5, a: 0.25 },
});
});

it.each([
['RGB(255, 128, 0)', { r: 1, g: 128 / 255, b: 0, a: 1 }],
['rgba(255, 128, 0, .5)', { r: 1, g: 128 / 255, b: 0, a: 0.5 }],
['rgba(255 128 0 / 50%)', { r: 1, g: 128 / 255, b: 0, a: 0.5 }],
['rgb(255, 128, 0 / 50%)', { r: 1, g: 128 / 255, b: 0, a: 0.5 }],
['rgb(1. 2.5 .5)', { r: 1 / 255, g: 2.5 / 255, b: 0.5 / 255, a: 1 }],
])('parses %s without regular expressions', (value, color) => {
expect(parseColorValue(value)).toEqual({ status: 'resolved', color });
});

it.each(['rgba(0 0 0)', 'rgb(0 0 0 / 50% / 25%)', 'rgb(0, 0 0)', 'rgb(256 0 0)', 'rgb(0 0 0 / 101%)'])(
'rejects malformed literal %s',
(value) => {
expect(parseColorValue(value)).toMatchObject({
status: 'unresolvable',
diagnostic: { reason: 'unsupported-color-format' },
});
},
);

it('rejects long invalid input without a backtracking expression', () => {
const value = `rgb(${' '.repeat(100_000)}x)`;

expect(parseColorValue(value)).toMatchObject({
status: 'unresolvable',
diagnostic: { reason: 'unsupported-color-format' },
});
});

it('preserves native numeric conversion at byte-rounding boundaries', () => {
const result = parseColorValue('rgba(0, 0, 0, 0.00588235294117647)');

expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.color.a).toBe(0.00588235294117647);
expect(rgbaToHex(result.color)).toBe('#00000001');
}
});

it('formats RGBA and round-trips RGB through OKLCH', () => {
expect(rgbaToHex({ r: 1, g: 0.5, b: 0, a: 0.5 })).toBe('#ff800080');

const source = { r: 0.1, g: 0.4, b: 0.8 };
const roundTrip = oklchToRgb(rgbToOklch(source));
expect(roundTrip.r).toBeCloseTo(source.r, 6);
expect(roundTrip.g).toBeCloseTo(source.g, 6);
expect(roundTrip.b).toBeCloseTo(source.b, 6);
});

it('returns diagnostics for unsupported and native colors', () => {
expect(parseColorValue('red')).toMatchObject({
status: 'unresolvable',
diagnostic: { reason: 'unsupported-color-format' },
});
expect(parseColorValue(PlatformColor('labelColor'))).toMatchObject({
status: 'unresolvable',
diagnostic: { reason: 'non-literal-color' },
});
});
});

describe('color compositing and contrast', () => {
it('composites normalized and literal colors', () => {
expect(compositeRgba({ r: 0, g: 0, b: 0, a: 0.5 }, { r: 1, g: 1, b: 1, a: 1 })).toEqual({
r: 0.5,
g: 0.5,
b: 0.5,
a: 1,
});
expect(compositeColor('#0000000d', '#ffffff')).toBe('#f2f2f2');
expect(compositeColor('rgba(255 0 0 / 50%)', '#000000')).toBe('#800000');
});

it('resolves effective colors and their WCAG ratio', () => {
expect(resolveContrastColors('#00000080', '#ffffff')).toEqual({
ratio: expect.closeTo(4.0041, 4),
foregroundResolved: '#7f7f7f',
backgroundResolved: '#ffffff',
});
expect(getContrastRatio('#000000', '#ffffff')).toBe(21);
});

it('rejects unsupported direct operations', () => {
expect(() => compositeColor('red', '#ffffff')).toThrow('Unsupported color literal');
expect(() => compositeColor('#00000080', '#ffffff80')).toThrow('background color must be opaque');
expect(() => getContrastRatio('#000000', '#ffffff', '#ffffff80')).toThrow('fallback surface must be opaque');
});
});

describe.each(['light', 'dark'] as const)('%s interaction colors', (mode) => {
it.each(INTERACTIVE_COLOR_TOKENS)('matches default and Bebop Warm output for %s', (token) => {
const vector = x3InteractionConformance[token];
const warmVector = x3BebopWarmInteractionConformance[token];
for (const state of ['hover', 'pressed'] as const) {
const deviationKey = `${token}.${state}.${mode}` as keyof typeof x3WarmVariantDeviations;
const deviation = x3WarmVariantDeviations[deviationKey];
const upstreamValue = vector[modeIndexes[mode][state]];
const warmValue = warmVector[modeIndexes[mode][state]];

expect(getStateColor(state, vector[modeIndexes[mode].rest], token, mode, 'default')).toEqual({
status: 'derived',
value: deviation?.defaultValue ?? upstreamValue,
});
expect(getStateColor(state, warmVector[modeIndexes[mode].rest], token, mode, 'bebopWarm')).toEqual({
status: 'derived',
value: warmValue,
});
if (deviation) {
expect(upstreamValue).toBe(deviation.warmValue);
}
}
});

it('generates a complete override map', () => {
const colors = conformanceColors(mode);
for (const token of INTERACTIVE_COLOR_TOKENS) {
colors[token] = x3BebopWarmInteractionConformance[token][modeIndexes[mode].rest];
}
const result = getInteractionColorOverrides(colors, mode, 'hover', { variant: 'bebopWarm' });

expect(result.diagnostics).toEqual([]);
expect(Object.keys(result.overrides)).toHaveLength(59);
});
});

describe('interaction color API', () => {
it('classifies every inverse token with the upstream heavy/loud/onloud rule', () => {
const expected = [
'backgroundNeutralHeavy',
'backgroundNeutralLoud',
'backgroundBrandHeavy',
'backgroundBrandLoud',
'backgroundDangerLoud',
'backgroundWarningLoud',
'backgroundSuccessLoud',
'strokeNeutralHeavy',
'strokeNeutralLoud',
'strokeNeutralOnloud',
'strokeBrandLoud',
'strokeBrandOnloud',
'strokeDangerLoud',
'strokeDangerOnloud',
'strokeWarningLoud',
'strokeWarningOnloud',
'strokeSuccessLoud',
'strokeSuccessOnloud',
'foregroundNeutralOnloud',
'foregroundBrandOnloud',
'foregroundDangerOnloud',
'foregroundWarningOnloud',
'foregroundSuccessOnloud',
];

expect(INTERACTIVE_COLOR_TOKENS.filter(isInverseInteractionToken)).toEqual(expected);
expect(INTERACTIVE_COLOR_TOKENS).toHaveLength(59);
expect(Object.keys(x3WarmVariantDeviations)).toHaveLength(10);
});

it('fills missing values without replacing authored interaction colors', () => {
const input = {
...conformanceColors('light'),
hover: { surfaceNeutralFarther: '#123456' },
};
const result = getInteractionColors(input, 'light');

expect(result.colors.hover.surfaceNeutralFarther).toBe('#123456');
expect(result.colors.hover.surfaceNeutralFar).toBe('#e8e8e8');
expect(result.colors.pressed.surfaceNeutralFarther).toBe('#d7d7d7');
expect(result.diagnostics).toEqual([]);
});

it('preserves identity when every interaction value is authored', () => {
const colors = conformanceColors('light');
const hover = getInteractionColorOverrides(colors, 'light', 'hover').overrides;
const pressed = getInteractionColorOverrides(colors, 'light', 'pressed').overrides;
const input = { ...colors, hover, pressed };

expect(getInteractionColors(input, 'light').colors).toBe(input);
});

it('reports opaque platform colors instead of deriving a wrong value', () => {
expect(getInteractionColor(PlatformColor('labelColor'), 'foregroundNeutralPrimary', 'light', 'hover')).toMatchObject({
status: 'unresolvable',
diagnostic: {
reason: 'non-literal-color',
token: 'foregroundNeutralPrimary',
state: 'hover',
},
});
});

it('reports malformed literals', () => {
expect(getPressColor('rgb(12oops 0 0)', 'foregroundDangerPrimary', 'light')).toMatchObject({
status: 'unresolvable',
diagnostic: { reason: 'unsupported-color-format' },
});
});
});
Loading
Loading