From 74663073062bfbb667f711bbab8b1c22505d9d2d Mon Sep 17 00:00:00 2001 From: ZivSa1 Date: Thu, 26 Feb 2026 14:58:47 +0200 Subject: [PATCH] Enhance color palette generation with saturation curve options - Updated GeneratePaletteOptions to include saturationCurve, saturationThreshold, and saturationFloor for improved saturation adjustments. - Refactored adjustSaturation function to utilize the new saturation curve logic. - Modified color tint tests to reflect new expected values based on saturation adjustments. - Added tests to ensure saturation curve behavior respects thresholds and custom floor values. --- .../src/style/__tests__/colors.spec.js | 73 ++++++++--------- .../react-native-ui-lib/src/style/colors.ts | 79 ++++++++----------- 2 files changed, 67 insertions(+), 85 deletions(-) diff --git a/packages/react-native-ui-lib/src/style/__tests__/colors.spec.js b/packages/react-native-ui-lib/src/style/__tests__/colors.spec.js index 46aeaec9ce..94699c9efe 100644 --- a/packages/react-native-ui-lib/src/style/__tests__/colors.spec.js +++ b/packages/react-native-ui-lib/src/style/__tests__/colors.spec.js @@ -92,46 +92,33 @@ describe('style/Colors', () => { }); it('should handle color that does not exist in `uilib`', () => { - expect(uut.getColorTint('#F1BE0B', 10)).toEqual('#8D7006'); // - expect(uut.getColorTint('#F1BE0B', 20)).toEqual('#BE9609'); // - expect(uut.getColorTint('#F1BE0B', 30)).toEqual('#F1BE0B'); // - expect(uut.getColorTint('#F1BE0B', 40)).toEqual('#F6CC37'); // - expect(uut.getColorTint('#F1BE0B', 50)).toEqual('#F8D868'); // - expect(uut.getColorTint('#F1BE0B', 60)).toEqual('#FAE599'); // - expect(uut.getColorTint('#F1BE0B', 70)).toEqual('#FDF1C9'); // - expect(uut.getColorTint('#F1BE0B', 80)).toEqual('#FFFEFA'); // + expect(uut.getColorTint('#F1BE0B', 10)).toEqual('#7E6715'); + expect(uut.getColorTint('#F1BE0B', 20)).toEqual('#B59112'); + expect(uut.getColorTint('#F1BE0B', 30)).toEqual('#F1BE0B'); + expect(uut.getColorTint('#F1BE0B', 40)).toEqual('#ECC741'); + expect(uut.getColorTint('#F1BE0B', 50)).toEqual('#E8CF78'); + expect(uut.getColorTint('#F1BE0B', 60)).toEqual('#EADCA9'); + expect(uut.getColorTint('#F1BE0B', 70)).toEqual('#F1EBD5'); + expect(uut.getColorTint('#F1BE0B', 80)).toEqual('#FEFDFB'); }); it('should round down tint level to the nearest one', () => { - expect(uut.getColorTint('#F1BE0B', 75)).toEqual('#FDF1C9'); - expect(uut.getColorTint('#F1BE0B', 25)).toEqual('#BE9609'); + expect(uut.getColorTint('#F1BE0B', 75)).toEqual('#F1EBD5'); + expect(uut.getColorTint('#F1BE0B', 25)).toEqual('#B59112'); expect(uut.getColorTint('#F1BE0B', 35)).toEqual('#F1BE0B'); }); it('should handle out of range tint levels and round them to the nearest one in range', () => { - expect(uut.getColorTint('#F1BE0B', 3)).toEqual('#8D7006'); - expect(uut.getColorTint('#F1BE0B', 95)).toEqual('#FFFEFA'); + expect(uut.getColorTint('#F1BE0B', 3)).toEqual('#7E6715'); + expect(uut.getColorTint('#F1BE0B', 95)).toEqual('#FEFDFB'); }); }); describe('generateColorPalette', () => { const baseColor = '#3F88C5'; - const tints = ['#193852', '#255379', '#316EA1', '#3F88C5', '#66A0D1', '#8DB9DD', '#B5D1E9', '#DCE9F4']; + const tints = ['#233748', '#2E5270', '#376E9B', '#3F88C5', '#6CA0CB', '#96B8D4', '#BED0E0', '#E1E9EF']; const baseColorLight = '#DCE9F4'; const tintsLight = ['#1A3851', '#265278', '#326D9F', '#4187C3', '#68A0CF', '#8EB8DC', '#B5D1E8', '#DCE9F4']; - const saturationLevels = [-10, -10, -20, -20, -25, -25, -25, -25, -20, -10]; - const tintsSaturationLevels = [ - '#1E384D', - '#2D5271', - '#466C8C', - '#3F88C5', - '#7F9EB8', - '#A0B7CB', - '#C1D0DD', - '#E2E9EE' - ]; - // const tintsSaturationLevelsDarkest = ['#162837', '#223F58', '#385770', '#486E90', '#3F88C5', '#7C9CB6', '#9AB2C6', '#B7C9D7', '#D3DFE9', '#F0F5F9']; - // const tintsAddDarkestTints = ['#12283B', '#1C405E', '#275881', '#3270A5', '#3F88C5', '#629ED0', '#86B4DA', '#A9CAE5', '#CCDFF0', '#EFF5FA']; it('should memoize calls for generateColorPalette', () => { uut.getColorTint(baseColor, 20); @@ -163,21 +150,6 @@ describe('style/Colors', () => { expect(palette).toEqual(tintsLight); }); - it('should generateColorPalette with adjustSaturation option true and saturationLevels 8 array', () => { - const palette = uut.generateColorPalette(baseColor, {adjustSaturation: true, saturationLevels}); - expect(palette.length).toBe(8); - expect(palette).toContain(baseColor); // adjusting baseColor tint as well - expect(palette).toEqual(tintsSaturationLevels); - }); - - // it('should generateColorPalette with adjustSaturation option true and saturationLevels 10 array and addDarkestTints true', () => { - // const options = {adjustSaturation: true, saturationLevels, addDarkestTints: true}; - // const palette = uut.generateColorPalette(baseColor, options); - // expect(palette.length).toBe(10); - // expect(palette).toContain(baseColor); // adjusting baseColor tint as well - // expect(palette).toEqual(tintsSaturationLevelsDarkest); - // }); - it('should generateColorPalette with avoidReverseOnDark option false not reverse on light mode (default)', () => { const palette = uut.generateColorPalette(baseColor, {avoidReverseOnDark: false}); expect(palette.length).toBe(8); @@ -205,6 +177,25 @@ describe('style/Colors', () => { // expect(palette).toContain(baseColor); // expect(palette).toEqual(tintsAddDarkestTints); // }); + + it('should not apply saturation curve when base color saturation is below threshold', () => { + const lowSatColor = '#7A8A8A'; + const rawPalette = ['#323939', '#4A5454', '#626F6F', '#7A8A8A', '#95A2A2', '#B0BABA', '#CBD2D2', '#E7EAEA']; + const palette = uut.generateColorPalette(lowSatColor); + expect(palette).toEqual(rawPalette); + }); + + it('should respect custom saturationFloor', () => { + const palette = uut.generateColorPalette(baseColor, {saturationFloor: 40}); + const expected = ['#20374B', '#2E5270', '#376E9B', '#3F88C5', '#6CA0CB', '#96B8D4', '#BCD0E2', '#DFE9F1']; + expect(palette).toEqual(expected); + }); + + it('should not apply curve when adjustSaturation is false', () => { + const rawPalette = ['#193852', '#255379', '#316EA1', '#3F88C5', '#66A0D1', '#8DB9DD', '#B5D1E9', '#DCE9F4']; + const palette = uut.generateColorPalette(baseColor, {adjustSaturation: false}); + expect(palette).toEqual(rawPalette); + }); }); describe('generateDesignTokens', () => { diff --git a/packages/react-native-ui-lib/src/style/colors.ts b/packages/react-native-ui-lib/src/style/colors.ts index b905d7fd49..fca868cea5 100644 --- a/packages/react-native-ui-lib/src/style/colors.ts +++ b/packages/react-native-ui-lib/src/style/colors.ts @@ -19,11 +19,15 @@ export type GetColorByHexOptions = {validColors?: string[]}; export type GeneratePaletteOptions = { /** Whether to adjust the lightness of very light colors (generating darker palette) */ adjustLightness?: boolean; - /** Whether to adjust the saturation of colors with high lightness and saturation (unifying saturation level throughout palette) */ + /** Whether to apply the saturation curve to unify saturation levels throughout the palette */ adjustSaturation?: boolean; - /** Array of saturation adjustments to apply on the color's tints array (from darkest to lightest). - * The 'adjustSaturation' option must be true */ - saturationLevels?: number[]; + /** Percentage-based saturation curve indexed by distance from base color. + * When provided, applies proportional saturation reduction outward from the base color */ + saturationCurve?: number[]; + /** Base saturation threshold below which the saturation curve is not applied (default: 50) */ + saturationThreshold?: number; + /** Minimum saturation value when applying the curve (default: 20) */ + saturationFloor?: number; /** Whether to add two extra dark colors usually used for dark mode (generating a palette of 10 instead of 8 colors) */ addDarkestTints?: boolean; // TODO: rename 'fullPalette' /** Whether to reverse the color palette to generate dark mode palette (pass 'true' to generate the same palette for both light and dark modes) */ @@ -268,7 +272,7 @@ export class Colors { const end = options?.addDarkestTints && colorLightness > 10 ? undefined : size; const sliced = tints.slice(start, end); - const adjusted = options?.adjustSaturation && adjustSaturation(sliced, color, options?.saturationLevels); + const adjusted = options?.adjustSaturation && adjustSaturationWithCurve(sliced, color, options); return adjusted || sliced; }, generatePaletteCacheResolver); @@ -277,7 +281,9 @@ export class Colors { adjustSaturation: true, addDarkestTints: false, avoidReverseOnDark: false, - saturationLevels: undefined + saturationCurve: [1.0, 0.89, 0.77, 0.65, 0.55, 0.47, 0.42, 0.38, 0.34, 0.30], + saturationThreshold: 50, + saturationFloor: 20 }; generateColorPalette = _.memoize((color: string, options?: GeneratePaletteOptions): string[] => { @@ -354,50 +360,35 @@ function colorStringValue(color: string | object) { return color?.toString(); } -function adjustAllSaturations(colors: string[], baseColor: string, levels: number[]) { - const array: string[] = []; - _.forEach(colors, (c, index) => { - if (c === baseColor) { - array[index] = baseColor; - } else { - const hsl = Color(c).hsl(); - const saturation = hsl.color[1]; - const level = levels[index]; - if (level !== undefined) { - const saturationLevel = saturation + level; - const clampedLevel = _.clamp(saturationLevel, 0, 100); - const adjusted = setSaturation(c, clampedLevel); - array[index] = adjusted; - } - } - }); - return array; -} +type CurveOptions = Pick; + +function adjustSaturationWithCurve(colors: string[], baseColor: string, options?: CurveOptions): string[] | null { + const {saturationCurve: curve, saturationThreshold: threshold = 50, saturationFloor: floor = 20} = options ?? {}; -function adjustSaturation(colors: string[], baseColor: string, levels?: number[]) { - if (levels) { - return adjustAllSaturations(colors, baseColor, levels); + if (!curve) { + return null; } - let array; - const lightnessLevel = 80; - const saturationLevel = 60; - const hsl = Color(baseColor).hsl(); - const lightness = Math.round(hsl.color[2]); + const baseSaturation = Color(baseColor).hsl().color[1]; + if (baseSaturation <= threshold) { + return null; + } - if (lightness > lightnessLevel) { - const saturation = Math.round(hsl.color[1]); - if (saturation > saturationLevel) { - array = _.map(colors, e => (e !== baseColor ? setSaturation(e, saturationLevel) : e)); - } + const baseIndex = colors.indexOf(baseColor.toUpperCase()); + if (baseIndex === -1) { + return null; } - return array; -} -function setSaturation(color: string, saturation: number): string { - const hsl = Color(color).hsl(); - hsl.color[1] = saturation; - return hsl.hex(); + return colors.map((hex, i) => { + if (i === baseIndex) { + return hex; + } + const hsl = Color(hex).hsl(); + const distance = Math.abs(i - baseIndex); + const percentage = curve[Math.min(distance, curve.length - 1)]; + const newSaturation = Math.max(floor, Math.ceil(baseSaturation * percentage)); + return Color.hsl(hsl.color[0], newSaturation, hsl.color[2]).hex(); + }); } function generateColorTint(color: string, tintLevel: number): string {