Skip to content

Commit cd6397b

Browse files
authored
Enhance color palette generation with saturation curve options (#3942)
* 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. * -Refactor adjustSaturationWithCurve function to use GeneratePaletteOptions - Changed the saturation adjustment calculation from ceil to round * fix tests and add darktint test * add back the old saturation logic for fallback and avoid breaking changes for users * re-added comment: * The 'adjustSaturation' option must be true */
1 parent 4b71eea commit cd6397b

2 files changed

Lines changed: 93 additions & 88 deletions

File tree

packages/react-native-ui-lib/src/style/__tests__/colors.spec.js

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -92,46 +92,33 @@ describe('style/Colors', () => {
9292
});
9393

9494
it('should handle color that does not exist in `uilib`', () => {
95-
expect(uut.getColorTint('#F1BE0B', 10)).toEqual('#8D7006'); //
96-
expect(uut.getColorTint('#F1BE0B', 20)).toEqual('#BE9609'); //
97-
expect(uut.getColorTint('#F1BE0B', 30)).toEqual('#F1BE0B'); //
98-
expect(uut.getColorTint('#F1BE0B', 40)).toEqual('#F6CC37'); //
99-
expect(uut.getColorTint('#F1BE0B', 50)).toEqual('#F8D868'); //
100-
expect(uut.getColorTint('#F1BE0B', 60)).toEqual('#FAE599'); //
101-
expect(uut.getColorTint('#F1BE0B', 70)).toEqual('#FDF1C9'); //
102-
expect(uut.getColorTint('#F1BE0B', 80)).toEqual('#FFFEFA'); //
95+
expect(uut.getColorTint('#F1BE0B', 10)).toEqual('#7D6716');
96+
expect(uut.getColorTint('#F1BE0B', 20)).toEqual('#B49013');
97+
expect(uut.getColorTint('#F1BE0B', 30)).toEqual('#F1BE0B');
98+
expect(uut.getColorTint('#F1BE0B', 40)).toEqual('#EBC642');
99+
expect(uut.getColorTint('#F1BE0B', 50)).toEqual('#E7CF79');
100+
expect(uut.getColorTint('#F1BE0B', 60)).toEqual('#E9DBAA');
101+
expect(uut.getColorTint('#F1BE0B', 70)).toEqual('#F1EBD5');
102+
expect(uut.getColorTint('#F1BE0B', 80)).toEqual('#FEFDFB');
103103
});
104104

105105
it('should round down tint level to the nearest one', () => {
106-
expect(uut.getColorTint('#F1BE0B', 75)).toEqual('#FDF1C9');
107-
expect(uut.getColorTint('#F1BE0B', 25)).toEqual('#BE9609');
106+
expect(uut.getColorTint('#F1BE0B', 75)).toEqual('#F1EBD5');
107+
expect(uut.getColorTint('#F1BE0B', 25)).toEqual('#B49013');
108108
expect(uut.getColorTint('#F1BE0B', 35)).toEqual('#F1BE0B');
109109
});
110110

111111
it('should handle out of range tint levels and round them to the nearest one in range', () => {
112-
expect(uut.getColorTint('#F1BE0B', 3)).toEqual('#8D7006');
113-
expect(uut.getColorTint('#F1BE0B', 95)).toEqual('#FFFEFA');
112+
expect(uut.getColorTint('#F1BE0B', 3)).toEqual('#7D6716');
113+
expect(uut.getColorTint('#F1BE0B', 95)).toEqual('#FEFDFB');
114114
});
115115
});
116116

117117
describe('generateColorPalette', () => {
118118
const baseColor = '#3F88C5';
119-
const tints = ['#193852', '#255379', '#316EA1', '#3F88C5', '#66A0D1', '#8DB9DD', '#B5D1E9', '#DCE9F4'];
119+
const tints = ['#233748', '#2F526F', '#376E9B', '#3F88C5', '#6CA0CB', '#97B8D3', '#BED0E0', '#E1E9EF'];
120120
const baseColorLight = '#DCE9F4';
121121
const tintsLight = ['#1A3851', '#265278', '#326D9F', '#4187C3', '#68A0CF', '#8EB8DC', '#B5D1E8', '#DCE9F4'];
122-
const saturationLevels = [-10, -10, -20, -20, -25, -25, -25, -25, -20, -10];
123-
const tintsSaturationLevels = [
124-
'#1E384D',
125-
'#2D5271',
126-
'#466C8C',
127-
'#3F88C5',
128-
'#7F9EB8',
129-
'#A0B7CB',
130-
'#C1D0DD',
131-
'#E2E9EE'
132-
];
133-
// const tintsSaturationLevelsDarkest = ['#162837', '#223F58', '#385770', '#486E90', '#3F88C5', '#7C9CB6', '#9AB2C6', '#B7C9D7', '#D3DFE9', '#F0F5F9'];
134-
// const tintsAddDarkestTints = ['#12283B', '#1C405E', '#275881', '#3270A5', '#3F88C5', '#629ED0', '#86B4DA', '#A9CAE5', '#CCDFF0', '#EFF5FA'];
135122

136123
it('should memoize calls for generateColorPalette', () => {
137124
uut.getColorTint(baseColor, 20);
@@ -163,21 +150,6 @@ describe('style/Colors', () => {
163150
expect(palette).toEqual(tintsLight);
164151
});
165152

166-
it('should generateColorPalette with adjustSaturation option true and saturationLevels 8 array', () => {
167-
const palette = uut.generateColorPalette(baseColor, {adjustSaturation: true, saturationLevels});
168-
expect(palette.length).toBe(8);
169-
expect(palette).toContain(baseColor); // adjusting baseColor tint as well
170-
expect(palette).toEqual(tintsSaturationLevels);
171-
});
172-
173-
// it('should generateColorPalette with adjustSaturation option true and saturationLevels 10 array and addDarkestTints true', () => {
174-
// const options = {adjustSaturation: true, saturationLevels, addDarkestTints: true};
175-
// const palette = uut.generateColorPalette(baseColor, options);
176-
// expect(palette.length).toBe(10);
177-
// expect(palette).toContain(baseColor); // adjusting baseColor tint as well
178-
// expect(palette).toEqual(tintsSaturationLevelsDarkest);
179-
// });
180-
181153
it('should generateColorPalette with avoidReverseOnDark option false not reverse on light mode (default)', () => {
182154
const palette = uut.generateColorPalette(baseColor, {avoidReverseOnDark: false});
183155
expect(palette.length).toBe(8);
@@ -199,12 +171,36 @@ describe('style/Colors', () => {
199171
expect(palette).toEqual(tints);
200172
});
201173

202-
// it('should generateColorPalette with addDarkestTints option true return 10 tints with 9 lightness increment', () => {
203-
// const palette = uut.generateColorPalette(baseColor, {addDarkestTints: true});
204-
// expect(palette.length).toBe(10);
205-
// expect(palette).toContain(baseColor);
206-
// expect(palette).toEqual(tintsAddDarkestTints);
207-
// });
174+
it('should generateColorPalette with addDarkestTints option true return 10 tints with saturation curve', () => {
175+
const palette = uut.generateColorPalette(baseColor, {addDarkestTints: true});
176+
const expected = ['#1B2732', '#283F52', '#325776', '#38709F', '#3F88C5', '#689DCA', '#90B3D0', '#B3C9DB', '#D4DFE8', '#F2F5F7'];
177+
expect(palette.length).toBe(10);
178+
expect(palette).toContain(baseColor);
179+
expect(palette).toEqual(expected);
180+
});
181+
182+
it('should not apply saturation curve when base color saturation is below threshold', () => {
183+
const lowSatColor = '#7A8A8A';
184+
const rawPalette = ['#323939', '#4A5454', '#626F6F', '#7A8A8A', '#95A2A2', '#B0BABA', '#CBD2D2', '#E7EAEA'];
185+
const palette = uut.generateColorPalette(lowSatColor);
186+
expect(palette).toEqual(rawPalette);
187+
});
188+
189+
it('should not apply curve when adjustSaturation is false', () => {
190+
const rawPalette = ['#193852', '#255379', '#316EA1', '#3F88C5', '#66A0D1', '#8DB9DD', '#B5D1E9', '#DCE9F4'];
191+
const palette = uut.generateColorPalette(baseColor, {adjustSaturation: false});
192+
expect(palette).toEqual(rawPalette);
193+
});
194+
195+
it('should apply legacy saturationLevels when provided', () => {
196+
const saturationLevels = [-10, -10, -20, -20, -25, -25, -25, -25];
197+
const expected = ['#1E384D', '#2D5271', '#466C8C', '#3F88C5', '#7F9EB8', '#A0B7CB', '#C1D0DD', '#E2E9EE'];
198+
const palette = uut.generateColorPalette(baseColor, {adjustSaturation: true, saturationLevels});
199+
expect(palette.length).toBe(8);
200+
expect(palette).toContain(baseColor);
201+
expect(palette).toEqual(expected);
202+
});
203+
208204
});
209205

210206
describe('generateDesignTokens', () => {

packages/react-native-ui-lib/src/style/colors.ts

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,26 @@ import Scheme, {Schemes, SchemeType} from './scheme';
1212
import type {ExtendTypeWith} from '../typings/common';
1313
import LogService from '../services/LogService';
1414

15+
const SATURATION_CURVE = [1.0, 0.89, 0.77, 0.65, 0.55, 0.47, 0.42, 0.38, 0.34, 0.30];
16+
const SATURATION_THRESHOLD = 50;
17+
const SATURATION_FLOOR = 20;
18+
1519
export type DesignToken = {semantic?: [string]; resource_paths?: [string]; toString: Function};
1620
export type TokensOptions = {primaryColor: string};
1721
export type GetColorTintOptions = {avoidReverseOnDark?: boolean};
1822
export type GetColorByHexOptions = {validColors?: string[]};
1923
export type GeneratePaletteOptions = {
2024
/** Whether to adjust the lightness of very light colors (generating darker palette) */
2125
adjustLightness?: boolean;
22-
/** Whether to adjust the saturation of colors with high lightness and saturation (unifying saturation level throughout palette) */
26+
/** Whether to apply the saturation curve to unify saturation levels throughout the palette */
2327
adjustSaturation?: boolean;
24-
/** Array of saturation adjustments to apply on the color's tints array (from darkest to lightest).
28+
/** Custom percentage-based saturation curve indexed by distance from the base color.
29+
* Overrides the default curve when provided. Each value represents the fraction of the base
30+
* color's saturation to apply at that distance (e.g. [1.0, 0.89, 0.77, ...]).
31+
* The 'adjustSaturation' option must be true */
32+
saturationCurve?: number[];
33+
/** Array of additive saturation adjustments to apply per-index on the palette (from darkest to lightest).
34+
* When provided, uses legacy per-index saturation logic instead of the default curve.
2535
* The 'adjustSaturation' option must be true */
2636
saturationLevels?: number[];
2737
/** Whether to add two extra dark colors usually used for dark mode (generating a palette of 10 instead of 8 colors) */
@@ -268,16 +278,15 @@ export class Colors {
268278
const end = options?.addDarkestTints && colorLightness > 10 ? undefined : size;
269279
const sliced = tints.slice(start, end);
270280

271-
const adjusted = options?.adjustSaturation && adjustSaturation(sliced, color, options?.saturationLevels);
281+
const adjusted = options?.adjustSaturation && adjustSaturation(sliced, color, options);
272282
return adjusted || sliced;
273283
}, generatePaletteCacheResolver);
274284

275285
defaultPaletteOptions = {
276286
adjustLightness: true,
277287
adjustSaturation: true,
278288
addDarkestTints: false,
279-
avoidReverseOnDark: false,
280-
saturationLevels: undefined
289+
avoidReverseOnDark: false
281290
};
282291

283292
generateColorPalette = _.memoize((color: string, options?: GeneratePaletteOptions): string[] => {
@@ -354,50 +363,50 @@ function colorStringValue(color: string | object) {
354363
return color?.toString();
355364
}
356365

357-
function adjustAllSaturations(colors: string[], baseColor: string, levels: number[]) {
358-
const array: string[] = [];
359-
_.forEach(colors, (c, index) => {
360-
if (c === baseColor) {
361-
array[index] = baseColor;
362-
} else {
363-
const hsl = Color(c).hsl();
364-
const saturation = hsl.color[1];
365-
const level = levels[index];
366-
if (level !== undefined) {
367-
const saturationLevel = saturation + level;
368-
const clampedLevel = _.clamp(saturationLevel, 0, 100);
369-
const adjusted = setSaturation(c, clampedLevel);
370-
array[index] = adjusted;
371-
}
366+
function adjustSaturation(colors: string[], baseColor: string, options?: GeneratePaletteOptions): string[] | null {
367+
if (options?.saturationLevels) {
368+
return adjustSaturationByLevels(colors, baseColor, options.saturationLevels);
369+
}
370+
return adjustSaturationWithCurve(colors, baseColor, options?.saturationCurve);
371+
}
372+
373+
function adjustSaturationByLevels(colors: string[], baseColor: string, levels: number[]): string[] {
374+
return colors.map((color, index) => {
375+
if (color === baseColor) {
376+
return baseColor;
377+
}
378+
const level = levels[index];
379+
if (level === undefined) {
380+
return color;
372381
}
382+
const hsl = Color(color).hsl();
383+
const newSaturation = _.clamp(hsl.color[1] + level, 0, 100);
384+
return Color.hsl(hsl.color[0], newSaturation, hsl.color[2]).hex();
373385
});
374-
return array;
375386
}
376387

377-
function adjustSaturation(colors: string[], baseColor: string, levels?: number[]) {
378-
if (levels) {
379-
return adjustAllSaturations(colors, baseColor, levels);
388+
function adjustSaturationWithCurve(colors: string[], baseColor: string, customCurve?: number[]): string[] | null {
389+
const baseSaturation = Color(baseColor).hsl().color[1];
390+
if (baseSaturation <= SATURATION_THRESHOLD) {
391+
return null;
380392
}
381393

382-
let array;
383-
const lightnessLevel = 80;
384-
const saturationLevel = 60;
385-
const hsl = Color(baseColor).hsl();
386-
const lightness = Math.round(hsl.color[2]);
387-
388-
if (lightness > lightnessLevel) {
389-
const saturation = Math.round(hsl.color[1]);
390-
if (saturation > saturationLevel) {
391-
array = _.map(colors, e => (e !== baseColor ? setSaturation(e, saturationLevel) : e));
392-
}
394+
const baseIndex = colors.indexOf(baseColor.toUpperCase());
395+
if (baseIndex === -1) {
396+
return null;
393397
}
394-
return array;
395-
}
396398

397-
function setSaturation(color: string, saturation: number): string {
398-
const hsl = Color(color).hsl();
399-
hsl.color[1] = saturation;
400-
return hsl.hex();
399+
const curve = customCurve ?? SATURATION_CURVE;
400+
return colors.map((hex, i) => {
401+
if (i === baseIndex) {
402+
return hex;
403+
}
404+
const hsl = Color(hex).hsl();
405+
const distance = Math.abs(i - baseIndex);
406+
const percentage = curve[Math.min(distance, curve.length - 1)];
407+
const newSaturation = Math.max(SATURATION_FLOOR, Math.round(baseSaturation * percentage));
408+
return Color.hsl(hsl.color[0], newSaturation, hsl.color[2]).hex();
409+
});
401410
}
402411

403412
function generateColorTint(color: string, tintLevel: number): string {

0 commit comments

Comments
 (0)