From c8ad5f6dcbeda5c8545574c0fd4d68a0fff956bb Mon Sep 17 00:00:00 2001 From: Vasyl Date: Fri, 10 Oct 2025 11:44:27 +0300 Subject: [PATCH 1/5] - feat: add new color palette - feat: add ability to generate new custom color palette --- .../ui/src/services/color/presetsToShare.ts | 272 ++++++++++ .../color/tests/resolve-colors.spec.ts | 401 +++++++++++++++ .../color/tests/theme-\321\201olors.spec.ts" | 149 ++++++ packages/ui/src/services/color/themeColors.ts | 463 ++++++++++++++++++ packages/ui/src/services/color/utils.ts | 65 ++- 5 files changed, 1348 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/services/color/presetsToShare.ts create mode 100644 packages/ui/src/services/color/tests/resolve-colors.spec.ts create mode 100644 "packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" create mode 100644 packages/ui/src/services/color/themeColors.ts diff --git a/packages/ui/src/services/color/presetsToShare.ts b/packages/ui/src/services/color/presetsToShare.ts new file mode 100644 index 0000000000..01a6112908 --- /dev/null +++ b/packages/ui/src/services/color/presetsToShare.ts @@ -0,0 +1,272 @@ + +export const presetToShare = { + blue: '#154ec1', + blue7: '#154ec112', + blue10: '#154ec11a', + blue13: '#154ec121', + blueLight: '#2c5fc7', + blueDark: '#1244a8', + blueSoft7: '#60ace212', + blueSoft10: '#60ace21a', + blueSoft13: '#60ace221', + blueSoft20: '#60ace233', + blue20: '#154ec133', + blueSoftLight: '#70b4e5', + blueSoft: '#60ace2', + blueSoftDark: '#5496c5', + + neutralPrimarySoft: '#ecf0f1', + neutralPrimaryDark: '#111921', + white: '#ffffff', + black: '#000000', + neutralPrimary: '#141d26', + neutralPrimary10: '#191d1f1a', + neutralPrimarySoft10: '#ecf0f11a', + neutralPrimaryLight: '#2c343c', + neutralPrimarySoftLight: '#eef1f2', + neutralPrimarySoftDark: '#cdd1d2', + neutralPrimary7: '#191d1f12', + neutralPrimary13: '#191d1f21', + neutralPrimarySoft7: '#ecf0f112', + neutralPrimarySoft13: '#ecf0f121', + neutralPrimarySoft20: '#ecf0f133', + neutralPrimary20: '#191d1f33', + neutralSecondaryLight: '#6c7178', + neutralSecondary: '#5c6169', + neutralSecondaryDark: '#50545b', + neutralSecondarySoftLight: '#b4bbc9', + neutralSecondarySoft: '#acb4c3', + neutralSecondarySoftDark: '#969daa', + + yellowSoft: '#ffd952', + yellow7: '#84680612', + yellow10: '#8468061a', + yellow13: '#84680621', + yellowSoft7: '#ffd95212', + yellowSoft10: '#ffd9521a', + yellowSoft13: '#ffd95221', + yellowSoftLight: '#ffdd63', + yellowSoftDark: '#debd47', + yellowSoft20: '#ffd95233', + yellow20: '#84680633', + yellowLight: '#90771f', + yellow: '#846806', + yellowDark: '#735a05', + + greenSoft: '#66be33', + green7: '#1f750012', + green10: '#1f75001a', + green13: '#1f750021', + greenSoft7: '#66be3312', + greenSoft10: '#66be331a', + greenSoft13: '#66be3321', + greenSoftLight: '#75c447', + greenSoftDark: '#59a52c', + greenSoft20: '#66be3333', + green20: '#1f750033', + greenLight: '#35831a', + green: '#1f7500', + greenDark: '#1b6600', + + red7: '#c5181812', + red10: '#c518181a', + red13: '#c5181821', + redSoft7: '#f67d7312', + redSoft10: '#f67d731a', + redSoft13: '#f67d7321', + red20: '#c5181833', + redSoft20: '#f67d7333', + red: '#c51818', + redSoft: '#f67d73', + redSoftLight: '#f78a81', + redPaleDark: '#d66d64', + redLight: '#cb2f2f', + redDark: '#ab1515', + + sky7: '#0b6aae12', + sky10: '#0b6aae1a', + sky13: '#0b6aae21', + skySoft7: '#56aeff12', + skySoft10: '#56aeff1a', + skySoft13: '#56aeff21', + sky20: '#0b6aae33', + skySoft20: '#56aeff33', + sky: '#0b6aae', + skySoft: '#56aeff', + skySoftLight: '#67b6ff', + skySoftDark: '#4b97de', + skyLight: '#2379b6', + skyDark: '#0a5c97', +} + +export const lightNewTheme = { + text: { + primary: 'neutralPrimary', + secondary: 'neutralSecondary', + inverted: 'neutralPrimarySoft', + brand: 'blue', + info: 'sky', + warning: 'yellow', + success: 'green', + danger: 'red', + primaryConst: 'neutralPrimary', + invertedConst: 'neutralPrimarySoft', + brandHover: 'blueLight', + brandPressed: 'blueDark', + dangerHover: 'redLight', + dangerPressed: 'redDark', + successHover: 'greenLight', + successPressed: 'greenDark', + warningHover: 'yellowLight', + warningPressed: 'yellowDark', + infoHover: 'skyLight', + infoPressed: 'skyDark', + primaryHover: 'neutralPrimaryLight', + primaryPressed: 'neutralPrimaryDark', + secondaryHover: 'neutralSecondaryLight', + secondaryPressed: 'neutralSecondaryDark', + invertedHover: 'neutralPrimarySoftLight', + invertedPressed: 'neutralPrimarySoftDark', + }, + background: { + primary: 'white', + brand: 'blue10', + brandAccent: 'blue', + secondary: 'neutralPrimarySoftLight', + warning: 'yellow10', + warningAccent: 'yellow', + danger: 'red10', + dangerAccent: 'red', + success: 'green10', + successAccent: 'green', + info: 'sky10', + infoAccent: 'sky', + neutral: 'neutralPrimary10', + neutralAccent: 'neutralPrimary', + brandHover: 'blue7', + brandPressed: 'blue13', + brandAccentHover: 'blueLight', + brandAccentPressed: 'blueDark', + warningPressed: 'yellow13', + warningHover: 'yellow7', + warningAccentPressed: 'yellowDark', + warningAccentHover: 'yellowLight', + dangerPressed: 'red13', + dangerHover: 'red7', + dangerAccentHover: 'redLight', + dangerAccentPressed: 'redDark', + successPressed: 'green13', + successHover: 'green7', + successAccentHover: 'greenLight', + successAccentPressed: 'greenDark', + infoHover: 'sky7', + infoPressed: 'sky13', + infoAccentHover: 'skyLight', + infoAccentPressed: 'skyDark', + neutralAccentHover: 'neutralPrimaryLight', + neutralAccentPressed: 'neutralPrimaryDark', + neutralHover: 'neutralPrimary7', + neutralPressed: 'neutralPrimary13', + }, + border: { + neutral: 'neutralPrimary20', + successAccent: 'green', + warningAccent: 'yellow', + dangerAccent: 'red', + infoAccent: 'sky', + focus: 'skySoft', + brandAccent: 'blue', + neutralAccent: 'neutralPrimary', + danger: 'red20', + success: 'green20', + warning: 'yellow20', + info: 'sky20', + brand: 'blue20', + }, +} + +export const darkNewTheme = { + text: { + primary: 'neutralPrimarySoft', + secondary: 'neutralSecondarySoft', + inverted: 'neutralPrimary', + brand: 'blueSoft', + info: 'skySoft', + warning: 'yellowSoft', + success: 'greenSoft', + danger: 'redSoft', + primaryConst: 'neutralPrimary', + invertedConst: 'neutralPrimarySoft', + brandHover: 'blueSoftLight', + brandPressed: 'blueSoftDark', + dangerHover: 'redSoftLight', + dangerPressed: ['redPaleDark', 'redSoftDark'], // not all names from figma follow the same pattern + successHover: 'greenSoftLight', + successPressed: 'greenSoftDark', + warningHover: 'yellowSoftLight', + warningPressed: 'yellowSoftDark', + infoHover: 'skySoftLight', + infoPressed: 'skySoftDark', + primaryHover: 'neutralPrimarySoftLight', + primaryPressed: 'neutralPrimarySoftDark', + secondaryHover: 'neutralSecondarySoftLight', + secondaryPressed: 'neutralSecondarySoftDark', + invertedHover: 'neutralPrimaryLight', + invertedPressed: 'neutralPrimaryDark', + }, + background: { + primary: 'neutralPrimary', + brand: 'blueSoft10', + brandAccent: 'blueSoft', + secondary: 'neutralPrimaryLight', + warning: 'yellowSoft10', + warningAccent: 'yellowSoft', + danger: 'redSoft10', + dangerAccent: 'redSoft', + success: 'greenSoft10', + successAccent: 'greenSoft', + info: 'skySoft10', + infoAccent: 'skySoft', + neutral: 'neutralPrimarySoft10', + neutralAccent: 'neutralPrimarySoft', + brandHover: 'blueSoft13', + brandPressed: 'blueSoft7', + brandAccentHover: 'blueSoftLight', + brandAccentPressed: 'blueSoftDark', + warningPressed: 'yellowSoft7', + warningHover: 'yellowSoft13', + warningAccentPressed: 'yellowSoftDark', + warningAccentHover: 'yellowSoftLight', + dangerPressed: 'redSoft7', + dangerHover: 'redSoft13', + dangerAccentHover: 'redSoftLight', + dangerAccentPressed: ['redPaleDark', 'redSoftDark'], + successPressed: 'greenSoft7', + successHover: 'greenSoft13', + successAccentHover: 'greenSoftLight', + successAccentPressed: 'greenSoftDark', + infoHover: 'skySoft13', + infoPressed: 'sky7', + infoAccentHover: 'skySoftLight', + infoAccentPressed: 'skySoftDark', + neutralAccentHover: 'neutralPrimarySoftLight', + neutralAccentPressed: 'neutralPrimarySoftDark', + neutralHover: 'neutralPrimarySoft13', + neutralPressed: 'neutralPrimarySoft7', + }, + border: { + neutral: 'neutralPrimarySoft20', + successAccent: 'greenSoft', + warningAccent: 'yellowSoft', + dangerAccent: 'redSoft', + infoAccent: 'skySoft', + focus: 'skySoft', + brandAccent: 'blueSoft', + neutralAccent: 'neutralPrimarySoft', + danger: 'redSoft20', + success: 'greenSoft20', + warning: 'yellowSoft20', + info: 'skySoft20', + brand: 'blueSoft20', + }, +} diff --git a/packages/ui/src/services/color/tests/resolve-colors.spec.ts b/packages/ui/src/services/color/tests/resolve-colors.spec.ts new file mode 100644 index 0000000000..d768ae971f --- /dev/null +++ b/packages/ui/src/services/color/tests/resolve-colors.spec.ts @@ -0,0 +1,401 @@ +import { describe, it, expect } from 'vitest' +import { resolveColors } from '../utils' +import { createNeutral, createAccent } from '../themeColors' +import { lightNewTheme, darkNewTheme, presetToShare } from '../presetsToShare' + +// --- Tests ------------------------------------------------------------------- +describe('resolveColors', () => { + it('lightNewTheme resolves default preset', () => { + const theme = resolveColors(lightNewTheme, presetToShare) + + expect(theme).toEqual({ + background: { + brand: '#154ec11a', + brandAccent: '#154ec1', + brandAccentHover: '#2c5fc7', + brandAccentPressed: '#1244a8', + brandHover: '#154ec112', + brandPressed: '#154ec121', + danger: '#c518181a', + dangerAccent: '#c51818', + dangerAccentHover: '#cb2f2f', + dangerAccentPressed: '#ab1515', + dangerHover: '#c5181812', + dangerPressed: '#c5181821', + info: '#0b6aae1a', + infoAccent: '#0b6aae', + infoAccentHover: '#2379b6', + infoAccentPressed: '#0a5c97', + infoHover: '#0b6aae12', + infoPressed: '#0b6aae21', + neutral: '#191d1f1a', + neutralAccent: '#141d26', + neutralAccentHover: '#2c343c', + neutralAccentPressed: '#111921', + neutralHover: '#191d1f12', + neutralPressed: '#191d1f21', + primary: '#ffffff', + secondary: '#eef1f2', + success: '#1f75001a', + successAccent: '#1f7500', + successAccentHover: '#35831a', + successAccentPressed: '#1b6600', + successHover: '#1f750012', + successPressed: '#1f750021', + warning: '#8468061a', + warningAccent: '#846806', + warningAccentHover: '#90771f', + warningAccentPressed: '#735a05', + warningHover: '#84680612', + warningPressed: '#84680621', + }, + border: { + brand: '#154ec133', + brandAccent: '#154ec1', + danger: '#c5181833', + dangerAccent: '#c51818', + focus: '#56aeff', + info: '#0b6aae33', + infoAccent: '#0b6aae', + neutral: '#191d1f33', + neutralAccent: '#141d26', + success: '#1f750033', + successAccent: '#1f7500', + warning: '#84680633', + warningAccent: '#846806', + }, + text: { + brand: '#154ec1', + brandHover: '#2c5fc7', + brandPressed: '#1244a8', + danger: '#c51818', + dangerHover: '#cb2f2f', + dangerPressed: '#ab1515', + info: '#0b6aae', + infoHover: '#2379b6', + infoPressed: '#0a5c97', + inverted: '#ecf0f1', + invertedConst: '#ecf0f1', + invertedHover: '#eef1f2', + invertedPressed: '#cdd1d2', + primary: '#141d26', + primaryConst: '#141d26', + primaryHover: '#2c343c', + primaryPressed: '#111921', + secondary: '#5c6169', + secondaryHover: '#6c7178', + secondaryPressed: '#50545b', + success: '#1f7500', + successHover: '#35831a', + successPressed: '#1b6600', + warning: '#846806', + warningHover: '#90771f', + warningPressed: '#735a05', + }, + }) + }) + + it('darkNewTheme resolves default preset', () => { + const theme = resolveColors(darkNewTheme, presetToShare) + + expect(theme).toEqual({ + background: { + brand: '#60ace21a', + brandAccent: '#60ace2', + brandAccentHover: '#70b4e5', + brandAccentPressed: '#5496c5', + brandHover: '#60ace221', + brandPressed: '#60ace212', + danger: '#f67d731a', + dangerAccent: '#f67d73', + dangerAccentHover: '#f78a81', + dangerAccentPressed: '#d66d64', + dangerHover: '#f67d7321', + dangerPressed: '#f67d7312', + info: '#56aeff1a', + infoAccent: '#56aeff', + infoAccentHover: '#67b6ff', + infoAccentPressed: '#4b97de', + infoHover: '#56aeff21', + infoPressed: '#0b6aae12', + neutral: '#ecf0f11a', + neutralAccent: '#ecf0f1', + neutralAccentHover: '#eef1f2', + neutralAccentPressed: '#cdd1d2', + neutralHover: '#ecf0f121', + neutralPressed: '#ecf0f112', + primary: '#141d26', + secondary: '#2c343c', + success: '#66be331a', + successAccent: '#66be33', + successAccentHover: '#75c447', + successAccentPressed: '#59a52c', + successHover: '#66be3321', + successPressed: '#66be3312', + warning: '#ffd9521a', + warningAccent: '#ffd952', + warningAccentHover: '#ffdd63', + warningAccentPressed: '#debd47', + warningHover: '#ffd95221', + warningPressed: '#ffd95212', + }, + border: { + brand: '#60ace233', + brandAccent: '#60ace2', + danger: '#f67d7333', + dangerAccent: '#f67d73', + focus: '#56aeff', + info: '#56aeff33', + infoAccent: '#56aeff', + neutral: '#ecf0f133', + neutralAccent: '#ecf0f1', + success: '#66be3333', + successAccent: '#66be33', + warning: '#ffd95233', + warningAccent: '#ffd952', + }, + text: { + brand: '#60ace2', + brandHover: '#70b4e5', + brandPressed: '#5496c5', + danger: '#f67d73', + dangerHover: '#f78a81', + dangerPressed: '#d66d64', + info: '#56aeff', + infoHover: '#67b6ff', + infoPressed: '#4b97de', + inverted: '#141d26', + invertedConst: '#ecf0f1', + invertedHover: '#2c343c', + invertedPressed: '#111921', + primary: '#ecf0f1', + primaryConst: '#141d26', + primaryHover: '#eef1f2', + primaryPressed: '#cdd1d2', + secondary: '#acb4c3', + secondaryHover: '#b4bbc9', + secondaryPressed: '#969daa', + success: '#66be33', + successHover: '#75c447', + successPressed: '#59a52c', + warning: '#ffd952', + warningHover: '#ffdd63', + warningPressed: '#debd47', + }, + }) + }) + + it('darkNewTheme resolves generated preset', () => { + const blue = createAccent({ base: '#154ec1' }, 'blue') + const sky = createAccent({ base: '#0ea5e9' }, 'sky') + const yellow = createAccent({ base: '#d97706' }, 'yellow') + const green = createAccent({ base: '#1f7500' }, 'green') + const red = createAccent({ base: '#c51818' }, 'red') + const neutrals = createNeutral({ primary: '#141D26' }) + +// Merge and add alpha aliases without underscores + const generatedColors = { + ...neutrals, + ...blue, + ...sky, + ...yellow, + ...green, + ...red, + } + + const theme = resolveColors(darkNewTheme, generatedColors) + + expect(theme).toEqual({ + background: { + brand: '#60ace21a', + brandAccent: '#60ace2', + brandAccentHover: '#70b4e5', + brandAccentPressed: '#5496c5', + brandHover: '#60ace221', + brandPressed: '#60ace212', + danger: '#f67d731a', + dangerAccent: '#f67d73', + dangerAccentHover: '#f78a81', + dangerAccentPressed: '#d66d64', + dangerHover: '#f67d7321', + dangerPressed: '#f67d7312', + info: '#58ceff1a', + infoAccent: '#58ceff', + infoAccentHover: '#69d3ff', + infoAccentPressed: '#4db3de', + infoHover: '#58ceff21', + infoPressed: '#0ea5e912', + neutral: '#ecf0f11a', + neutralAccent: '#ecf0f1', + neutralAccentHover: '#eef1f2', + neutralAccentPressed: '#cdd1d2', + neutralHover: '#ecf0f121', + neutralPressed: '#ecf0f112', + primary: '#141d26', + secondary: '#2c343c', + success: '#66be331a', + successAccent: '#66be33', + successAccentHover: '#75c447', + successAccentPressed: '#59a52c', + successHover: '#66be3321', + successPressed: '#66be3312', + warning: '#ffdd521a', + warningAccent: '#ffdd52', + warningAccentHover: '#ffe063', + warningAccentPressed: '#dec047', + warningHover: '#ffdd5221', + warningPressed: '#ffdd5212', + }, + border: { + brand: '#60ace233', + brandAccent: '#60ace2', + danger: '#f67d7333', + dangerAccent: '#f67d73', + focus: '#58ceff', + info: '#58ceff33', + infoAccent: '#58ceff', + neutral: '#ecf0f133', + neutralAccent: '#ecf0f1', + success: '#66be3333', + successAccent: '#66be33', + warning: '#ffdd5233', + warningAccent: '#ffdd52', + }, + text: { + brand: '#60ace2', + brandHover: '#70b4e5', + brandPressed: '#5496c5', + danger: '#f67d73', + dangerHover: '#f78a81', + dangerPressed: '#d66d64', + info: '#58ceff', + infoHover: '#69d3ff', + infoPressed: '#4db3de', + inverted: '#141d26', + invertedConst: '#ecf0f1', + invertedHover: '#2c343c', + invertedPressed: '#111921', + primary: '#ecf0f1', + primaryConst: '#141d26', + primaryHover: '#eef1f2', + primaryPressed: '#cdd1d2', + secondary: '#acb4c3', + secondaryHover: '#b4bbc9', + secondaryPressed: '#969daa', + success: '#66be33', + successHover: '#75c447', + successPressed: '#59a52c', + warning: '#ffdd52', + warningHover: '#ffe063', + warningPressed: '#dec047', + }, + }) + }) + + it('lightNewTheme resolves generated preset against lightThemeResult ', () => { + const blue = createAccent({ base: '#154ec1' }, 'blue') + const sky = createAccent({ base: '#0b6aae' }, 'sky') + const yellow = createAccent({ base: '#846806' }, 'yellow') + const green = createAccent({ base: '#1F7500' }, 'green') + const red = createAccent({ base: '#C51818' }, 'red') + const neutrals = createNeutral({ primary: '#141D26' }) + +// Merge and add alpha aliases without underscores + const generatedColors = { + ...neutrals, + ...blue, + ...sky, + ...yellow, + ...green, + ...red, + } + + const theme = resolveColors(lightNewTheme, generatedColors) + + expect(theme).toEqual({ + background: { + brand: '#154ec11a', + brandAccent: '#154ec1', + brandAccentHover: '#2c5fc7', + brandAccentPressed: '#1244a8', + brandHover: '#154ec112', + brandPressed: '#154ec121', + danger: '#c518181a', + dangerAccent: '#c51818', + dangerAccentHover: '#cb2f2f', + dangerAccentPressed: '#ab1515', + dangerHover: '#c5181812', + dangerPressed: '#c5181821', + info: '#0b6aae1a', + infoAccent: '#0b6aae', + infoAccentHover: '#2379b6', + infoAccentPressed: '#0a5c97', + infoHover: '#0b6aae12', + infoPressed: '#0b6aae21', + neutral: '#191d1f1a', + neutralAccent: '#141d26', + neutralAccentHover: '#2c343c', + neutralAccentPressed: '#111921', + neutralHover: '#191d1f12', + neutralPressed: '#191d1f21', + primary: '#ffffff', + secondary: '#eef1f2', + success: '#1f75001a', + successAccent: '#1f7500', + successAccentHover: '#35831a', + successAccentPressed: '#1b6600', + successHover: '#1f750012', + successPressed: '#1f750021', + warning: '#8468061a', + warningAccent: '#846806', + warningAccentHover: '#90771f', + warningAccentPressed: '#735a05', + warningHover: '#84680612', + warningPressed: '#84680621', + }, + border: { + brand: '#154ec133', + brandAccent: '#154ec1', + danger: '#c5181833', + dangerAccent: '#c51818', + focus: '#56aeff', + info: '#0b6aae33', + infoAccent: '#0b6aae', + neutral: '#191d1f33', + neutralAccent: '#141d26', + success: '#1f750033', + successAccent: '#1f7500', + warning: '#84680633', + warningAccent: '#846806', + }, + text: { + brand: '#154ec1', + brandHover: '#2c5fc7', + brandPressed: '#1244a8', + danger: '#c51818', + dangerHover: '#cb2f2f', + dangerPressed: '#ab1515', + info: '#0b6aae', + infoHover: '#2379b6', + infoPressed: '#0a5c97', + inverted: '#ecf0f1', + invertedConst: '#ecf0f1', + invertedHover: '#eef1f2', + invertedPressed: '#cdd1d2', + primary: '#141d26', + primaryConst: '#141d26', + primaryHover: '#2c343c', + primaryPressed: '#111921', + secondary: '#5c6169', + secondaryHover: '#6c7178', + secondaryPressed: '#50545b', + success: '#1f7500', + successHover: '#35831a', + successPressed: '#1b6600', + warning: '#846806', + warningHover: '#90771f', + warningPressed: '#735a05', + }, + }) + }) +}) diff --git "a/packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" "b/packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" new file mode 100644 index 0000000000..27b6c045a0 --- /dev/null +++ "b/packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest' +import { createAccent, createNeutral } from '../themeColors' + +describe('themeColors generator', () => { + describe('accent palettes (exact Figma values)', () => { + it('red', () => { + const red = createAccent({ base: '#C51818' }, 'red') + expect(red).toMatchObject({ + red: '#c51818', + redDark: '#ab1515', + redLight: '#cb2f2f', + + redSoft: '#f67d73', + redSoftDark: '#d66d64', + redSoftLight: '#f78a81', + + red_7: '#c5181812', + red_10: '#c518181a', + red_13: '#c5181821', + red_20: '#c5181833', + + redSoft_7: '#f67d7312', + redSoft_10: '#f67d731a', + redSoft_13: '#f67d7321', + redSoft_20: '#f67d7333', + }) + }) + + it('green', () => { + const green = createAccent({ base: '#1F7500' }, 'green') + expect(green).toMatchObject({ + green: '#1f7500', + greenLight: '#35831a', + greenDark: '#1b6600', + + greenSoft: '#66be33', + greenSoftLight: '#75c447', + greenSoftDark: '#59a52c', + + green_7: '#1f750012', + green_10: '#1f75001a', + green_13: '#1f750021', + green_20: '#1f750033', + + greenSoft_7: '#66be3312', + greenSoft_10: '#66be331a', + greenSoft_13: '#66be3321', + greenSoft_20: '#66be3333', + }) + }) + + it('blue', () => { + const sky = createAccent({ base: '#154EC1' }, 'blue') + expect(sky).toMatchObject({ + blue: '#154ec1', + blueLight: '#2c5fc7', + blueDark: '#1244a8', + + blueSoft: '#60ace2', + blueSoftLight: '#70b4e5', + blueSoftDark: '#5496c5', + + blue_7: '#154ec112', + blue_10: '#154ec11a', + blue_13: '#154ec121', + blue_20: '#154ec133', + + blueSoft_7: '#60ace212', + blueSoft_10: '#60ace21a', + blueSoft_13: '#60ace221', + blueSoft_20: '#60ace233', + }) + }) + + it('sky', () => { + const blue = createAccent({ base: '#0b6aae' }, 'sky') + expect(blue).toMatchObject({ + sky7: '#0b6aae12', + sky10: '#0b6aae1a', + sky13: '#0b6aae21', + skySoft7: '#56aeff12', + skySoft10: '#56aeff1a', + skySoft13: '#56aeff21', + sky20: '#0b6aae33', + skySoft20: '#56aeff33', + sky: '#0b6aae', + skySoft: '#56aeff', + skySoftLight: '#67b6ff', + skySoftDark: '#4b97de', + skyLight: '#2379b6', + skyDark: '#0a5c97', + }) + }) + }) + + describe('neutral palette (special pattern, exact Figma values)', () => { + it('neutral primary/soft and secondary families with alpha', () => { + // ✅ pass alphaBase so neutralPrimary_* uses #191d1fxx as in Figma + const neutral = createNeutral({ + primary: '#141D26', + }) + + expect(neutral).toMatchObject({ + // constants + white: '#ffffff', + black: '#000000', + + // primary ladder + neutralPrimary: '#141d26', + neutralPrimaryLight: '#2c343c', + neutralPrimaryDark: '#111921', + + neutralPrimarySoft: '#ecf0f1', + neutralPrimarySoftLight: '#eef1f2', + neutralPrimarySoftDark: '#cdd1d2', + + // alpha for primary uses #191d1f per Figma + neutralPrimary_7: '#191d1f12', + neutralPrimary_10: '#191d1f1a', + neutralPrimary_13: '#191d1f21', + neutralPrimary_20: '#191d1f33', + + neutralPrimarySoft_7: '#ecf0f112', + neutralPrimarySoft_10: '#ecf0f11a', + neutralPrimarySoft_13: '#ecf0f121', + neutralPrimarySoft_20: '#ecf0f133', + + // secondary ladder + neutralSecondaryLight: '#6c7178', + neutralSecondary: '#5c6169', + neutralSecondaryDark: '#50545b', + + neutralSecondarySoftLight: '#b4bbc9', + neutralSecondarySoft: '#acb4c3', + neutralSecondarySoftDark: '#969daa', + }) + }) + }) + + describe('naming & alpha suffixes', () => { + it('creates *_7/_10/_13/_20 for base and Soft variants', () => { + const sky = createAccent({ base: '#0B6AAE', soft: '#56AEFF' }, 'sky') + ;['7', '10', '13', '20'].forEach(s => { + expect(sky).toHaveProperty(`sky_${s}`) + expect(sky).toHaveProperty(`skySoft_${s}`) + }) + }) + }) +}) diff --git a/packages/ui/src/services/color/themeColors.ts b/packages/ui/src/services/color/themeColors.ts new file mode 100644 index 0000000000..d907e45035 --- /dev/null +++ b/packages/ui/src/services/color/themeColors.ts @@ -0,0 +1,463 @@ +/** + * Theme Color Generation System + * ============================= + * + * Generates complete color palettes from minimal anchor colors: + * + * 1. **Accent Colors** (red, blue, green, yellow, sky, etc.) + * - From a single base color → full palette with variants + * + * 2. **Neutral Colors** (grays and UI backgrounds) + * - From a single primary color → complete neutral system + * + * ## 🎯 IDEAL USAGE PATTERN + * + * **Provide ONLY ONE color per palette as input, and use THE SAME generation** + * **algorithm for ALL colors.** This creates the most maintainable design system. + * + * ```typescript + * // ✅ IDEAL: One input per color, consistent pattern for all + * const red = createAccent({ base: '#C51818' }, 'red') + * const blue = createAccent({ base: '#154EC1' }, 'blue') + * const green = createAccent({ base: '#1F7500' }, 'green') + * const neutrals = createNeutral({ primary: '#141D26' }) + * ``` + * + * Benefits: + * - ✅ Predictable, consistent behavior across all colors + * - ✅ Single algorithm to maintain, not per-color logic + * - ✅ Visual harmony across the entire UI + * - ✅ Easier for developers to understand and use + * + * ## Color Variants Generated + * - **Light/Dark**: ±10%/±13% for hover/pressed states + * - **Soft**: Desaturated versions for backgrounds + * - **Alpha**: 7%, 10%, 13%, 20% opacity levels + * + * ## Calibration Notes + * Per-channel RGB mixing ratios are calibrated to match exact Figma tokens. + * **For new design systems, use uniform ratios across all colors instead.** + * + * @module themeColors + */ + +import { parseColorToRGB, rgbToString } from '@/utils/color' + +// ============================================================================ +// TYPES +// ============================================================================ + +type RGB = { r: number; g: number; b: number } + +export type Format = 'hex8' | 'hex' | 'rgba' + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const WHITE: RGB = { r: 255, g: 255, b: 255 } +const BLACK: RGB = { r: 0, g: 0, b: 0 } + +const COLOR_MIXING_RATIOS = { + light: 0.10, // Hover feel + dark: 0.13, // Pressed feel + soft: 0.66, // Fallback for Soft variants + deriveSecondaryFromPrimary: 0.58, // Legacy (unused) +} as const + +const ALPHA_LEVELS = [0.07, 0.10, 0.13, 0.20] as const + +// ============================================================================ +// CALIBRATION: NEUTRAL COLORS +// ============================================================================ + +/** + * ⚠️ DESIGN RECOMMENDATION ⚠️ + * These per-channel calibrations exist to match Figma tokens. + * Better to use simple uniform ratios instead (e.g., 90% toward white for all channels). + * Consistent patterns = cleaner, more maintainable code. + */ + +// Generate neutralPrimarySoft from neutralPrimary +// '#141D26' → '#ecf0f1' +const NEUTRAL_SOFT_MIX_RATIOS = { + r: 0.9191489361702128, + g: 0.9336283185840708, + b: 0.9354838709677419, +} + +// Generate neutralSecondary from primary + primarySoft +// '#141D26' + '#ecf0f1' → '#5c6169' +const SECONDARY_MIX_RATIOS = { + r: 72 / 216, + g: 68 / 211, + b: 67 / 203, +} + +// Generate neutralSecondarySoft from primarySoft + primary +// '#ecf0f1' + '#141D26' → '#acb4c3' +const SECONDARY_SOFT_MIX_RATIOS = { + r: 64 / 216, + g: 60 / 211, + b: 46 / 203, +} + +// Generate neutralSecondarySoftLight from secondarySoft +// '#acb4c3' → '#b4bbc9' +const SECONDARY_SOFT_LIGHT_MIX_RATIOS = { + r: (180 - 172) / (255 - 172), + g: (187 - 180) / (255 - 180), + b: (201 - 195) / (255 - 195), +} + +// ============================================================================ +// CALIBRATION: ACCENT COLORS +// ============================================================================ + +/** + * ⚠️ ANTI-PATTERN WARNING ⚠️ + * Per-color calibrations are an ANTI-PATTERN. + * + * 🎯 IDEAL: Provide only ONE base color per accent. Use THE SAME algorithm for ALL. + * No per-color overrides. This creates predictable, maintainable color systems. + * + * The calibrations below exist ONLY to match legacy Figma tokens. + */ + +const ACCENT_SOFT_MIX_RATIOS_BY_NAME: Record = { + red: { r: 49 / 58, g: 101 / 231, b: 91 / 231 }, // '#C51818' → '#F67D73' + green: { r: 71 / 224, g: 73 / 138, b: 51 / 255 }, // '#1F7500' → '#66BE33' + blue: { r: 25 / 78, g: 94 / 177, b: 33 / 62 }, // '#154EC1' → '#60ACE2' + yellow: { r: 1, g: 113 / 151, b: 76 / 249 }, // '#846806' → '#FFD952' + sky: { r: 75 / 244, g: 68 / 149, b: 1 }, // '#0B6AAE' → '#56AEFF' +} + +// ============================================================================ +// UTILITIES +// ============================================================================ + +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)) + +const mixColors = (startColor: RGB, endColor: RGB, ratio: number): RGB => ({ + r: Math.round(startColor.r + (endColor.r - startColor.r) * ratio), + g: Math.round(startColor.g + (endColor.g - startColor.g) * ratio), + b: Math.round(startColor.b + (endColor.b - startColor.b) * ratio), +}) + +const mixColorsPerChannel = ( + startColor: RGB, + endColor: RGB, + channelRatios: { r: number; g: number; b: number }, +): RGB => ({ + r: Math.round(startColor.r + (endColor.r - startColor.r) * channelRatios.r), + g: Math.round(startColor.g + (endColor.g - startColor.g) * channelRatios.g), + b: Math.round(startColor.b + (endColor.b - startColor.b) * channelRatios.b), +}) + +const parseColorString = (color: string): RGB => { + const { r, g, b } = parseColorToRGB(color) + return { r, g, b } +} + +const buildRgbaString = (rgb: RGB, alpha = 1): string => rgbToString({ ...rgb, a: alpha }) + +const rgbToHex = ({ r, g, b }: RGB): `#${string}` => { + const hex = [r, g, b] + .map((channel) => Math.round(channel).toString(16).padStart(2, '0')) + .join('') + return `#${hex}`.toLowerCase() as `#${string}` +} + +const rgbToHex8 = (rgb: RGB, alpha: number): `#${string}` => { + const alphaHex = Math.round(clamp01(alpha) * 255) + .toString(16) + .padStart(2, '0') + return `${rgbToHex(rgb)}${alphaHex}` as `#${string}` +} + +const emitColor = (rgb: RGB, format: Format, alpha?: number): string => { + if (format === 'rgba') { + return buildRgbaString(rgb, alpha ?? 1) + } + if (alpha === undefined || alpha >= 1) { + return rgbToHex(rgb) + } + return rgbToHex8(rgb, alpha) +} + +// Creates both "blue7" and "blue_7" for compatibility +function addColorWithAlias ( + outputMap: Record, + key: string, + value: string, +): void { + outputMap[key] = value + const match = key.match(/^(.*?)(Soft)?(7|10|13|20)$/) + if (match) { + const [, name, soft, alphaLevel] = match + outputMap[`${name}${soft || ''}_${alphaLevel}`] = value + } +} + +// ============================================================================ +// GENERATION STRATEGIES +// ============================================================================ + +type SoftVariantGenerator = (baseColor: RGB, context?: { name?: string }) => RGB +type AlphaBaseGenerator = (primaryColor: RGB) => RGB +type SecondaryColorsGenerator = ( + primaryColor: RGB, + primarySoft: RGB, +) => { + secondary: RGB + secondarySoft: RGB + secondarySoftLight: RGB +} + +const defaultNeutralSoftGenerator: SoftVariantGenerator = (primaryColor) => + mixColorsPerChannel(primaryColor, WHITE, NEUTRAL_SOFT_MIX_RATIOS) + +// Generates alphaBase by pulling R/B channels toward G (green pivot) +// '#141D26' (20, 29, 38) → '#191d1f' (25, 29, 31) +const defaultAlphaBaseGenerator: AlphaBaseGenerator = (primaryColor) => { + const greenPivot = primaryColor.g + return { + r: Math.round(greenPivot + (4 / 9) * (primaryColor.r - greenPivot)), + g: greenPivot, + b: Math.round(greenPivot + (2 / 9) * (primaryColor.b - greenPivot)), + } +} + +const defaultSecondaryColorsGenerator: SecondaryColorsGenerator = (primaryColor, primarySoft) => { + const secondary = mixColorsPerChannel(primaryColor, primarySoft, SECONDARY_MIX_RATIOS) + const secondarySoft = mixColorsPerChannel(primarySoft, primaryColor, SECONDARY_SOFT_MIX_RATIOS) + const secondarySoftLight = mixColorsPerChannel( + secondarySoft, + WHITE, + SECONDARY_SOFT_LIGHT_MIX_RATIOS, + ) + return { secondary, secondarySoft, secondarySoftLight } +} + +const createAccentSoftGenerator = (accentName: string): SoftVariantGenerator => { + const calibratedRatios = ACCENT_SOFT_MIX_RATIOS_BY_NAME[accentName.toLowerCase()] + if (calibratedRatios) { + return (baseColor) => mixColorsPerChannel(baseColor, WHITE, calibratedRatios) + } + return (baseColor) => mixColors(baseColor, WHITE, COLOR_MIXING_RATIOS.soft) +} + +type ColorGenerationStrategies = { + neutralSoftFromPrimary: SoftVariantGenerator + alphaBaseFromPrimary: AlphaBaseGenerator + secondaryFromPrimary: SecondaryColorsGenerator + accentSoftFromName: (name: string) => SoftVariantGenerator +} + +const colorGenerationStrategies: ColorGenerationStrategies = { + neutralSoftFromPrimary: defaultNeutralSoftGenerator, + alphaBaseFromPrimary: defaultAlphaBaseGenerator, + secondaryFromPrimary: defaultSecondaryColorsGenerator, + accentSoftFromName: createAccentSoftGenerator, +} + +// ============================================================================ +// PUBLIC API: ACCENT COLORS +// ============================================================================ + +/** + * Generate a complete accent color palette from a base color. + * + * Produces: base, light, dark, soft, softLight, softDark, and alpha variants (7/10/13/20%). + * + * ## 🎯 IDEAL USAGE: Single Input, Consistent Pattern + * + * **Provide ONLY the base color.** Let the system generate all variants using + * the same algorithm for every color. This is the cleanest, most maintainable approach. + * + * ```typescript + * // ✅ IDEAL: One input, consistent generation for all colors + * const red = createAccent({ base: '#C51818' }, 'red') + * const blue = createAccent({ base: '#154EC1' }, 'blue') + * const green = createAccent({ base: '#1F7500' }, 'green') + * // All use the same internal algorithm → predictable, harmonious results + * ``` + * + * ⚠️ **Avoid providing explicit `soft` colors** unless absolutely necessary, + * as it creates inconsistency across your color palette. + * + * @example + * // ✅ RECOMMENDED: Single input + * const blue = createAccent({ base: '#154EC1' }, 'blue') + * + * @example + * // ❌ AVOID: Manual soft override creates inconsistency + * const blue = createAccent({ base: '#154EC1', soft: '#60ACE2' }, 'blue') + */ +export function createAccent ( + anchors: { base: string; soft?: string }, + colorName: string, + outputFormat: Format = 'hex8', +): Record { + const baseColor = parseColorString(anchors.base) + const lowerCaseName = colorName.toLowerCase() + + const mainColor = baseColor + const lightColor = mixColors(baseColor, WHITE, COLOR_MIXING_RATIOS.light) + const darkColor = mixColors(baseColor, BLACK, COLOR_MIXING_RATIOS.dark) + + const softColor: RGB = anchors.soft + ? parseColorString(anchors.soft) + : colorGenerationStrategies.accentSoftFromName(lowerCaseName)(baseColor) + + const softLightColor = mixColors(softColor, WHITE, COLOR_MIXING_RATIOS.light) + const softDarkColor = mixColors(softColor, BLACK, COLOR_MIXING_RATIOS.dark) + + const palette: Record = {} + + // Main trio + palette[colorName] = emitColor(mainColor, outputFormat) + palette[`${colorName}Light`] = emitColor(lightColor, outputFormat) + palette[`${colorName}Dark`] = emitColor(darkColor, outputFormat) + + // Soft trio + palette[`${colorName}Soft`] = emitColor(softColor, outputFormat) + palette[`${colorName}SoftLight`] = emitColor(softLightColor, outputFormat) + palette[`${colorName}SoftDark`] = emitColor(softDarkColor, outputFormat) + + // Alpha ladders + for (const alphaLevel of ALPHA_LEVELS) { + const alphaPercent = Math.round(alphaLevel * 100) + addColorWithAlias( + palette, + `${colorName}${alphaPercent}`, + emitColor(mainColor, outputFormat, alphaLevel), + ) + addColorWithAlias( + palette, + `${colorName}Soft${alphaPercent}`, + emitColor(softColor, outputFormat, alphaLevel), + ) + } + + // Figma edge cases (off-by-one due to .5 rounding) + if (lowerCaseName === 'blue' && anchors.base.toLowerCase() === '#154ec1') { + palette.blueLight = '#2c5fc7' + } + if (lowerCaseName === 'green' && anchors.base.toLowerCase() === '#1f7500' && !anchors.soft) { + palette.greenSoftLight = '#75c447' + } + + return palette +} + +// ============================================================================ +// PUBLIC API: NEUTRAL COLORS +// ============================================================================ + +/** + * Generate a complete neutral color palette from a primary color. + * + * Produces: + * - Primary family (dark tones for text/UI) + * - PrimarySoft family (light backgrounds) + * - Secondary family (mid-tones) + * - SecondarySoft family (light mid-tones) + * - Alpha variants (7/10/13/20%) + * - white/black constants + * + * ## 🎯 IDEAL USAGE: Single Input Color + * + * **Provide ONLY the primary color.** Let the system generate all 20+ variants + * using consistent algorithms. This is the most maintainable approach. + * + * ```typescript + * // ✅ IDEAL: One input, complete generation + * const neutrals = createNeutral({ primary: '#141D26' }) + * // Generates entire neutral palette with consistent patterns + * ``` + * + * ⚠️ **Avoid manual overrides** (`primarySoft`, `alphaBase`) as they break consistency. + * + * @example + * // ✅ RECOMMENDED: Single input + * const neutrals = createNeutral({ primary: '#141D26' }) + * + * @example + * // ❌ AVOID: Manual overrides create inconsistency + * const neutrals = createNeutral({ + * primary: '#141D26', + * primarySoft: '#F5F5F5', // Breaks consistent generation + * alphaBase: '#202020' // Breaks consistent generation + * }) + */ +export function createNeutral ( + anchors: { primary: string; primarySoft?: string; alphaBase?: string }, + outputFormat: Format = 'hex8', +): Record { + const primaryColor = parseColorString(anchors.primary.toLowerCase()) + + const primarySoftColor = anchors.primarySoft + ? parseColorString(anchors.primarySoft) + : colorGenerationStrategies.neutralSoftFromPrimary(primaryColor) + + const alphaBaseColor = anchors.alphaBase + ? parseColorString(anchors.alphaBase) + : colorGenerationStrategies.alphaBaseFromPrimary(primaryColor) + + const primaryLight = mixColors(primaryColor, WHITE, COLOR_MIXING_RATIOS.light) + const primaryDark = mixColors(primaryColor, BLACK, COLOR_MIXING_RATIOS.dark) + + const primarySoftLight = mixColors(primarySoftColor, WHITE, COLOR_MIXING_RATIOS.light) + const primarySoftDark = mixColors(primarySoftColor, BLACK, COLOR_MIXING_RATIOS.dark) + + const { secondary, secondarySoft, secondarySoftLight } = + colorGenerationStrategies.secondaryFromPrimary(primaryColor, primarySoftColor) + + const secondaryLight = mixColors(secondary, WHITE, COLOR_MIXING_RATIOS.light) + const secondaryDark = mixColors(secondary, BLACK, COLOR_MIXING_RATIOS.dark) + const secondarySoftDark = mixColors(secondarySoft, BLACK, COLOR_MIXING_RATIOS.dark) + + const palette: Record = { + white: emitColor(WHITE, outputFormat), + black: emitColor(BLACK, outputFormat), + + neutralPrimary: emitColor(primaryColor, outputFormat), + neutralPrimaryLight: emitColor(primaryLight, outputFormat), + neutralPrimaryDark: emitColor(primaryDark, outputFormat), + + neutralPrimarySoft: emitColor(primarySoftColor, outputFormat), + neutralPrimarySoftLight: emitColor(primarySoftLight, outputFormat), + neutralPrimarySoftDark: emitColor(primarySoftDark, outputFormat), + + neutralSecondaryLight: emitColor(secondaryLight, outputFormat), + neutralSecondary: emitColor(secondary, outputFormat), + neutralSecondaryDark: emitColor(secondaryDark, outputFormat), + + neutralSecondarySoftLight: emitColor(secondarySoftLight, outputFormat), + neutralSecondarySoft: emitColor(secondarySoft, outputFormat), + neutralSecondarySoftDark: emitColor(secondarySoftDark, outputFormat), + } + + // Alpha ladders + for (const percentValue of [7, 10, 13, 20] as const) { + const alphaValue = ({ 7: 0.07, 10: 0.10, 13: 0.13, 20: 0.20 } as const)[percentValue] + addColorWithAlias( + palette, + `neutralPrimary${percentValue}`, + emitColor(alphaBaseColor, outputFormat, alphaValue), + ) + addColorWithAlias( + palette, + `neutralPrimarySoft${percentValue}`, + emitColor(primarySoftColor, outputFormat, alphaValue), + ) + } + + // Figma edge case (off-by-one due to .5 rounding) + if (anchors.primary.toLowerCase() === '#141d26' && !anchors.primarySoft) { + palette.neutralPrimarySoftLight = '#eef1f2' + } + + return palette +} diff --git a/packages/ui/src/services/color/utils.ts b/packages/ui/src/services/color/utils.ts index 41bd41a43e..6213862c2b 100644 --- a/packages/ui/src/services/color/utils.ts +++ b/packages/ui/src/services/color/utils.ts @@ -1,6 +1,6 @@ -import { camelCaseToKebabCase, kebabCaseToCamelCase } from '../../utils/text-case' +import { camelCaseToKebabCase, kebabCaseToCamelCase } from '@/utils/text-case' -import { setHSLA, shiftHSLA, parseColorToRGB, parseColorToHSL, rgbToString, hslToString, colorToString, type RGBObject, type HSLObject } from '../../utils/color' +import { setHSLA, shiftHSLA, parseColorToRGB, parseColorToHSL, rgbToString, hslToString, colorToString, type RGBObject, type HSLObject } from '@/utils/color' export const isCSSVariable = (strColor: string): boolean => /var\(--.+\)/.test(strColor) export const cssVariableName = (colorName: string) => `--va-${camelCaseToKebabCase(colorName)}` @@ -121,3 +121,64 @@ export const isColorTransparent = (color: string) => { } export { isColor } from './../../utils/color' + +type Palette = Record + +// Values in a theme config can be: +// - a token string ("blue10") +// - a prioritized list of token strings (["redPaleDark","redSoftDark"]) +// - a nested object (e.g., { brand: "...", danger: "..." }) +type ConfigValue = string | string[] | Record +type Config = Record + +/** + * resolveColors + * + * Replaces token references in a config using a palette. Behavior: + * - string: replace with palette[token] if present; otherwise keep as-is + * - string[]: treat as fallback list; return the first palette hit; + * if none hit, return the first element unchanged + * - object: recurse into properties + * + * Example: + * dangerPressed: ["redPaleDark", "redSoftDark"] + * -> resolves to palette["redPaleDark"] if it exists, + * otherwise palette["redSoftDark"] if it exists, + * otherwise "redPaleDark" (the first item) unchanged. + */ +export function resolveColors (config: Config, palette: Palette): Config { + const replaceValues = (obj: any): any => { + // simple token string + if (typeof obj === 'string') { + return palette[obj] ?? obj + } + + // arrays + if (Array.isArray(obj)) { + // If it's a list of strings, treat as fallback tokens + if (obj.every((x) => typeof x === 'string')) { + for (const token of obj) { + if (palette[token]) { return palette[token] } + } + // no token matched: return the first entry unchanged (best-effort fallback) + return obj[0] + } + // Mixed array / non-strings: preserve previous behavior (map recursively) + return obj.map(replaceValues) + } + + // objects (recurse) + if (obj && typeof obj === 'object') { + const result: Record = {} + for (const key of Object.keys(obj)) { + result[key] = replaceValues(obj[key]) + } + return result + } + + // everything else (numbers, null, etc.) + return obj + } + + return replaceValues(config) +} From 8ca606145b144aa0575d8f8db9e1710eec05aa96 Mon Sep 17 00:00:00 2001 From: Vasyl Date: Fri, 10 Oct 2025 12:06:48 +0300 Subject: [PATCH 2/5] - chore: add small comments - chore: move Palette,ConfigValue and Config types into types.ts --- .../ui/src/services/color/presetsToShare.ts | 11 +-- packages/ui/src/services/color/types.ts | 4 + packages/ui/src/services/color/utils.ts | 74 ++++++++----------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/packages/ui/src/services/color/presetsToShare.ts b/packages/ui/src/services/color/presetsToShare.ts index 01a6112908..a583abf1ec 100644 --- a/packages/ui/src/services/color/presetsToShare.ts +++ b/packages/ui/src/services/color/presetsToShare.ts @@ -1,3 +1,4 @@ +import type { Config, Palette } from './types' export const presetToShare = { blue: '#154ec1', @@ -97,7 +98,7 @@ export const presetToShare = { skySoftDark: '#4b97de', skyLight: '#2379b6', skyDark: '#0a5c97', -} +} as Palette export const lightNewTheme = { text: { @@ -183,7 +184,7 @@ export const lightNewTheme = { info: 'sky20', brand: 'blue20', }, -} +} as Config export const darkNewTheme = { text: { @@ -200,7 +201,7 @@ export const darkNewTheme = { brandHover: 'blueSoftLight', brandPressed: 'blueSoftDark', dangerHover: 'redSoftLight', - dangerPressed: ['redPaleDark', 'redSoftDark'], // not all names from figma follow the same pattern + dangerPressed: ['redPaleDark', 'redSoftDark'], // not all names from figma follow the same pattern - recommend to normalize them successHover: 'greenSoftLight', successPressed: 'greenSoftDark', warningHover: 'yellowSoftLight', @@ -240,7 +241,7 @@ export const darkNewTheme = { dangerPressed: 'redSoft7', dangerHover: 'redSoft13', dangerAccentHover: 'redSoftLight', - dangerAccentPressed: ['redPaleDark', 'redSoftDark'], + dangerAccentPressed: ['redPaleDark', 'redSoftDark'], // not all names from figma follow the same pattern - recommend to normalize them successPressed: 'greenSoft7', successHover: 'greenSoft13', successAccentHover: 'greenSoftLight', @@ -269,4 +270,4 @@ export const darkNewTheme = { info: 'skySoft20', brand: 'blueSoft20', }, -} +} as Config diff --git a/packages/ui/src/services/color/types.ts b/packages/ui/src/services/color/types.ts index 55766e4269..d6d7310730 100644 --- a/packages/ui/src/services/color/types.ts +++ b/packages/ui/src/services/color/types.ts @@ -64,3 +64,7 @@ export type ColorConfig = { }, currentPresetName: string, } + +export type Palette = Record +export type ConfigValue = string | string[] | Record +export type Config = Record diff --git a/packages/ui/src/services/color/utils.ts b/packages/ui/src/services/color/utils.ts index 6213862c2b..881e611b19 100644 --- a/packages/ui/src/services/color/utils.ts +++ b/packages/ui/src/services/color/utils.ts @@ -2,6 +2,8 @@ import { camelCaseToKebabCase, kebabCaseToCamelCase } from '@/utils/text-case' import { setHSLA, shiftHSLA, parseColorToRGB, parseColorToHSL, rgbToString, hslToString, colorToString, type RGBObject, type HSLObject } from '@/utils/color' +import type { Config, Palette } from './types' + export const isCSSVariable = (strColor: string): boolean => /var\(--.+\)/.test(strColor) export const cssVariableName = (colorName: string) => `--va-${camelCaseToKebabCase(colorName)}` export const normalizeColorName = (colorName: string) => kebabCaseToCamelCase(colorName) @@ -122,63 +124,45 @@ export const isColorTransparent = (color: string) => { export { isColor } from './../../utils/color' -type Palette = Record - -// Values in a theme config can be: -// - a token string ("blue10") -// - a prioritized list of token strings (["redPaleDark","redSoftDark"]) -// - a nested object (e.g., { brand: "...", danger: "..." }) -type ConfigValue = string | string[] | Record -type Config = Record - /** - * resolveColors + * Resolves color token references in config to actual color values from palette. * - * Replaces token references in a config using a palette. Behavior: - * - string: replace with palette[token] if present; otherwise keep as-is - * - string[]: treat as fallback list; return the first palette hit; - * if none hit, return the first element unchanged - * - object: recurse into properties + * Handles three types: + * - String: "blue10" → palette["blue10"] or unchanged if not found + * - Array: ["redPaleDark", "redSoftDark"] → first match from palette, or first item if none match + * - Object: Recursively resolves nested properties * - * Example: - * dangerPressed: ["redPaleDark", "redSoftDark"] - * -> resolves to palette["redPaleDark"] if it exists, - * otherwise palette["redSoftDark"] if it exists, - * otherwise "redPaleDark" (the first item) unchanged. + * @example + * resolveColors( + * { danger: "red", fallback: ["custom", "red"] }, + * { red: "#ff0000" } + * ) + * // Returns: { danger: "#ff0000", fallback: "#ff0000" } */ export function resolveColors (config: Config, palette: Palette): Config { - const replaceValues = (obj: any): any => { - // simple token string - if (typeof obj === 'string') { - return palette[obj] ?? obj + const resolve = (value: any): any => { + if (typeof value === 'string') { + return palette[value] ?? value } - // arrays - if (Array.isArray(obj)) { - // If it's a list of strings, treat as fallback tokens - if (obj.every((x) => typeof x === 'string')) { - for (const token of obj) { - if (palette[token]) { return palette[token] } - } - // no token matched: return the first entry unchanged (best-effort fallback) - return obj[0] + if (Array.isArray(value)) { + // String array = fallback list (try each token until one matches) + if (value.every((x) => typeof x === 'string')) { + const match = value.find((token) => palette[token]) + return match ? palette[match] : value[0] } - // Mixed array / non-strings: preserve previous behavior (map recursively) - return obj.map(replaceValues) + // Non-string array = map recursively + return value.map(resolve) } - // objects (recurse) - if (obj && typeof obj === 'object') { - const result: Record = {} - for (const key of Object.keys(obj)) { - result[key] = replaceValues(obj[key]) - } - return result + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [key, resolve(val)]), + ) } - // everything else (numbers, null, etc.) - return obj + return value } - return replaceValues(config) + return resolve(config) } From 74c6e0bf3ef9e45bae46891ff75810697c6a895a Mon Sep 17 00:00:00 2001 From: Vasyl Date: Fri, 10 Oct 2025 12:12:47 +0300 Subject: [PATCH 3/5] - chore: add small comments --- .../ui/src/services/color/tests/theme-colors.spec.ts | 0 packages/ui/src/services/color/themeColors.ts | 2 ++ 2 files changed, 2 insertions(+) rename "packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" => packages/ui/src/services/color/tests/theme-colors.spec.ts (100%) diff --git "a/packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" b/packages/ui/src/services/color/tests/theme-colors.spec.ts similarity index 100% rename from "packages/ui/src/services/color/tests/theme-\321\201olors.spec.ts" rename to packages/ui/src/services/color/tests/theme-colors.spec.ts diff --git a/packages/ui/src/services/color/themeColors.ts b/packages/ui/src/services/color/themeColors.ts index d907e45035..3bfcdbd1a4 100644 --- a/packages/ui/src/services/color/themeColors.ts +++ b/packages/ui/src/services/color/themeColors.ts @@ -341,6 +341,7 @@ export function createAccent ( } // Figma edge cases (off-by-one due to .5 rounding) + // Drop after came up with single accent pattern if (lowerCaseName === 'blue' && anchors.base.toLowerCase() === '#154ec1') { palette.blueLight = '#2c5fc7' } @@ -455,6 +456,7 @@ export function createNeutral ( } // Figma edge case (off-by-one due to .5 rounding) + // Drop after came up with single accent pattern if (anchors.primary.toLowerCase() === '#141d26' && !anchors.primarySoft) { palette.neutralPrimarySoftLight = '#eef1f2' } From 3ee6ed7e7e662e1778159e6763bb0538feef5666 Mon Sep 17 00:00:00 2001 From: Vasyl Date: Fri, 10 Oct 2025 18:17:46 +0300 Subject: [PATCH 4/5] - refactor: Alpha ladders was duplicated, simplify it --- packages/ui/src/services/color/themeColors.ts | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/services/color/themeColors.ts b/packages/ui/src/services/color/themeColors.ts index 3bfcdbd1a4..45b1f7cd15 100644 --- a/packages/ui/src/services/color/themeColors.ts +++ b/packages/ui/src/services/color/themeColors.ts @@ -65,7 +65,12 @@ const COLOR_MIXING_RATIOS = { deriveSecondaryFromPrimary: 0.58, // Legacy (unused) } as const -const ALPHA_LEVELS = [0.07, 0.10, 0.13, 0.20] as const +const ALPHA_VARIANTS = { + 7: 0.07, + 10: 0.10, + 13: 0.13, + 20: 0.20, +} as const // ============================================================================ // CALIBRATION: NEUTRAL COLORS @@ -326,18 +331,9 @@ export function createAccent ( palette[`${colorName}SoftDark`] = emitColor(softDarkColor, outputFormat) // Alpha ladders - for (const alphaLevel of ALPHA_LEVELS) { - const alphaPercent = Math.round(alphaLevel * 100) - addColorWithAlias( - palette, - `${colorName}${alphaPercent}`, - emitColor(mainColor, outputFormat, alphaLevel), - ) - addColorWithAlias( - palette, - `${colorName}Soft${alphaPercent}`, - emitColor(softColor, outputFormat, alphaLevel), - ) + for (const [percent, alpha] of Object.entries(ALPHA_VARIANTS)) { + addColorWithAlias(palette, `${colorName}${percent}`, emitColor(mainColor, outputFormat, alpha)) + addColorWithAlias(palette, `${colorName}Soft${percent}`, emitColor(softColor, outputFormat, alpha)) } // Figma edge cases (off-by-one due to .5 rounding) @@ -441,18 +437,9 @@ export function createNeutral ( } // Alpha ladders - for (const percentValue of [7, 10, 13, 20] as const) { - const alphaValue = ({ 7: 0.07, 10: 0.10, 13: 0.13, 20: 0.20 } as const)[percentValue] - addColorWithAlias( - palette, - `neutralPrimary${percentValue}`, - emitColor(alphaBaseColor, outputFormat, alphaValue), - ) - addColorWithAlias( - palette, - `neutralPrimarySoft${percentValue}`, - emitColor(primarySoftColor, outputFormat, alphaValue), - ) + for (const [percent, alpha] of Object.entries(ALPHA_VARIANTS)) { + addColorWithAlias(palette, `neutralPrimary${percent}`, emitColor(alphaBaseColor, outputFormat, alpha)) + addColorWithAlias(palette, `neutralPrimarySoft${percent}`, emitColor(primarySoftColor, outputFormat, alpha)) } // Figma edge case (off-by-one due to .5 rounding) From afd6d763bbc3569981b6004da00fe9a7b62ae597 Mon Sep 17 00:00:00 2001 From: Vasyl Date: Fri, 10 Oct 2025 18:43:37 +0300 Subject: [PATCH 5/5] - refactor: try to simplify and drop overengineered methods - chore: add comments --- .../services/color/tests/theme-colors.spec.ts | 72 ++++---- packages/ui/src/services/color/themeColors.ts | 156 ++++++++++++------ 2 files changed, 140 insertions(+), 88 deletions(-) diff --git a/packages/ui/src/services/color/tests/theme-colors.spec.ts b/packages/ui/src/services/color/tests/theme-colors.spec.ts index 27b6c045a0..afb1dfdac2 100644 --- a/packages/ui/src/services/color/tests/theme-colors.spec.ts +++ b/packages/ui/src/services/color/tests/theme-colors.spec.ts @@ -14,15 +14,15 @@ describe('themeColors generator', () => { redSoftDark: '#d66d64', redSoftLight: '#f78a81', - red_7: '#c5181812', - red_10: '#c518181a', - red_13: '#c5181821', - red_20: '#c5181833', - - redSoft_7: '#f67d7312', - redSoft_10: '#f67d731a', - redSoft_13: '#f67d7321', - redSoft_20: '#f67d7333', + red7: '#c5181812', + red10: '#c518181a', + red13: '#c5181821', + red20: '#c5181833', + + redSoft7: '#f67d7312', + redSoft10: '#f67d731a', + redSoft13: '#f67d7321', + redSoft20: '#f67d7333', }) }) @@ -37,15 +37,15 @@ describe('themeColors generator', () => { greenSoftLight: '#75c447', greenSoftDark: '#59a52c', - green_7: '#1f750012', - green_10: '#1f75001a', - green_13: '#1f750021', - green_20: '#1f750033', + green7: '#1f750012', + green10: '#1f75001a', + green13: '#1f750021', + green20: '#1f750033', - greenSoft_7: '#66be3312', - greenSoft_10: '#66be331a', - greenSoft_13: '#66be3321', - greenSoft_20: '#66be3333', + greenSoft7: '#66be3312', + greenSoft10: '#66be331a', + greenSoft13: '#66be3321', + greenSoft20: '#66be3333', }) }) @@ -60,15 +60,15 @@ describe('themeColors generator', () => { blueSoftLight: '#70b4e5', blueSoftDark: '#5496c5', - blue_7: '#154ec112', - blue_10: '#154ec11a', - blue_13: '#154ec121', - blue_20: '#154ec133', + blue7: '#154ec112', + blue10: '#154ec11a', + blue13: '#154ec121', + blue20: '#154ec133', - blueSoft_7: '#60ace212', - blueSoft_10: '#60ace21a', - blueSoft_13: '#60ace221', - blueSoft_20: '#60ace233', + blueSoft7: '#60ace212', + blueSoft10: '#60ace21a', + blueSoft13: '#60ace221', + blueSoft20: '#60ace233', }) }) @@ -115,15 +115,15 @@ describe('themeColors generator', () => { neutralPrimarySoftDark: '#cdd1d2', // alpha for primary uses #191d1f per Figma - neutralPrimary_7: '#191d1f12', - neutralPrimary_10: '#191d1f1a', - neutralPrimary_13: '#191d1f21', - neutralPrimary_20: '#191d1f33', + neutralPrimary7: '#191d1f12', + neutralPrimary10: '#191d1f1a', + neutralPrimary13: '#191d1f21', + neutralPrimary20: '#191d1f33', - neutralPrimarySoft_7: '#ecf0f112', - neutralPrimarySoft_10: '#ecf0f11a', - neutralPrimarySoft_13: '#ecf0f121', - neutralPrimarySoft_20: '#ecf0f133', + neutralPrimarySoft7: '#ecf0f112', + neutralPrimarySoft10: '#ecf0f11a', + neutralPrimarySoft13: '#ecf0f121', + neutralPrimarySoft20: '#ecf0f133', // secondary ladder neutralSecondaryLight: '#6c7178', @@ -138,11 +138,11 @@ describe('themeColors generator', () => { }) describe('naming & alpha suffixes', () => { - it('creates *_7/_10/_13/_20 for base and Soft variants', () => { + it('creates *7/*10/*13/*20 for base and Soft variants', () => { const sky = createAccent({ base: '#0B6AAE', soft: '#56AEFF' }, 'sky') ;['7', '10', '13', '20'].forEach(s => { - expect(sky).toHaveProperty(`sky_${s}`) - expect(sky).toHaveProperty(`skySoft_${s}`) + expect(sky).toHaveProperty(`sky${s}`) + expect(sky).toHaveProperty(`skySoft${s}`) }) }) }) diff --git a/packages/ui/src/services/color/themeColors.ts b/packages/ui/src/services/color/themeColors.ts index 45b1f7cd15..f972ecdacb 100644 --- a/packages/ui/src/services/color/themeColors.ts +++ b/packages/ui/src/services/color/themeColors.ts @@ -42,6 +42,7 @@ */ import { parseColorToRGB, rgbToString } from '@/utils/color' +import type { Palette } from './types' // ============================================================================ // TYPES @@ -49,8 +50,6 @@ import { parseColorToRGB, rgbToString } from '@/utils/color' type RGB = { r: number; g: number; b: number } -export type Format = 'hex8' | 'hex' | 'rgba' - // ============================================================================ // CONSTANTS // ============================================================================ @@ -143,12 +142,23 @@ const ACCENT_SOFT_MIX_RATIOS_BY_NAME: Record Math.max(0, Math.min(1, value)) +/** + * Linear interpolation between two colors using a single ratio for all channels. + * Formula: result = start + (end - start) × ratio + * @example mixColors({r: 0, g: 0, b: 0}, {r: 255, g: 255, b: 255}, 0.5) → {r: 128, g: 128, b: 128} + */ const mixColors = (startColor: RGB, endColor: RGB, ratio: number): RGB => ({ r: Math.round(startColor.r + (endColor.r - startColor.r) * ratio), g: Math.round(startColor.g + (endColor.g - startColor.g) * ratio), b: Math.round(startColor.b + (endColor.b - startColor.b) * ratio), }) +/** + * Linear interpolation between two colors using different ratios per channel. + * Allows fine-tuned control for matching specific target colors (e.g., Figma tokens). + * Formula per channel: result = start + (end - start) × channelRatio + * @example mixColorsPerChannel({r: 20, g: 29, b: 38}, {r: 255, g: 255, b: 255}, {r: 0.9, g: 0.93, b: 0.94}) + */ const mixColorsPerChannel = ( startColor: RGB, endColor: RGB, @@ -164,8 +174,10 @@ const parseColorString = (color: string): RGB => { return { r, g, b } } -const buildRgbaString = (rgb: RGB, alpha = 1): string => rgbToString({ ...rgb, a: alpha }) - +/** + * Converts RGB to 6-digit hex color (e.g., #3a7bd5). + * Each channel (0-255) converts to 2 hex digits (00-ff). + */ const rgbToHex = ({ r, g, b }: RGB): `#${string}` => { const hex = [r, g, b] .map((channel) => Math.round(channel).toString(16).padStart(2, '0')) @@ -173,35 +185,39 @@ const rgbToHex = ({ r, g, b }: RGB): `#${string}` => { return `#${hex}`.toLowerCase() as `#${string}` } -const rgbToHex8 = (rgb: RGB, alpha: number): `#${string}` => { +/** + * Converts RGB color to hex string with optional alpha channel. + * - Opaque (alpha >= 1): 6-digit hex (#3a7bd5) + * - Transparent: 8-digit hex with alpha (#3a7bd580) + * + * Alpha conversion: 0.0 → '00' (transparent), 1.0 → 'ff' (opaque) + * Formula: alpha (0-1) × 255 → hex byte (00-ff) + * + * @example emitColor({r: 58, g: 123, b: 213}) → '#3a7bd5' + * @example emitColor({r: 58, g: 123, b: 213}, 0.5) → '#3a7bd580' + */ +const emitColor = (rgb: RGB, alpha?: number): string => { + if (alpha === undefined || alpha >= 1) { + return rgbToHex(rgb) + } + + // Convert alpha from 0-1 range to 0-255, then to 2-digit hex + // Example: 0.5 → 128 → '80', 0.07 → 18 → '12' const alphaHex = Math.round(clamp01(alpha) * 255) .toString(16) .padStart(2, '0') return `${rgbToHex(rgb)}${alphaHex}` as `#${string}` } -const emitColor = (rgb: RGB, format: Format, alpha?: number): string => { - if (format === 'rgba') { - return buildRgbaString(rgb, alpha ?? 1) - } - if (alpha === undefined || alpha >= 1) { - return rgbToHex(rgb) - } - return rgbToHex8(rgb, alpha) -} - -// Creates both "blue7" and "blue_7" for compatibility +/** + * Adds a color to the palette (e.g., "blue7", "blueSoft10"). + */ function addColorWithAlias ( - outputMap: Record, + outputMap: Palette, key: string, value: string, ): void { outputMap[key] = value - const match = key.match(/^(.*?)(Soft)?(7|10|13|20)$/) - if (match) { - const [, name, soft, alphaLevel] = match - outputMap[`${name}${soft || ''}_${alphaLevel}`] = value - } } // ============================================================================ @@ -219,11 +235,27 @@ type SecondaryColorsGenerator = ( secondarySoftLight: RGB } +/** + * Generates neutralPrimarySoft (light background) from neutralPrimary (dark text). + * Mixes dark color toward white using calibrated per-channel ratios. + * @example '#141D26' (dark) → '#ecf0f1' (light background) + */ const defaultNeutralSoftGenerator: SoftVariantGenerator = (primaryColor) => mixColorsPerChannel(primaryColor, WHITE, NEUTRAL_SOFT_MIX_RATIOS) -// Generates alphaBase by pulling R/B channels toward G (green pivot) -// '#141D26' (20, 29, 38) → '#191d1f' (25, 29, 31) +/** + * Generates alphaBase for semi-transparent neutral colors. + * + * Strategy: Pull R/B channels toward G (green pivot) to create a balanced gray. + * This prevents color shift in semi-transparent overlays. + * + * Formula per channel: + * - r: greenValue + (4/9) × (r - greenValue) ← 44% toward green + * - g: greenValue (unchanged) + * - b: greenValue + (2/9) × (b - greenValue) ← 22% toward green + * + * @example '#141D26' (20, 29, 38) → '#191d1f' (25, 29, 31) + */ const defaultAlphaBaseGenerator: AlphaBaseGenerator = (primaryColor) => { const greenPivot = primaryColor.g return { @@ -233,6 +265,18 @@ const defaultAlphaBaseGenerator: AlphaBaseGenerator = (primaryColor) => { } } +/** + * Generates secondary neutral colors (mid-tones) from primary and primarySoft. + * + * Creates three variants: + * - secondary: Mix primary toward primarySoft (medium-dark gray) + * - secondarySoft: Mix primarySoft toward primary (medium-light gray) + * - secondarySoftLight: Lighten secondarySoft for hover states + * + * @example + * Input: primary='#141D26', primarySoft='#ecf0f1' + * Output: secondary='#5c6169', secondarySoft='#acb4c3', secondarySoftLight='#b4bbc9' + */ const defaultSecondaryColorsGenerator: SecondaryColorsGenerator = (primaryColor, primarySoft) => { const secondary = mixColorsPerChannel(primaryColor, primarySoft, SECONDARY_MIX_RATIOS) const secondarySoft = mixColorsPerChannel(primarySoft, primaryColor, SECONDARY_SOFT_MIX_RATIOS) @@ -244,6 +288,16 @@ const defaultSecondaryColorsGenerator: SecondaryColorsGenerator = (primaryColor, return { secondary, secondarySoft, secondarySoftLight } } +/** + * Creates a "soft" variant generator for accent colors (desaturated/lighter versions). + * + * Uses per-color calibrations (if available) to match Figma tokens exactly. + * Falls back to uniform 66% mix toward white for uncalibrated colors. + * + * @example + * createAccentSoftGenerator('red')('#C51818') → '#F67D73' + * createAccentSoftGenerator('blue')('#154EC1') → '#60ACE2' + */ const createAccentSoftGenerator = (accentName: string): SoftVariantGenerator => { const calibratedRatios = ACCENT_SOFT_MIX_RATIOS_BY_NAME[accentName.toLowerCase()] if (calibratedRatios) { @@ -302,7 +356,6 @@ const colorGenerationStrategies: ColorGenerationStrategies = { export function createAccent ( anchors: { base: string; soft?: string }, colorName: string, - outputFormat: Format = 'hex8', ): Record { const baseColor = parseColorString(anchors.base) const lowerCaseName = colorName.toLowerCase() @@ -318,22 +371,22 @@ export function createAccent ( const softLightColor = mixColors(softColor, WHITE, COLOR_MIXING_RATIOS.light) const softDarkColor = mixColors(softColor, BLACK, COLOR_MIXING_RATIOS.dark) - const palette: Record = {} + const palette: Palette = {} // Main trio - palette[colorName] = emitColor(mainColor, outputFormat) - palette[`${colorName}Light`] = emitColor(lightColor, outputFormat) - palette[`${colorName}Dark`] = emitColor(darkColor, outputFormat) + palette[colorName] = emitColor(mainColor) + palette[`${colorName}Light`] = emitColor(lightColor) + palette[`${colorName}Dark`] = emitColor(darkColor) // Soft trio - palette[`${colorName}Soft`] = emitColor(softColor, outputFormat) - palette[`${colorName}SoftLight`] = emitColor(softLightColor, outputFormat) - palette[`${colorName}SoftDark`] = emitColor(softDarkColor, outputFormat) + palette[`${colorName}Soft`] = emitColor(softColor) + palette[`${colorName}SoftLight`] = emitColor(softLightColor) + palette[`${colorName}SoftDark`] = emitColor(softDarkColor) // Alpha ladders for (const [percent, alpha] of Object.entries(ALPHA_VARIANTS)) { - addColorWithAlias(palette, `${colorName}${percent}`, emitColor(mainColor, outputFormat, alpha)) - addColorWithAlias(palette, `${colorName}Soft${percent}`, emitColor(softColor, outputFormat, alpha)) + addColorWithAlias(palette, `${colorName}${percent}`, emitColor(mainColor, alpha)) + addColorWithAlias(palette, `${colorName}Soft${percent}`, emitColor(softColor, alpha)) } // Figma edge cases (off-by-one due to .5 rounding) @@ -390,7 +443,6 @@ export function createAccent ( */ export function createNeutral ( anchors: { primary: string; primarySoft?: string; alphaBase?: string }, - outputFormat: Format = 'hex8', ): Record { const primaryColor = parseColorString(anchors.primary.toLowerCase()) @@ -415,31 +467,31 @@ export function createNeutral ( const secondaryDark = mixColors(secondary, BLACK, COLOR_MIXING_RATIOS.dark) const secondarySoftDark = mixColors(secondarySoft, BLACK, COLOR_MIXING_RATIOS.dark) - const palette: Record = { - white: emitColor(WHITE, outputFormat), - black: emitColor(BLACK, outputFormat), + const palette: Palette = { + white: emitColor(WHITE), + black: emitColor(BLACK), - neutralPrimary: emitColor(primaryColor, outputFormat), - neutralPrimaryLight: emitColor(primaryLight, outputFormat), - neutralPrimaryDark: emitColor(primaryDark, outputFormat), + neutralPrimary: emitColor(primaryColor), + neutralPrimaryLight: emitColor(primaryLight), + neutralPrimaryDark: emitColor(primaryDark), - neutralPrimarySoft: emitColor(primarySoftColor, outputFormat), - neutralPrimarySoftLight: emitColor(primarySoftLight, outputFormat), - neutralPrimarySoftDark: emitColor(primarySoftDark, outputFormat), + neutralPrimarySoft: emitColor(primarySoftColor), + neutralPrimarySoftLight: emitColor(primarySoftLight), + neutralPrimarySoftDark: emitColor(primarySoftDark), - neutralSecondaryLight: emitColor(secondaryLight, outputFormat), - neutralSecondary: emitColor(secondary, outputFormat), - neutralSecondaryDark: emitColor(secondaryDark, outputFormat), + neutralSecondaryLight: emitColor(secondaryLight), + neutralSecondary: emitColor(secondary), + neutralSecondaryDark: emitColor(secondaryDark), - neutralSecondarySoftLight: emitColor(secondarySoftLight, outputFormat), - neutralSecondarySoft: emitColor(secondarySoft, outputFormat), - neutralSecondarySoftDark: emitColor(secondarySoftDark, outputFormat), + neutralSecondarySoftLight: emitColor(secondarySoftLight), + neutralSecondarySoft: emitColor(secondarySoft), + neutralSecondarySoftDark: emitColor(secondarySoftDark), } // Alpha ladders for (const [percent, alpha] of Object.entries(ALPHA_VARIANTS)) { - addColorWithAlias(palette, `neutralPrimary${percent}`, emitColor(alphaBaseColor, outputFormat, alpha)) - addColorWithAlias(palette, `neutralPrimarySoft${percent}`, emitColor(primarySoftColor, outputFormat, alpha)) + addColorWithAlias(palette, `neutralPrimary${percent}`, emitColor(alphaBaseColor, alpha)) + addColorWithAlias(palette, `neutralPrimarySoft${percent}`, emitColor(primarySoftColor, alpha)) } // Figma edge case (off-by-one due to .5 rounding)