@patreon/studio
Version:
Patreon Studio Design System
272 lines • 12.2 kB
JavaScript
import Color from 'color';
import { colorSchemeModes } from '../../../utilities/color-scheme';
import { LRUCache } from '../../../utilities/lru';
import { customCurve } from './bezier';
/**
* This represents the minimum contrast ratio between the creator color and a background color.
* 3.0 is AA for large (18px+), 4.5 is AA for small text.
*
* We've decided to use 3.0 for now, but we may want to revisit this. The current implementation
* is based on the optical results instead of the technical expectation of contrast for small text
* and may need be revisited.
*/
const CONTRAST_THRESHOLD = 3.0;
const SECONDARY_CONTRAST_THRESHOLD = 10.0;
// Reference colors for contrast checks when extending a palette
const white = Color('white');
const black = Color('black');
/**
* Generates a normalized color ramp based on an input color. Take the input color
* hue and saturation, then normalizes the lightness to a bezier curve.
*
* @param inputColor The color to use as the base for the generated colors.
* @param stopCount The number of colors to generate.
*
* @param hueShift The amount the hue is shifted overall
* @param hueRange The range of hues to use when generating colors.
* @param hueCurve The curve to use when generating hues.
* @param saturationBandwidth The bandwidth of saturation.
* @param saturationRange The range of saturations to use when generating colors.
* @param saturationCurve The curve to use when generating saturations.
* @param lightnessCurve The curve to use when generating lightnesses.
*
* @returns An array of colors.
*/
export function interpolateTonalColorStops({ inputColor, stopCount, hueShift = 0, hueRange = 0, saturationBandwidth = 1, saturationRange = 0, }) {
// get the original hue and saturation
const originalHue = inputColor.hue();
const originalSaturation = inputColor.saturationl();
// check if the inputColor has any lightness
const hasLightness = inputColor.lightness() > 0;
return Array.from({ length: stopCount }).map((_, i) => {
const stopProgress = i / (stopCount - 1);
// track the hue progress against the hue curve
const hueProgress = customCurve(stopProgress);
// calculate the hue bandwidth if the color has lightness
// 360 here represents the full range of hues
const hueCalcRange = hasLightness ? hueRange : 0;
const hueBandwidth = 360 * hueCalcRange;
const hueBandwidthOffset = 360 * (hueCalcRange / 2);
// calculate the hue offset
const hueOffset = hueProgress * hueBandwidth - hueBandwidthOffset;
// calculate the hue based on the hue shift and offset
const hue = hueShift * 360 + (originalHue + hueOffset);
// track the saturation progress against the saturation curve
const saturationProgress = customCurve(stopProgress);
// calculate the saturation bandwidth if the color has lightness
const saturationCalcRange = hasLightness ? saturationRange : 0;
// calculate the saturation offset so that the input saturation is the center of the bandwidth
const saturationOffset = saturationProgress * (100 * saturationCalcRange) - 100 * (saturationCalcRange / 2);
// calculate the saturation based on the bandwidth and offset
const saturation = originalSaturation === 0 ? 0 : saturationBandwidth * (originalSaturation + saturationOffset);
// track the lightness progress against the lightness curve
const lightnessProgress = customCurve(stopProgress);
// calculate the lightness based on the lightness curve
const lightness = lightnessProgress * 100;
// return a hex value
return inputColor.lightness(lightness).hue(hue).saturationl(saturation).hex();
});
}
/**
* Generates a color ramp based on an input and mix color.
*
* @param inputColor The color to use as the base for the generated colors.
* @param stopCount The number of colors to generate.
* @param mixColor The color to mix with the input color.
*
* @returns An array of colors.
*/
export function interpolateAccentColorStops({ inputColor, stopCount, mixColor, }) {
// generate the color ramp which is white/black on the left and creator color on the right
return Array.from({ length: stopCount }).map((_, i) => inputColor.mix(mixColor, 1 - i / (stopCount - 1)).hex());
}
/**
* Generates a color ramp based on an input color. The ramp is generated by interpolating
* the alpha of the input color from 0% to 100%.
*
* @param inputColor The color to use as the base for the generated colors.
* @param stopCount The number of colors to generate.
*
* @returns An array of colors.
*/
export function interpolateAlphaColorStops({ inputColor, stopCount }) {
// generate a color ramp using the creator color which is 0% alpha
// on the left and 100% alpha on the right
return Array.from({ length: stopCount }).map((_, i) => inputColor
.alpha(i / (stopCount - 1))
.rgb()
.toString());
}
/**
* Generates a complete color palette based on an input color.
*
* @param inputColor The color to use as the base for the generated colors.
* @param config The configuration used when generating colors.
*
* @returns A object containing a set of color ramps.
*/
export function uncachedGenerateColorPalette({ inputColor: userInputColor, }) {
const stopCount = 16;
const inputColor = Color(userInputColor);
// generate the tonal color ramps used for backgrounds and text
const support = interpolateTonalColorStops({
inputColor,
stopCount,
hueShift: 0,
hueRange: 0,
saturationBandwidth: 1,
saturationRange: 1,
});
// generate the tonal color ramps used for backgrounds and text
const base = interpolateTonalColorStops({
inputColor,
stopCount,
hueShift: 0,
hueRange: 0,
saturationBandwidth: 0.3,
saturationRange: 1,
});
return colorSchemeModes.reduce((acc, mode) => {
// get the functions needed to generate the extended palette
const extendPalette = getExtendPalette(mode);
// get the mix color for the accent ramp
const mixColor = getMixColor(mode);
// generate the accent color ramps used for interactive elements
const accent = interpolateAccentColorStops({ inputColor, stopCount, mixColor });
// get the input color for the alpha ramp
const alphaInputColor = getAlphaInputColor(mode, { support, base });
// generate alpha color stops based on the support ramp.
const alpha = interpolateAlphaColorStops({ inputColor: alphaInputColor, stopCount });
// extend the palette with the generated color ramps
acc[mode] = extendPalette({
accent,
alpha,
support,
base,
});
return acc;
}, {});
}
/**
* Generates a complete color palette based on an input color.
*
* @param inputColor The color to use as the base for the generated colors.
* @param config The configuration used when generating colors.
*
* @returns A object containing a set of color ramps.
*/
export const generateColorPalette = (() => {
// its okay that this is a global cache because the output of
// `generateColorPalette` is deterministic
const cache = new LRUCache({ size: 100 });
return ({ inputColor }) => {
const cachedPalette = cache.get(inputColor);
if (cachedPalette) {
return cachedPalette;
}
const palette = uncachedGenerateColorPalette({ inputColor });
cache.set(inputColor, palette);
return palette;
};
})();
/**
* Generates extended palette for the light color scheme. This has a bit of logic to determine the
* button action and onAction assignments based on contrast between the creator color and background.
*
* It will attempt to make creator color the button.action then fallback to button.onAction when
* contrast is too low.
*
* @param palette A color palette to use when generating the token map.
* @returns An extended palette with button action and onAction tokens.
*/
function extendLightModePalette(palette) {
const { accent, support } = palette;
const accentColor = Color(accent[15]);
// default to accent for button action, support for button on action
let buttonAction = [accent[15], accent[14], accent[13]];
let buttonOnAction = [support[15], support[15], support[15]];
// invert to support bg / accent text when accent doesn't have
// enough contrast with the page background
if (accentColor.contrast(white) < CONTRAST_THRESHOLD) {
buttonAction = [support[1], support[2], support[3]];
buttonOnAction = [accent[15], accent[14], accent[13]];
if (accentColor.contrast(black) < SECONDARY_CONTRAST_THRESHOLD) {
// invert to support bg / support text when accent doesn't have
// enough contrast with the support background
buttonOnAction = [support[12], support[12], support[12]];
}
}
return { ...palette, buttonAction, buttonOnAction };
}
/**
* Generates extended palette for the dark color scheme. This has a bit of logic to determine the
* button action and onAction assignments based on contrast between the creator color and background.
*
* It will attempt to make creator color the button.action, when the contrast between creator color
* and black is too low it will invert the button action pair. When the contrast between creator
* color and white is too low it will invert only the button.onAction.
*
* @param palette A color palette to use when generating the token map.
* @returns An extended palette with button action and onAction tokens.
*/
function extendDarkModePalette(palette) {
const { accent, support } = palette;
const accentColor = Color(accent[15]);
// default to accent bg / support.15 text
let buttonAction = [accent[15], accent[14], accent[13]];
let buttonOnAction = [support[15], support[14], support[13]];
// invert to support bg / accent text when accent doesn't have
// enough contrast with the page background
if (accentColor.contrast(black) < CONTRAST_THRESHOLD) {
buttonAction = [support[14], support[13], support[12]];
buttonOnAction = [accent[15], accent[15], accent[15]];
if (accentColor.contrast(white) < SECONDARY_CONTRAST_THRESHOLD) {
// invert to support bg / support text when accent doesn't have
// enough contrast with the support background
buttonOnAction = [support[3], support[3], support[3]];
}
// invert to support bg / support text when accent doesn't have
// enough contrast with the support background
}
else if (accentColor.contrast(white) < CONTRAST_THRESHOLD) {
const halfLightness = Math.floor((accentColor.lightness() * 16) / 100 / 2);
const lightnessStop = halfLightness + 6;
const index = lightnessStop;
buttonAction = [support[index], support[index - 1], support[index - 2]];
buttonOnAction = [support[1], support[1], support[1]];
}
return { ...palette, buttonAction, buttonOnAction };
}
/** Returns the extended color palette function based on the color mode. */
function getExtendPalette(colorMode) {
switch (colorMode) {
case 'dark':
return extendDarkModePalette;
case 'light':
default:
return extendLightModePalette;
}
}
/** Returns the alpha input color based on the color mode. */
function getAlphaInputColor(colorMode, { support, base }) {
// on light mode we base the alpha ramp on a support color
// whereas on dark mode we base it on a base color
switch (colorMode) {
case 'dark':
return Color(base[10]);
case 'light':
default:
return Color(support[4]);
}
}
/** Returns the mix color based on the color mode. */
function getMixColor(colorMode) {
switch (colorMode) {
case 'dark':
return black;
case 'light':
default:
return white;
}
}
//# sourceMappingURL=generateColorPalette.js.map