diff --git a/app/soapbox/utils/__tests__/hsl.test.ts b/app/soapbox/utils/__tests__/hsl.test.ts new file mode 100644 index 000000000..8e91ffb2c --- /dev/null +++ b/app/soapbox/utils/__tests__/hsl.test.ts @@ -0,0 +1,82 @@ +import { + hslShift, + expandPalette, + // paletteToDelta, + HSLPaletteDelta, +} from '../hsl'; + +test('hslShift()', () => { + expect(hslShift({ h: 50, s: 50, l: 50 }, [-200, 10, 0])) + .toEqual({ h: 210, s: 60, l: 50 }); + + expect(hslShift({ h: 1, s: 2, l: 3 }, [0, 0, 0])) + .toEqual({ h: 1, s: 2, l: 3 }); + + expect(hslShift({ h: 0, s: 100, l: 0 }, [-1, 1, -1])) + .toEqual({ h: 359, s: 100, l: 0 }); +}); + +test('expandPalette()', () => { + const palette = { + 200: { h: 7, s: 7, l: 7 }, + 500: { h: 50, s: 50, l: 50 }, + }; + + const paletteDelta: HSLPaletteDelta = { + 50: [-10, -100, 7], + 100: [0, 0, 0], + 200: [0, 0, 0], + 300: [0, 0, 0], + 400: [0, 0, 0], + 500: [0, 0, 0], + 600: [0, 0, 0], + 700: [0, 0, 0], + 800: [0, 0, 0], + 900: [10, 100, -7], + }; + + const expected = { + 50: { h: 40, s: 0, l: 57 }, + 100: { h: 50, s: 50, l: 50 }, + 200: { h: 7, s: 7, l: 7 }, + 300: { h: 50, s: 50, l: 50 }, + 400: { h: 50, s: 50, l: 50 }, + 500: { h: 50, s: 50, l: 50 }, + 600: { h: 50, s: 50, l: 50 }, + 700: { h: 50, s: 50, l: 50 }, + 800: { h: 50, s: 50, l: 50 }, + 900: { h: 60, s: 100, l: 43 }, + }; + + expect(expandPalette(palette, paletteDelta)).toEqual(expected); +}); + +// test('paletteToDelta()', () => { +// const palette = { +// 50: { h: 40, s: 0, l: 57 }, +// 100: { h: 50, s: 50, l: 50 }, +// 200: { h: 7, s: 7, l: 7 }, +// 300: { h: 50, s: 50, l: 50 }, +// 400: { h: 50, s: 50, l: 50 }, +// 500: { h: 50, s: 50, l: 50 }, +// 600: { h: 50, s: 50, l: 50 }, +// 700: { h: 50, s: 50, l: 50 }, +// 800: { h: 50, s: 50, l: 50 }, +// 900: { h: 60, s: 100, l: 43 }, +// }; + +// const expected = { +// 50: [-10, -50, 7], +// 100: [0, 0, 0], +// 200: [-43, -43, -43], +// 300: [0, 0, 0], +// 400: [0, 0, 0], +// 500: [0, 0, 0], +// 600: [0, 0, 0], +// 700: [0, 0, 0], +// 800: [0, 0, 0], +// 900: [10, 50, -7], +// }; + +// expect(paletteToDelta(palette)).toEqual(expected); +// }); \ No newline at end of file diff --git a/app/soapbox/utils/hsl.ts b/app/soapbox/utils/hsl.ts new file mode 100644 index 000000000..c068cd05c --- /dev/null +++ b/app/soapbox/utils/hsl.ts @@ -0,0 +1,88 @@ +import type { Hsl } from 'soapbox/types/colors'; + +/** A neutral color. */ +const GRAY: Hsl = { h: 0, s: 50, l: 50 }; + +/** Modulo (`%`) in both directions. */ +// https://stackoverflow.com/a/39740009 +const wrapAround = (value: number, delta: number, max: number): number => { + if (delta >= 0) { + return (value + delta) % max; + } else { + return max - ((max - (value + delta)) % max); + } +}; + +/** Clamp the value within the range of `min` and `max`. */ +// https://stackoverflow.com/a/47837835 +const minmax = ( + value: number, + min: number, + max: number, +) => Math.min(max, Math.max(min, value)); + +/** + * Represents an HSL color shift. + * + * For example, `[-20, 10, 0]` means "-20deg hue, +10% saturation, unchanged lightness". +*/ +type HSLDelta = [hDelta: number, sDelta: number, lDelta: number]; + +/** Tailwind color shade. */ +type Shade = '50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; +/** Tailwind color palette with HSL. */ +type HSLPalette = Record; +/** Tailwind color palette delta map (in HSL). */ +type HSLPaletteDelta = Record; + +/** Alter the color by the given delta. */ +const hslShift = (seed: Hsl, delta: HSLDelta): Hsl => { + return { + h: wrapAround(seed.h, delta[0], 360), + s: minmax(seed.s + delta[1], 0, 100), + l: minmax(seed.l + delta[2], 0, 100), + }; + +}; + +/** Generate a color palette from a single color. */ +const generatePalette = (seed: Hsl, paletteDelta: HSLPaletteDelta): HSLPalette => { + const shades = Object.keys(paletteDelta) as Shade[]; + + return shades.reduce((result: HSLPalette, shade: Shade) => { + const delta = paletteDelta[shade]; + result[shade] = hslShift(seed, delta); + return result; + }, {} as HSLPalette); +}; + +/** Expands a partial color palette, filling in the gaps. */ +const expandPalette = (palette: Partial, paletteDelta: HSLPaletteDelta): HSLPalette => { + const seed = palette['500'] || GRAY; + const generated = generatePalette(seed, paletteDelta); + return Object.assign(generated, palette); +}; + +/** Convert a complete color palette into a delta map. */ +const paletteToDelta = (palette: HSLPalette): HSLPaletteDelta => { + const seed = palette['500']; + const shades = Object.keys(palette) as Shade[]; + + return shades.reduce((result: HSLPaletteDelta, shade: Shade) => { + const color = palette[shade]; + result[shade] = [ + wrapAround(color.h, -seed.h, 360), + minmax(color.s - seed.s, -100, 100), + minmax(color.l - seed.l, -100, 100), + ]; + return result; + }, {} as HSLPaletteDelta); +}; + +export { + hslShift, + expandPalette, + paletteToDelta, + HSLDelta, + HSLPaletteDelta, +}; \ No newline at end of file