theme-vir
Version:
Create an entire web theme.
448 lines (447 loc) • 19.6 kB
JavaScript
import { assert, assertWrap, check } from '@augment-vir/assert';
import { arrayToObject, crossProduct, filterMap, getEnumValues, getOrSet, log, mapObjectValues, removeDuplicates, stringify, } from '@augment-vir/common';
import { ContrastLevelName, calculateContrast, contrastLevelLabel, contrastLevelNameMap, findColorAtContrastLevel, } from '@electrovir/color';
import { defineColorThemeOverride } from './color-theme-override.js';
import { defineColorTheme, noRefColorInitToString, } from './color-theme.js';
/**
* Black and white color values.
*
* @category Internal
*/
export const defaultOmittedColorGroupColorValues = [
'#000000',
'#ffffff',
'#000',
'#fff',
'white',
'black',
];
/** @category Internal */
export function groupColors(colors,
/**
* Color values to omit from the grouping. Defaults to
* {@link defaultOmittedColorGroupColorValues}.
*
* @default defaultOmittedColorGroupColorValues
*/
omittedColorValues = defaultOmittedColorGroupColorValues) {
const colorGroups = {};
Object.values(colors).forEach((color) => {
if (omittedColorValues.includes(color.default)) {
return;
}
const paletteColor = extractPaletteColor(color);
getOrSet(colorGroups, paletteColor.colorName, () => []).push(paletteColor);
});
return colorGroups;
}
/** @category Internal */
export function extractPaletteColor(color) {
const split = String(color.name).replace(/^-+/, '').split('-');
const suffix = split.length > 2 ? split.at(-1) : undefined;
const prefix = assertWrap.isTruthy(split[0]);
// eslint-disable-next-line sonarjs/argument-type
const colorName = split.slice(1, suffix ? -1 : undefined).join('-');
return {
suffix,
prefix,
colorName,
definition: color,
cssVarName: String(color.name),
};
}
/** @category Internal */
export function extractParam(possibleParams, { mapFrom, mapTo, }) {
if (check.isArray(possibleParams)) {
return removeDuplicates(possibleParams.map((param) => {
if (mapFrom && check.isKeyOf(param, mapFrom)) {
return param;
}
else if (mapTo && check.isKeyOf(param, mapTo) && mapTo[param] != undefined) {
return mapTo[param];
}
else {
throw new Error(`Unknown font weight: ${String(param)}`);
}
}));
}
else {
return extractParam(filterMap(Object.entries(possibleParams), ([name, enabled,]) => {
if (enabled) {
/**
* This cast is okay because the recursive case (handling an array) will
* guard against bas names or weights.
*/
return name;
}
else {
return undefined;
}
}, check.isTruthy), {
mapTo,
mapFrom,
});
}
}
/** @category Internal */
export const defaultLightThemePair = {
background: 'white',
foreground: 'black',
};
/** @category Internal */
export const defaultContrastLevels = getEnumValues(ContrastLevelName);
/**
* Extra contrast levels generated by {@link buildColorTheme} beyond the standard `ContrastLevelName`
* values. `highest-contrast` always picks the palette color with the most contrast against the
* fixed color; `lowest-contrast` always picks the closest.
*
* @category Internal
*/
export var ExtremeContrastLevel;
(function (ExtremeContrastLevel) {
ExtremeContrastLevel["HighestContrast"] = "highest-contrast";
ExtremeContrastLevel["LowestContrast"] = "lowest-contrast";
})(ExtremeContrastLevel || (ExtremeContrastLevel = {}));
/**
* Picks the absolute lightest or darkest palette color based on which extreme produces the most (or
* least) contrast against the fixed color.
*/
function resolveExtremeContrastColor({ comparison, isHighestContrast, lightestColorString, darkestColorString, }) {
const paletteIsBackground = check.isArray(comparison.background);
const fixedColor = paletteIsBackground
? comparison.foreground
: comparison.background;
const lightContrastParams = paletteIsBackground
? {
foreground: fixedColor,
background: lightestColorString,
}
: {
foreground: lightestColorString,
background: fixedColor,
};
const darkContrastParams = paletteIsBackground
? {
foreground: fixedColor,
background: darkestColorString,
}
: {
foreground: darkestColorString,
background: fixedColor,
};
const lightestContrast = Math.abs(calculateContrast(lightContrastParams).contrast);
const darkestContrast = Math.abs(calculateContrast(darkContrastParams).contrast);
return isHighestContrast
? lightestContrast > darkestContrast
? lightestColorString
: darkestColorString
: lightestContrast < darkestContrast
? lightestColorString
: darkestColorString;
}
function findColorWithPreference(colors, desiredContrastLevel, preference,
/** Pre-computed contrast-against-white values per color string. Higher = darker. */
lightnessProxies) {
const minContrast = contrastLevelNameMap[desiredContrastLevel].min;
const candidateColors = check.isArray(colors.foreground)
? colors.foreground
: check.isArray(colors.background)
? colors.background
: [];
const qualifying = candidateColors.filter((candidate) => {
const foreground = check.isArray(colors.foreground) ? candidate : colors.foreground;
const background = check.isArray(colors.foreground) ? colors.background : candidate;
return (Math.abs(calculateContrast({
foreground,
background: background,
}).contrast) >= minContrast);
});
if (qualifying.length === 0) {
return undefined;
}
return qualifying.reduce((best, color) => {
const bestProxy = lightnessProxies[best] ?? 0;
const colorProxy = lightnessProxies[color] ?? 0;
return preference === 'lightest'
? colorProxy < bestProxy
? color
: best
: colorProxy > bestProxy
? color
: best;
});
}
/**
* Creates a color theme from a color palette.
*
* @category Color Theme
*/
export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmittedColorGroupColorValues, crossContrastLevels = defaultContrastLevels, prefix = 'vir', } = {}) {
const contrastLevels = extractParam(crossContrastLevels, {
mapFrom: contrastLevelLabel,
});
const colorGroups = groupColors(colorPalette, omittedColorValues);
const defaultTheme = {
background: 'white',
foreground: 'black',
prefix,
};
const lightThemeColors = {};
const darkThemeOverrides = {};
// Compute these once outside the loop since they don't change
const allContrastLevels = [
ExtremeContrastLevel.HighestContrast,
...contrastLevels,
ExtremeContrastLevel.LowestContrast,
];
const allCrosses = crossProduct({
crossWith: [
'color-in-foreground-light-mode',
'color-in-foreground-dark-mode',
'color-behind-bg-light-mode',
'color-behind-bg-dark-mode',
'color-behind-fg-light-mode',
'color-behind-fg-dark-mode',
'color-on-self-light-mode',
'color-on-self-dark-mode',
],
contrast: allContrastLevels,
});
const defaultForegroundString = noRefColorInitToString(defaultTheme.foreground);
const defaultBackgroundString = noRefColorInitToString(defaultTheme.background);
Object.entries(colorGroups).forEach(([colorGroupName, colors,]) => {
assert.isLengthAtLeast(colors, 1);
const colorStrings = colors.map((color) => color.definition.default);
const firstColor = colors[0];
// Create an object for O(1) color lookup instead of O(n) find()
const colorByDefault = arrayToObject(colors, (color) => ({
key: color.definition.default,
value: color,
}));
/** Pre-computed contrast-against-white per color. Higher value = darker shade. */
const lightnessProxies = arrayToObject(colorStrings, (colorString) => {
return {
key: colorString,
value: Math.abs(calculateContrast({
foreground: colorString,
background: '#ffffff',
}).contrast),
};
});
/** Lightest palette color (least contrast against white). */
const lightestColorString = colorStrings.reduce((lightest, color) => (lightnessProxies[color] ?? 0) < (lightnessProxies[lightest] ?? 0)
? color
: lightest);
/** Darkest palette color (most contrast against white). */
const darkestColorString = colorStrings.reduce((darkest, color) => (lightnessProxies[color] ?? 0) > (lightnessProxies[darkest] ?? 0) ? color : darkest);
/**
* On-self light mode: lightest fg achieving small-body contrast on the lightest palette
* bg. Fixed across all on-self contrast levels.
*/
const lightSelfFgString = assertWrap.isTruthy(findColorWithPreference({
foreground: colorStrings,
background: lightestColorString,
}, ContrastLevelName.SmallBodyText, 'lightest', lightnessProxies), `Failed to find light mode on-self foreground color for ${firstColor.colorName}`);
/**
* On-self dark mode: darkest fg achieving small-body contrast on the darkest palette
* bg. Fixed across all on-self contrast levels.
*/
const darkSelfFgString = assertWrap.isTruthy(findColorWithPreference({
foreground: colorStrings,
background: darkestColorString,
}, ContrastLevelName.SmallBodyText, 'darkest', lightnessProxies), `Failed to find dark mode on-self foreground color for ${firstColor.colorName}`);
/**
* Reversed palette order for dark mode on-self backgrounds. `findColorAtContrastLevel`
* picks the last qualifying color in array order. With the natural lightest-to-darkest
* order, high-contrast backgrounds end up as the darkest palette colors, which are
* indistinguishable from the dark page background. Reversing the order makes it select
* the lightest palette color that still achieves the required contrast level, producing
* backgrounds that are visible against the dark page.
*/
const reversedColorStrings = colorStrings.toReversed();
// Pre-compute base name parts that don't change per cross
const baseNameParts = [
prefix,
firstColor.colorName,
];
allCrosses.forEach((cross) => {
const comparison = cross.crossWith === 'color-in-foreground-light-mode'
? {
foreground: colorStrings,
background: defaultBackgroundString,
}
: cross.crossWith === 'color-in-foreground-dark-mode'
? {
foreground: colorStrings,
background: defaultForegroundString,
}
: cross.crossWith === 'color-on-self-dark-mode'
? {
foreground: darkSelfFgString,
background: reversedColorStrings,
}
: cross.crossWith === 'color-on-self-light-mode'
? {
foreground: lightSelfFgString,
background: colorStrings,
}
: cross.crossWith === 'color-behind-bg-light-mode'
? {
foreground: defaultBackgroundString,
background: colorStrings,
}
: cross.crossWith === 'color-behind-bg-dark-mode'
? {
foreground: defaultForegroundString,
background: colorStrings,
}
: cross.crossWith === 'color-behind-fg-light-mode'
? {
foreground: defaultForegroundString,
background: colorStrings,
}
: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
cross.crossWith === 'color-behind-fg-dark-mode'
? {
foreground: defaultBackgroundString,
background: colorStrings,
}
: undefined;
/** Tracks which default reference each string value in the comparison maps to. */
const referencesToDefault = cross.crossWith === 'color-in-foreground-light-mode'
? {
background: {
refDefaultBackground: true,
},
}
: cross.crossWith === 'color-in-foreground-dark-mode'
? {
background: {
refDefaultBackground: true,
},
}
: cross.crossWith === 'color-behind-bg-light-mode'
? {
foreground: {
refDefaultBackground: true,
},
}
: cross.crossWith === 'color-behind-bg-dark-mode'
? {
foreground: {
refDefaultBackground: true,
},
}
: cross.crossWith === 'color-behind-fg-light-mode'
? {
foreground: {
refDefaultForeground: true,
},
}
: cross.crossWith === 'color-behind-fg-dark-mode'
? {
foreground: {
refDefaultForeground: true,
},
}
: undefined;
if (!comparison) {
throw new Error(`Forgot to handle crossWith: '${cross.crossWith}'`);
}
const matchedColorString = cross.contrast === ExtremeContrastLevel.HighestContrast ||
cross.contrast === ExtremeContrastLevel.LowestContrast
? resolveExtremeContrastColor({
comparison,
isHighestContrast: cross.contrast === ExtremeContrastLevel.HighestContrast,
lightestColorString,
darkestColorString,
})
: findColorAtContrastLevel(comparison, cross.contrast);
const matchedColor = matchedColorString
? colorByDefault[matchedColorString]
: undefined;
if (!matchedColor) {
log.error(`No valid '${colorGroupName}' color cross found for: ${stringify(cross)} with ${stringify(colorStrings)}`);
return undefined;
}
const isSelfContrast = cross.crossWith === 'color-on-self-light-mode' ||
cross.crossWith === 'color-on-self-dark-mode';
const isBehindBg = cross.crossWith === 'color-behind-bg-light-mode' ||
cross.crossWith === 'color-behind-bg-dark-mode';
const isBehindFg = cross.crossWith === 'color-behind-fg-light-mode' ||
cross.crossWith === 'color-behind-fg-dark-mode';
const colorValue = mapObjectValues(comparison, (key, value) => {
if (check.isString(value)) {
/**
* For self-contrast modes, the foreground is the fixed string side —
* use its CSS var reference
*/
if (isSelfContrast && key === 'foreground') {
const selfFg = cross.crossWith === 'color-on-self-light-mode'
? lightSelfFgString
: darkSelfFgString;
const selfFgColor = selfFg ? colorByDefault[selfFg] : undefined;
return selfFgColor?.definition.value || value;
}
const referenceToDefault = referencesToDefault &&
check.isKeyOf(key, referencesToDefault) &&
referencesToDefault[key];
if (referenceToDefault) {
return referenceToDefault;
}
return value;
}
else {
return matchedColor.definition.value;
}
});
const isLightMode = cross.crossWith === 'color-in-foreground-light-mode' ||
cross.crossWith === 'color-on-self-light-mode' ||
cross.crossWith === 'color-behind-bg-light-mode' ||
cross.crossWith === 'color-behind-fg-light-mode';
const nameSuffix = isSelfContrast
? [
'on',
'self',
cross.contrast,
]
: isBehindBg
? [
'behind',
'bg',
cross.contrast,
]
: isBehindFg
? [
'behind',
'fg',
cross.contrast,
]
: [
'foreground',
cross.contrast,
];
const name = [
...baseNameParts,
...nameSuffix,
].join('-');
if (isLightMode) {
lightThemeColors[name] = colorValue;
}
else {
darkThemeOverrides[name] = colorValue;
}
});
});
const defaultLightTheme = defineColorTheme(defaultTheme, lightThemeColors);
return {
defaultLight: defaultLightTheme,
darkOverride: defineColorThemeOverride(defaultLightTheme, 'dark', {
defaultOverride: {
background: 'black',
foreground: 'white',
},
colorOverrides: darkThemeOverrides,
}),
};
}