UNPKG

theme-vir

Version:
448 lines (447 loc) 19.6 kB
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, }), }; }