diff options
Diffstat (limited to 'src/services')
| -rw-r--r-- | src/services/color_convert/color_convert.js | 128 | ||||
| -rw-r--r-- | src/services/style_setter/style_setter.js | 532 | ||||
| -rw-r--r-- | src/services/theme_data/pleromafe.js | 615 | ||||
| -rw-r--r-- | src/services/theme_data/theme_data.service.js | 373 |
4 files changed, 1298 insertions, 350 deletions
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js index d1b17c61..ec104269 100644 --- a/src/services/color_convert/color_convert.js +++ b/src/services/color_convert/color_convert.js @@ -1,16 +1,27 @@ -import { map } from 'lodash' +import { invertLightness, contrastRatio } from 'chromatism' -const rgb2hex = (r, g, b) => { +// useful for visualizing color when debugging +export const consoleColor = (color) => console.log('%c##########', 'background: ' + color + '; color: ' + color) + +/** + * Convert r, g, b values into hex notation. All components are [0-255] + * + * @param {Number|String|Object} r - Either red component, {r,g,b} object, or hex string + * @param {Number} [g] - Green component + * @param {Number} [b] - Blue component + */ +export const rgb2hex = (r, g, b) => { if (r === null || typeof r === 'undefined') { return undefined } - if (r[0] === '#') { + // TODO: clean up this mess + if (r[0] === '#' || r === 'transparent') { return r } if (typeof r === 'object') { ({ r, g, b } = r) } - [r, g, b] = map([r, g, b], (val) => { + [r, g, b] = [r, g, b].map(val => { val = Math.ceil(val) val = val < 0 ? 0 : val val = val > 255 ? 255 : val @@ -58,7 +69,7 @@ const srgbToLinear = (srgb) => { * @param {Object} srgb - sRGB color * @returns {Number} relative luminance */ -const relativeLuminance = (srgb) => { +export const relativeLuminance = (srgb) => { const { r, g, b } = srgbToLinear(srgb) return 0.2126 * r + 0.7152 * g + 0.0722 * b } @@ -71,7 +82,7 @@ const relativeLuminance = (srgb) => { * @param {Object} b - sRGB color * @returns {Number} color ratio */ -const getContrastRatio = (a, b) => { +export const getContrastRatio = (a, b) => { const la = relativeLuminance(a) const lb = relativeLuminance(b) const [l1, l2] = la > lb ? [la, lb] : [lb, la] @@ -80,6 +91,17 @@ const getContrastRatio = (a, b) => { } /** + * Same as `getContrastRatio` but for multiple layers in-between + * + * @param {Object} text - text color (topmost layer) + * @param {[Object, Number]} layers[] - layers between text and bedrock + * @param {Object} bedrock - layer at the very bottom + */ +export const getContrastRatioLayers = (text, layers, bedrock) => { + return getContrastRatio(alphaBlendLayers(bedrock, layers), text) +} + +/** * This performs alpha blending between solid background and semi-transparent foreground * * @param {Object} fg - top layer color @@ -87,7 +109,7 @@ const getContrastRatio = (a, b) => { * @param {Object} bg - bottom layer color * @returns {Object} sRGB of resulting color */ -const alphaBlend = (fg, fga, bg) => { +export const alphaBlend = (fg, fga, bg) => { if (fga === 1 || typeof fga === 'undefined') return fg return 'rgb'.split('').reduce((acc, c) => { // Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending @@ -97,14 +119,30 @@ const alphaBlend = (fg, fga, bg) => { }, {}) } -const invert = (rgb) => { +/** + * Same as `alphaBlend` but for multiple layers in-between + * + * @param {Object} bedrock - layer at the very bottom + * @param {[Object, Number]} layers[] - layers between text and bedrock + */ +export const alphaBlendLayers = (bedrock, layers) => layers.reduce((acc, [color, opacity]) => { + return alphaBlend(color, opacity, acc) +}, bedrock) + +export const invert = (rgb) => { return 'rgb'.split('').reduce((acc, c) => { acc[c] = 255 - rgb[c] return acc }, {}) } -const hex2rgb = (hex) => { +/** + * Converts #rrggbb hex notation into an {r, g, b} object + * + * @param {String} hex - #rrggbb string + * @returns {Object} rgb representation of the color, values are 0-255 + */ +export const hex2rgb = (hex) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return result ? { r: parseInt(result[1], 16), @@ -113,18 +151,72 @@ const hex2rgb = (hex) => { } : null } -const mixrgb = (a, b) => { - return Object.keys(a).reduce((acc, k) => { +/** + * Old somewhat weird function for mixing two colors together + * + * @param {Object} a - one color (rgb) + * @param {Object} b - other color (rgb) + * @returns {Object} result + */ +export const mixrgb = (a, b) => { + return 'rgb'.split('').reduce((acc, k) => { acc[k] = (a[k] + b[k]) / 2 return acc }, {}) } +/** + * Converts rgb object into a CSS rgba() color + * + * @param {Object} color - rgb + * @returns {String} CSS rgba() color + */ +export const rgba2css = function (rgba) { + return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a})` +} -export { - rgb2hex, - hex2rgb, - mixrgb, - invert, - getContrastRatio, - alphaBlend +/** + * Get text color for given background color and intended text color + * This checks if text and background don't have enough color and inverts + * text color's lightness if needed. If text color is still not enough it + * will fall back to black or white + * + * @param {Object} bg - background color + * @param {Object} text - intended text color + * @param {Boolean} preserve - try to preserve intended text color's hue/saturation (i.e. no BW) + */ +export const getTextColor = function (bg, text, preserve) { + const contrast = getContrastRatio(bg, text) + + if (contrast < 4.5) { + const base = typeof text.a !== 'undefined' ? { a: text.a } : {} + const result = Object.assign(base, invertLightness(text).rgb) + if (!preserve && getContrastRatio(bg, result) < 4.5) { + // B&W + return contrastRatio(bg, text).rgb + } + // Inverted color + return result + } + return text +} + +/** + * Converts color to CSS Color value + * + * @param {Object|String} input - color + * @param {Number} [a] - alpha value + * @returns {String} a CSS Color value + */ +export const getCssColor = (input, a) => { + let rgb = {} + if (typeof input === 'object') { + rgb = input + } else if (typeof input === 'string') { + if (input.startsWith('#')) { + rgb = hex2rgb(input) + } else { + return input + } + } + return rgba2css({ ...rgb, a }) } diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index eaa495c4..fbdcf562 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -1,78 +1,9 @@ -import { times } from 'lodash' -import { brightness, invertLightness, convert, contrastRatio } from 'chromatism' -import { rgb2hex, hex2rgb, mixrgb, getContrastRatio, alphaBlend } from '../color_convert/color_convert.js' +import { convert } from 'chromatism' +import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js' +import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js' -// While this is not used anymore right now, I left it in if we want to do custom -// styles that aren't just colors, so user can pick from a few different distinct -// styles as well as set their own colors in the future. - -const setStyle = (href, commit) => { - /*** - What's going on here? - I want to make it easy for admins to style this application. To have - a good set of default themes, I chose the system from base16 - (https://chriskempson.github.io/base16/) to style all elements. They - all have the base00..0F classes. So the only thing an admin needs to - do to style Pleroma is to change these colors in that one css file. - Some default things (body text color, link color) need to be set dy- - namically, so this is done here by waiting for the stylesheet to be - loaded and then creating an element with the respective classes. - - It is a bit weird, but should make life for admins somewhat easier. - ***/ - const head = document.head - const body = document.body - body.classList.add('hidden') - const cssEl = document.createElement('link') - cssEl.setAttribute('rel', 'stylesheet') - cssEl.setAttribute('href', href) - head.appendChild(cssEl) - - const setDynamic = () => { - const baseEl = document.createElement('div') - body.appendChild(baseEl) - - let colors = {} - times(16, (n) => { - const name = `base0${n.toString(16).toUpperCase()}` - baseEl.setAttribute('class', name) - const color = window.getComputedStyle(baseEl).getPropertyValue('color') - colors[name] = color - }) - - body.removeChild(baseEl) - - const styleEl = document.createElement('style') - head.appendChild(styleEl) - // const styleSheet = styleEl.sheet - - body.classList.remove('hidden') - } - - cssEl.addEventListener('load', setDynamic) -} - -const rgb2rgba = function (rgba) { - return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})` -} - -const getTextColor = function (bg, text, preserve) { - const bgIsLight = convert(bg).hsl.l > 50 - const textIsLight = convert(text).hsl.l > 50 - - if ((bgIsLight && textIsLight) || (!bgIsLight && !textIsLight)) { - const base = typeof text.a !== 'undefined' ? { a: text.a } : {} - const result = Object.assign(base, invertLightness(text).rgb) - if (!preserve && getContrastRatio(bg, result) < 4.5) { - return contrastRatio(bg, text).rgb - } - return result - } - return text -} - -const applyTheme = (input, commit) => { - const { rules, theme } = generatePreset(input) +export const applyTheme = (input) => { + const { rules } = generatePreset(input) const head = document.head const body = document.body body.classList.add('hidden') @@ -87,14 +18,9 @@ const applyTheme = (input, commit) => { styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max') styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max') body.classList.remove('hidden') - - // commit('setOption', { name: 'colors', value: htmlColors }) - // commit('setOption', { name: 'radii', value: radii }) - commit('setOption', { name: 'customTheme', value: input }) - commit('setOption', { name: 'colors', value: theme.colors }) } -const getCssShadow = (input, usesDropShadow) => { +export const getCssShadow = (input, usesDropShadow) => { if (input.length === 0) { return 'none' } @@ -132,122 +58,18 @@ const getCssShadowFilter = (input) => { .join(' ') } -const getCssColor = (input, a) => { - let rgb = {} - if (typeof input === 'object') { - rgb = input - } else if (typeof input === 'string') { - if (input.startsWith('#')) { - rgb = hex2rgb(input) - } else if (input.startsWith('--')) { - return `var(${input})` - } else { - return input - } - } - return rgb2rgba({ ...rgb, a }) -} - -const generateColors = (input) => { - const colors = {} - const opacity = Object.assign({ - alert: 0.5, - input: 0.5, - faint: 0.5 - }, Object.entries(input.opacity || {}).reduce((acc, [k, v]) => { - if (typeof v !== 'undefined') { - acc[k] = v - } - return acc - }, {})) - const col = Object.entries(input.colors || input).reduce((acc, [k, v]) => { - if (typeof v === 'object') { - acc[k] = v - } else { - acc[k] = hex2rgb(v) - } - return acc - }, {}) - - const isLightOnDark = convert(col.bg).hsl.l < convert(col.text).hsl.l - const mod = isLightOnDark ? 1 : -1 - - colors.text = col.text - colors.lightText = brightness(20 * mod, colors.text).rgb - colors.link = col.link - colors.faint = col.faint || Object.assign({}, col.text) - - colors.bg = col.bg - colors.lightBg = col.lightBg || brightness(5, colors.bg).rgb - - colors.fg = col.fg - colors.fgText = col.fgText || getTextColor(colors.fg, colors.text) - colors.fgLink = col.fgLink || getTextColor(colors.fg, colors.link, true) - - colors.border = col.border || brightness(2 * mod, colors.fg).rgb - - colors.btn = col.btn || Object.assign({}, col.fg) - colors.btnText = col.btnText || getTextColor(colors.btn, colors.fgText) - - colors.input = col.input || Object.assign({}, col.fg) - colors.inputText = col.inputText || getTextColor(colors.input, colors.lightText) - - colors.panel = col.panel || Object.assign({}, col.fg) - colors.panelText = col.panelText || getTextColor(colors.panel, colors.fgText) - colors.panelLink = col.panelLink || getTextColor(colors.panel, colors.fgLink) - colors.panelFaint = col.panelFaint || getTextColor(colors.panel, colors.faint) - - colors.topBar = col.topBar || Object.assign({}, col.fg) - colors.topBarText = col.topBarText || getTextColor(colors.topBar, colors.fgText) - colors.topBarLink = col.topBarLink || getTextColor(colors.topBar, colors.fgLink) - - colors.faintLink = col.faintLink || Object.assign({}, col.link) - colors.linkBg = alphaBlend(colors.link, 0.4, colors.bg) - - colors.icon = mixrgb(colors.bg, colors.text) - - colors.cBlue = col.cBlue || hex2rgb('#0000FF') - colors.cRed = col.cRed || hex2rgb('#FF0000') - colors.cGreen = col.cGreen || hex2rgb('#00FF00') - colors.cOrange = col.cOrange || hex2rgb('#E3FF00') +export const generateColors = (themeData) => { + const sourceColors = !themeData.themeEngineVersion + ? colors2to3(themeData.colors || themeData) + : themeData.colors || themeData - colors.alertError = col.alertError || Object.assign({}, colors.cRed) - colors.alertErrorText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.bg), colors.text) - colors.alertErrorPanelText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.panel), colors.panelText) - - colors.alertWarning = col.alertWarning || Object.assign({}, colors.cOrange) - colors.alertWarningText = getTextColor(alphaBlend(colors.alertWarning, opacity.alert, colors.bg), colors.text) - colors.alertWarningPanelText = getTextColor(alphaBlend(colors.alertWarning, opacity.alert, colors.panel), colors.panelText) - - colors.badgeNotification = col.badgeNotification || Object.assign({}, colors.cRed) - colors.badgeNotificationText = contrastRatio(colors.badgeNotification).rgb - - Object.entries(opacity).forEach(([ k, v ]) => { - if (typeof v === 'undefined') return - if (k === 'alert') { - colors.alertError.a = v - colors.alertWarning.a = v - return - } - if (k === 'faint') { - colors[k + 'Link'].a = v - colors['panelFaint'].a = v - } - if (k === 'bg') { - colors['lightBg'].a = v - } - if (colors[k]) { - colors[k].a = v - } else { - console.error('Wrong key ' + k) - } - }) + const { colors, opacity } = getColors(sourceColors, themeData.opacity || {}) const htmlColors = Object.entries(colors) .reduce((acc, [k, v]) => { if (!v) return acc acc.solid[k] = rgb2hex(v) - acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgb2rgba(v) + acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v) return acc }, { complete: {}, solid: {} }) return { @@ -264,7 +86,7 @@ const generateColors = (input) => { } } -const generateRadii = (input) => { +export const generateRadii = (input) => { let inputRadii = input.radii || {} // v1 -> v2 if (typeof input.btnRadius !== 'undefined') { @@ -297,7 +119,7 @@ const generateRadii = (input) => { } } -const generateFonts = (input) => { +export const generateFonts = (input) => { const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => { acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => { acc[k] = v @@ -332,89 +154,123 @@ const generateFonts = (input) => { } } -const generateShadows = (input) => { - const border = (top, shadow) => ({ - x: 0, - y: top ? 1 : -1, - blur: 0, +const border = (top, shadow) => ({ + x: 0, + y: top ? 1 : -1, + blur: 0, + spread: 0, + color: shadow ? '#000000' : '#FFFFFF', + alpha: 0.2, + inset: true +}) +const buttonInsetFakeBorders = [border(true, false), border(false, true)] +const inputInsetFakeBorders = [border(true, true), border(false, false)] +const hoverGlow = { + x: 0, + y: 0, + blur: 4, + spread: 0, + color: '--faint', + alpha: 1 +} + +export const DEFAULT_SHADOWS = { + panel: [{ + x: 1, + y: 1, + blur: 4, spread: 0, - color: shadow ? '#000000' : '#FFFFFF', - alpha: 0.2, - inset: true - }) - const buttonInsetFakeBorders = [border(true, false), border(false, true)] - const inputInsetFakeBorders = [border(true, true), border(false, false)] - const hoverGlow = { + color: '#000000', + alpha: 0.6 + }], + topBar: [{ x: 0, y: 0, blur: 4, spread: 0, - color: '--faint', + color: '#000000', + alpha: 0.6 + }], + popup: [{ + x: 2, + y: 2, + blur: 3, + spread: 0, + color: '#000000', + alpha: 0.5 + }], + avatar: [{ + x: 0, + y: 1, + blur: 8, + spread: 0, + color: '#000000', + alpha: 0.7 + }], + avatarStatus: [], + panelHeader: [], + button: [{ + x: 0, + y: 0, + blur: 2, + spread: 0, + color: '#000000', alpha: 1 + }, ...buttonInsetFakeBorders], + buttonHover: [hoverGlow, ...buttonInsetFakeBorders], + buttonPressed: [hoverGlow, ...inputInsetFakeBorders], + input: [...inputInsetFakeBorders, { + x: 0, + y: 0, + blur: 2, + inset: true, + spread: 0, + color: '#000000', + alpha: 1 + }] +} +export const generateShadows = (input, colors) => { + // TODO this is a small hack for `mod` to work with shadows + // this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element + const hackContextDict = { + button: 'btn', + panel: 'bg', + top: 'topBar', + popup: 'popover', + avatar: 'bg', + panelHeader: 'panel', + input: 'input' } - - const shadows = { - panel: [{ - x: 1, - y: 1, - blur: 4, - spread: 0, - color: '#000000', - alpha: 0.6 - }], - topBar: [{ - x: 0, - y: 0, - blur: 4, - spread: 0, - color: '#000000', - alpha: 0.6 - }], - popup: [{ - x: 2, - y: 2, - blur: 3, - spread: 0, - color: '#000000', - alpha: 0.5 - }], - avatar: [{ - x: 0, - y: 1, - blur: 8, - spread: 0, - color: '#000000', - alpha: 0.7 - }], - avatarStatus: [], - panelHeader: [], - button: [{ - x: 0, - y: 0, - blur: 2, - spread: 0, - color: '#000000', - alpha: 1 - }, ...buttonInsetFakeBorders], - buttonHover: [hoverGlow, ...buttonInsetFakeBorders], - buttonPressed: [hoverGlow, ...inputInsetFakeBorders], - input: [...inputInsetFakeBorders, { - x: 0, - y: 0, - blur: 2, - inset: true, - spread: 0, - color: '#000000', - alpha: 1 - }], - ...(input.shadows || {}) - } + const inputShadows = input.shadows && !input.themeEngineVersion + ? shadows2to3(input.shadows, input.opacity) + : input.shadows || {} + const shadows = Object.entries({ + ...DEFAULT_SHADOWS, + ...inputShadows + }).reduce((shadowsAcc, [slotName, shadowDefs]) => { + const slotFirstWord = slotName.replace(/[A-Z].*$/, '') + const colorSlotName = hackContextDict[slotFirstWord] + const isLightOnDark = relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5 + const mod = isLightOnDark ? 1 : -1 + const newShadow = shadowDefs.reduce((shadowAcc, def) => [ + ...shadowAcc, + { + ...def, + color: rgb2hex(computeDynamicColor( + def.color, + (variableSlot) => convert(colors[variableSlot]).rgb, + mod + )) + } + ], []) + return { ...shadowsAcc, [slotName]: newShadow } + }, {}) return { rules: { shadows: Object .entries(shadows) - // TODO for v2.1: if shadow doesn't have non-inset shadows with spread > 0 - optionally + // TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally // convert all non-inset shadows into filter: drop-shadow() to boost performance .map(([k, v]) => [ `--${k}Shadow: ${getCssShadow(v)}`, @@ -429,7 +285,7 @@ const generateShadows = (input) => { } } -const composePreset = (colors, radii, shadows, fonts) => { +export const composePreset = (colors, radii, shadows, fonts) => { return { rules: { ...shadows.rules, @@ -446,98 +302,110 @@ const composePreset = (colors, radii, shadows, fonts) => { } } -const generatePreset = (input) => { - const shadows = generateShadows(input) +export const generatePreset = (input) => { const colors = generateColors(input) - const radii = generateRadii(input) - const fonts = generateFonts(input) - - return composePreset(colors, radii, shadows, fonts) + return composePreset( + colors, + generateRadii(input), + generateShadows(input, colors.theme.colors, colors.mod), + generateFonts(input) + ) } -const getThemes = () => { - return window.fetch('/static/styles.json') +export const getThemes = () => { + const cache = 'no-store' + + return window.fetch('/static/styles.json', { cache }) .then((data) => data.json()) .then((themes) => { - return Promise.all(Object.entries(themes).map(([k, v]) => { + return Object.entries(themes).map(([k, v]) => { + let promise = null if (typeof v === 'object') { - return Promise.resolve([k, v]) + promise = Promise.resolve(v) } else if (typeof v === 'string') { - return window.fetch(v) + promise = window.fetch(v, { cache }) .then((data) => data.json()) - .then((theme) => { - return [k, theme] - }) .catch((e) => { console.error(e) - return [] + return null }) } - })) + return [k, promise] + }) }) .then((promises) => { return promises - .filter(([k, v]) => v) .reduce((acc, [k, v]) => { acc[k] = v return acc }, {}) }) } +export const colors2to3 = (colors) => { + return Object.entries(colors).reduce((acc, [slotName, color]) => { + const btnPositions = ['', 'Panel', 'TopBar'] + switch (slotName) { + case 'lightBg': + return { ...acc, highlight: color } + case 'btnText': + return { + ...acc, + ...btnPositions + .reduce( + (statePositionAcc, position) => + ({ ...statePositionAcc, ['btn' + position + 'Text']: color }) + , {} + ) + } + default: + return { ...acc, [slotName]: color } + } + }, {}) +} -const setPreset = (val, commit) => { - return getThemes().then((themes) => { - const theme = themes[val] ? themes[val] : themes['pleroma-dark'] - const isV1 = Array.isArray(theme) - const data = isV1 ? {} : theme.theme - - if (isV1) { - const bgRgb = hex2rgb(theme[1]) - const fgRgb = hex2rgb(theme[2]) - const textRgb = hex2rgb(theme[3]) - const linkRgb = hex2rgb(theme[4]) - - const cRedRgb = hex2rgb(theme[5] || '#FF0000') - const cGreenRgb = hex2rgb(theme[6] || '#00FF00') - const cBlueRgb = hex2rgb(theme[7] || '#0000FF') - const cOrangeRgb = hex2rgb(theme[8] || '#E3FF00') +/** + * This handles compatibility issues when importing v2 theme's shadows to current format + * + * Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables + */ +export const shadows2to3 = (shadows, opacity) => { + return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => { + const isDynamic = ({ color }) => color.startsWith('--') + const getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])] + const newShadow = shadowDefs.reduce((shadowAcc, def) => [ + ...shadowAcc, + { + ...def, + alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha + } + ], []) + return { ...shadowsAcc, [slotName]: newShadow } + }, {}) +} - data.colors = { - bg: bgRgb, - fg: fgRgb, - text: textRgb, - link: linkRgb, - cRed: cRedRgb, - cBlue: cBlueRgb, - cGreen: cGreenRgb, - cOrange: cOrangeRgb +export const getPreset = (val) => { + return getThemes() + .then((themes) => themes[val] ? themes[val] : themes['pleroma-dark']) + .then((theme) => { + const isV1 = Array.isArray(theme) + const data = isV1 ? {} : theme.theme + + if (isV1) { + const bg = hex2rgb(theme[1]) + const fg = hex2rgb(theme[2]) + const text = hex2rgb(theme[3]) + const link = hex2rgb(theme[4]) + + const cRed = hex2rgb(theme[5] || '#FF0000') + const cGreen = hex2rgb(theme[6] || '#00FF00') + const cBlue = hex2rgb(theme[7] || '#0000FF') + const cOrange = hex2rgb(theme[8] || '#E3FF00') + + data.colors = { bg, fg, text, link, cRed, cBlue, cGreen, cOrange } } - } - // This is a hack, this function is only called during initial load. - // We want to cancel loading the theme from config.json if we're already - // loading a theme from the persisted state. - // Needed some way of dealing with the async way of things. - // load config -> set preset -> wait for styles.json to load -> - // load persisted state -> set colors -> styles.json loaded -> set colors - if (!window.themeLoaded) { - applyTheme(data, commit) - } - }) + return { theme: data, source: theme.source } + }) } -export { - setStyle, - setPreset, - applyTheme, - getTextColor, - generateColors, - generateRadii, - generateShadows, - generateFonts, - generatePreset, - getThemes, - composePreset, - getCssShadow, - getCssShadowFilter -} +export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme)) diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js new file mode 100644 index 00000000..33a2ed57 --- /dev/null +++ b/src/services/theme_data/pleromafe.js @@ -0,0 +1,615 @@ +import { invertLightness, brightness } from 'chromatism' +import { alphaBlend, mixrgb } from '../color_convert/color_convert.js' +/* This is a definition of all layer combinations + * each key is a topmost layer, each value represents layer underneath + * this is essentially a simplified tree + */ +export const LAYERS = { + undelay: null, // root + topBar: null, // no transparency support + badge: null, // no transparency support + fg: null, + bg: 'underlay', + highlight: 'bg', + panel: 'bg', + popover: 'bg', + selectedMenu: 'popover', + btn: 'bg', + btnPanel: 'panel', + btnTopBar: 'topBar', + input: 'bg', + inputPanel: 'panel', + inputTopBar: 'topBar', + alert: 'bg', + alertPanel: 'panel', + poll: 'bg' +} + +/* By default opacity slots have 1 as default opacity + * this allows redefining it to something else + */ +export const DEFAULT_OPACITY = { + alert: 0.5, + input: 0.5, + faint: 0.5, + underlay: 0.15 +} + +/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta + * Color and opacity slots definitions. Each key represents a slot. + * + * Short-hands: + * String beginning with `--` - value after dashes treated as sole + * dependency - i.e. `--value` equivalent to { depends: ['value']} + * String beginning with `#` - value would be treated as solid color + * defined in hexadecimal representation (i.e. #FFFFFF) and will be + * used as default. `#FFFFFF` is equivalent to { default: '#FFFFFF'} + * + * Full definition: + * @property {String[]} depends - color slot names this color depends ones. + * cyclic dependencies are supported to some extent but not recommended. + * @property {String} [opacity] - opacity slot used by this color slot. + * opacity is inherited from parents. To break inheritance graph use null + * @property {Number} [priority] - EXPERIMENTAL. used to pre-sort slots so + * that slots with higher priority come earlier + * @property {Function(mod, ...colors)} [color] - function that will be + * used to determine the color. By default it just copies first color in + * dependency list. + * @argument {Number} mod - `1` (light-on-dark) or `-1` (dark-on-light) + * depending on background color (for textColor)/given color. + * @argument {...Object} deps - each argument after mod represents each + * color from `depends` array. All colors take user customizations into + * account and represented by { r, g, b } objects. + * @returns {Object} resulting color, should be in { r, g, b } form + * + * @property {Boolean|String} [textColor] - true to mark color slot as text + * color. This enables automatic text color generation for the slot. Use + * 'preserve' string if you don't want text color to fall back to + * black/white. Use 'bw' to only ever use black or white. This also makes + * following properties required: + * @property {String} [layer] - which layer the text sit on top on - used + * to account for transparency in text color calculation + * layer is inherited from parents. To break inheritance graph use null + * @property {String} [variant] - which color slot is background (same as + * above, used to account for transparency) + */ +export const SLOT_INHERITANCE = { + bg: { + depends: [], + opacity: 'bg', + priority: 1 + }, + fg: { + depends: [], + priority: 1 + }, + text: { + depends: [], + layer: 'bg', + opacity: null, + priority: 1 + }, + underlay: { + default: '#000000', + opacity: 'underlay' + }, + link: { + depends: ['accent'], + priority: 1 + }, + accent: { + depends: ['link'], + priority: 1 + }, + faint: { + depends: ['text'], + opacity: 'faint' + }, + faintLink: { + depends: ['link'], + opacity: 'faint' + }, + postFaintLink: { + depends: ['postLink'], + opacity: 'faint' + }, + + cBlue: '#0000ff', + cRed: '#FF0000', + cGreen: '#00FF00', + cOrange: '#E3FF00', + + highlight: { + depends: ['bg'], + color: (mod, bg) => brightness(5 * mod, bg).rgb + }, + highlightLightText: { + depends: ['lightText'], + layer: 'highlight', + textColor: true + }, + highlightPostLink: { + depends: ['postLink'], + layer: 'highlight', + textColor: 'preserve' + }, + highlightFaintText: { + depends: ['faint'], + layer: 'highlight', + textColor: true + }, + highlightFaintLink: { + depends: ['faintLink'], + layer: 'highlight', + textColor: 'preserve' + }, + highlightPostFaintLink: { + depends: ['postFaintLink'], + layer: 'highlight', + textColor: 'preserve' + }, + highlightText: { + depends: ['text'], + layer: 'highlight', + textColor: true + }, + highlightLink: { + depends: ['link'], + layer: 'highlight', + textColor: 'preserve' + }, + highlightIcon: { + depends: ['highlight', 'highlightText'], + color: (mod, bg, text) => mixrgb(bg, text) + }, + + popover: { + depends: ['bg'], + opacity: 'popover' + }, + popoverLightText: { + depends: ['lightText'], + layer: 'popover', + textColor: true + }, + popoverPostLink: { + depends: ['postLink'], + layer: 'popover', + textColor: 'preserve' + }, + popoverFaintText: { + depends: ['faint'], + layer: 'popover', + textColor: true + }, + popoverFaintLink: { + depends: ['faintLink'], + layer: 'popover', + textColor: 'preserve' + }, + popoverPostFaintLink: { + depends: ['postFaintLink'], + layer: 'popover', + textColor: 'preserve' + }, + popoverText: { + depends: ['text'], + layer: 'popover', + textColor: true + }, + popoverLink: { + depends: ['link'], + layer: 'popover', + textColor: 'preserve' + }, + popoverIcon: { + depends: ['popover', 'popoverText'], + color: (mod, bg, text) => mixrgb(bg, text) + }, + + selectedPost: '--highlight', + selectedPostFaintText: { + depends: ['highlightFaintText'], + layer: 'highlight', + variant: 'selectedPost', + textColor: true + }, + selectedPostLightText: { + depends: ['highlightLightText'], + layer: 'highlight', + variant: 'selectedPost', + textColor: true + }, + selectedPostPostLink: { + depends: ['highlightPostLink'], + layer: 'highlight', + variant: 'selectedPost', + textColor: 'preserve' + }, + selectedPostFaintLink: { + depends: ['highlightFaintLink'], + layer: 'highlight', + variant: 'selectedPost', + textColor: 'preserve' + }, + selectedPostText: { + depends: ['highlightText'], + layer: 'highlight', + variant: 'selectedPost', + textColor: true + }, + selectedPostLink: { + depends: ['highlightLink'], + layer: 'highlight', + variant: 'selectedPost', + textColor: 'preserve' + }, + selectedPostIcon: { + depends: ['selectedPost', 'selectedPostText'], + color: (mod, bg, text) => mixrgb(bg, text) + }, + + selectedMenu: { + depends: ['bg'], + color: (mod, bg) => brightness(5 * mod, bg).rgb + }, + selectedMenuLightText: { + depends: ['highlightLightText'], + layer: 'selectedMenu', + variant: 'selectedMenu', + textColor: true + }, + selectedMenuFaintText: { + depends: ['highlightFaintText'], + layer: 'selectedMenu', + variant: 'selectedMenu', + textColor: true + }, + selectedMenuFaintLink: { + depends: ['highlightFaintLink'], + layer: 'selectedMenu', + variant: 'selectedMenu', + textColor: 'preserve' + }, + selectedMenuText: { + depends: ['highlightText'], + layer: 'selectedMenu', + variant: 'selectedMenu', + textColor: true + }, + selectedMenuLink: { + depends: ['highlightLink'], + layer: 'selectedMenu', + variant: 'selectedMenu', + textColor: 'preserve' + }, + selectedMenuIcon: { + depends: ['selectedMenu', 'selectedMenuText'], + color: (mod, bg, text) => mixrgb(bg, text) + }, + + selectedMenuPopover: { + depends: ['popover'], + color: (mod, bg) => brightness(5 * mod, bg).rgb + }, + selectedMenuPopoverLightText: { + depends: ['selectedMenuLightText'], + layer: 'selectedMenuPopover', + variant: 'selectedMenuPopover', + textColor: true + }, + selectedMenuPopoverFaintText: { + depends: ['selectedMenuFaintText'], + layer: 'selectedMenuPopover', + variant: 'selectedMenuPopover', + textColor: true + }, + selectedMenuPopoverFaintLink: { + depends: ['selectedMenuFaintLink'], + layer: 'selectedMenuPopover', + variant: 'selectedMenuPopover', + textColor: 'preserve' + }, + selectedMenuPopoverText: { + depends: ['selectedMenuText'], + layer: 'selectedMenuPopover', + variant: 'selectedMenuPopover', + textColor: true + }, + selectedMenuPopoverLink: { + depends: ['selectedMenuLink'], + layer: 'selectedMenuPopover', + variant: 'selectedMenuPopover', + textColor: 'preserve' + }, + selectedMenuPopoverIcon: { + depends: ['selectedMenuPopover', 'selectedMenuText'], + color: (mod, bg, text) => mixrgb(bg, text) + }, + + lightText: { + depends: ['text'], + layer: 'bg', + textColor: 'preserve', + color: (mod, text) => brightness(20 * mod, text).rgb + }, + + postLink: { + depends: ['link'], + layer: 'bg', + textColor: 'preserve' + }, + + border: { + depends: ['fg'], + opacity: 'border', + color: (mod, fg) => brightness(2 * mod, fg).rgb + }, + + poll: { + depends: ['accent', 'bg'], + copacity: 'poll', + color: (mod, accent, bg) => alphaBlend(accent, 0.4, bg) + }, + pollText: { + depends: ['text'], + layer: 'poll', + textColor: true + }, + + icon: { + depends: ['bg', 'text'], + inheritsOpacity: false, + color: (mod, bg, text) => mixrgb(bg, text) + }, + + // Foreground + fgText: { + depends: ['text'], + layer: 'fg', + textColor: true + }, + fgLink: { + depends: ['link'], + layer: 'fg', + textColor: 'preserve' + }, + + // Panel header + panel: { + depends: ['fg'], + opacity: 'panel' + }, + panelText: { + depends: ['text'], + layer: 'panel', + textColor: true + }, + panelFaint: { + depends: ['fgText'], + layer: 'panel', + opacity: 'faint', + textColor: true + }, + panelLink: { + depends: ['fgLink'], + layer: 'panel', + textColor: 'preserve' + }, + + // Top bar + topBar: '--fg', + topBarText: { + depends: ['fgText'], + layer: 'topBar', + textColor: true + }, + topBarLink: { + depends: ['fgLink'], + layer: 'topBar', + textColor: 'preserve' + }, + + // Tabs + tab: { + depends: ['btn'] + }, + tabText: { + depends: ['btnText'], + layer: 'btn', + textColor: true + }, + tabActiveText: { + depends: ['text'], + layer: 'bg', + textColor: true + }, + + // Buttons + btn: { + depends: ['fg'], + variant: 'btn', + opacity: 'btn' + }, + btnText: { + depends: ['fgText'], + layer: 'btn', + textColor: true + }, + btnPanelText: { + depends: ['btnText'], + layer: 'btnPanel', + variant: 'btn', + textColor: true + }, + btnTopBarText: { + depends: ['btnText'], + layer: 'btnTopBar', + variant: 'btn', + textColor: true + }, + + // Buttons: pressed + btnPressed: { + depends: ['btn'], + layer: 'btn' + }, + btnPressedText: { + depends: ['btnText'], + layer: 'btn', + variant: 'btnPressed', + textColor: true + }, + btnPressedPanel: { + depends: ['btnPressed'], + layer: 'btn' + }, + btnPressedPanelText: { + depends: ['btnPanelText'], + layer: 'btnPanel', + variant: 'btnPressed', + textColor: true + }, + btnPressedTopBar: { + depends: ['btnPressed'], + layer: 'btn' + }, + btnPressedTopBarText: { + depends: ['btnTopBarText'], + layer: 'btnTopBar', + variant: 'btnPressed', + textColor: true + }, + + // Buttons: toggled + btnToggled: { + depends: ['btn'], + layer: 'btn', + color: (mod, btn) => brightness(mod * 20, btn).rgb + }, + btnToggledText: { + depends: ['btnText'], + layer: 'btn', + variant: 'btnToggled', + textColor: true + }, + btnToggledPanelText: { + depends: ['btnPanelText'], + layer: 'btnPanel', + variant: 'btnToggled', + textColor: true + }, + btnToggledTopBarText: { + depends: ['btnTopBarText'], + layer: 'btnTopBar', + variant: 'btnToggled', + textColor: true + }, + + // Buttons: disabled + btnDisabled: { + depends: ['btn', 'bg'], + color: (mod, btn, bg) => alphaBlend(btn, 0.25, bg) + }, + btnDisabledText: { + depends: ['btnText', 'btnDisabled'], + layer: 'btn', + variant: 'btnDisabled', + color: (mod, text, btn) => alphaBlend(text, 0.25, btn) + }, + btnDisabledPanelText: { + depends: ['btnPanelText', 'btnDisabled'], + layer: 'btnPanel', + variant: 'btnDisabled', + color: (mod, text, btn) => alphaBlend(text, 0.25, btn) + }, + btnDisabledTopBarText: { + depends: ['btnTopBarText', 'btnDisabled'], + layer: 'btnTopBar', + variant: 'btnDisabled', + color: (mod, text, btn) => alphaBlend(text, 0.25, btn) + }, + + // Input fields + input: { + depends: ['fg'], + opacity: 'input' + }, + inputText: { + depends: ['text'], + layer: 'input', + textColor: true + }, + inputPanelText: { + depends: ['panelText'], + layer: 'inputPanel', + variant: 'input', + textColor: true + }, + inputTopbarText: { + depends: ['topBarText'], + layer: 'inputTopBar', + variant: 'input', + textColor: true + }, + + alertError: { + depends: ['cRed'], + opacity: 'alert' + }, + alertErrorText: { + depends: ['text'], + layer: 'alert', + variant: 'alertError', + textColor: true + }, + alertErrorPanelText: { + depends: ['panelText'], + layer: 'alertPanel', + variant: 'alertError', + textColor: true + }, + + alertWarning: { + depends: ['cOrange'], + opacity: 'alert' + }, + alertWarningText: { + depends: ['text'], + layer: 'alert', + variant: 'alertWarning', + textColor: true + }, + alertWarningPanelText: { + depends: ['panelText'], + layer: 'alertPanel', + variant: 'alertWarning', + textColor: true + }, + + alertNeutral: { + depends: ['text'], + opacity: 'alert' + }, + alertNeutralText: { + depends: ['text'], + layer: 'alert', + variant: 'alertNeutral', + color: (mod, text) => invertLightness(text).rgb, + textColor: true + }, + alertNeutralPanelText: { + depends: ['panelText'], + layer: 'alertPanel', + variant: 'alertNeutral', + textColor: true + }, + + badgeNotification: '--cRed', + badgeNotificationText: { + depends: ['text', 'badgeNotification'], + layer: 'badge', + variant: 'badgeNotification', + textColor: 'bw' + } +} diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js new file mode 100644 index 00000000..75768795 --- /dev/null +++ b/src/services/theme_data/theme_data.service.js @@ -0,0 +1,373 @@ +import { convert, brightness, contrastRatio } from 'chromatism' +import { alphaBlendLayers, getTextColor, relativeLuminance } from '../color_convert/color_convert.js' +import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js' + +/* + * # What's all this? + * Here be theme engine for pleromafe. All of this supposed to ease look + * and feel customization, making widget styles and make developer's life + * easier when it comes to supporting themes. Like many other theme systems + * it operates on color definitions, or "slots" - for example you define + * "button" color slot and then in UI component Button's CSS you refer to + * it as a CSS3 Variable. + * + * Some applications allow you to customize colors for certain things. + * Some UI toolkits allow you to define colors for each type of widget. + * Most of them are pretty barebones and have no assistance for common + * problems and cases, and in general themes themselves are very hard to + * maintain in all aspects. This theme engine tries to solve all of the + * common problems with themes. + * + * You don't have redefine several similar colors if you just want to + * change one color - all color slots are derived from other ones, so you + * can have at least one or two "basic" colors defined and have all other + * components inherit and modify basic ones. + * + * You don't have to test contrast ratio for colors or pick text color for + * each element even if you have light-on-dark elements in dark-on-light + * theme. + * + * You don't have to maintain order of code for inheriting slots from othet + * slots - dependency graph resolving does it for you. + */ + +/* This indicates that this version of code outputs similar theme data and + * should be incremented if output changes - for instance if getTextColor + * function changes and older themes no longer render text colors as + * author intended previously. + */ +export const CURRENT_VERSION = 3 + +export const getLayersArray = (layer, data = LAYERS) => { + let array = [layer] + let parent = data[layer] + while (parent) { + array.unshift(parent) + parent = data[parent] + } + return array +} + +export const getLayers = (layer, variant = layer, opacitySlot, colors, opacity) => { + return getLayersArray(layer).map((currentLayer) => ([ + currentLayer === layer + ? colors[variant] + : colors[currentLayer], + currentLayer === layer + ? opacity[opacitySlot] || 1 + : opacity[currentLayer] + ])) +} + +const getDependencies = (key, inheritance) => { + const data = inheritance[key] + if (typeof data === 'string' && data.startsWith('--')) { + return [data.substring(2)] + } else { + if (data === null) return [] + const { depends, layer, variant } = data + const layerDeps = layer + ? getLayersArray(layer).map(currentLayer => { + return currentLayer === layer + ? variant || layer + : currentLayer + }) + : [] + if (Array.isArray(depends)) { + return [...depends, ...layerDeps] + } else { + return [...layerDeps] + } + } +} + +/** + * Sorts inheritance object topologically - dependant slots come after + * dependencies + * + * @property {Object} inheritance - object defining the nodes + * @property {Function} getDeps - function that returns dependencies for + * given value and inheritance object. + * @returns {String[]} keys of inheritance object, sorted in topological + * order. Additionally, dependency-less nodes will always be first in line + */ +export const topoSort = ( + inheritance = SLOT_INHERITANCE, + getDeps = getDependencies +) => { + // This is an implementation of https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm + + const allKeys = Object.keys(inheritance) + const whites = new Set(allKeys) + const grays = new Set() + const blacks = new Set() + const unprocessed = [...allKeys] + const output = [] + + const step = (node) => { + if (whites.has(node)) { + // Make node "gray" + whites.delete(node) + grays.add(node) + // Do step for each node connected to it (one way) + getDeps(node, inheritance).forEach(step) + // Make node "black" + grays.delete(node) + blacks.add(node) + // Put it into the output list + output.push(node) + } else if (grays.has(node)) { + console.debug('Cyclic depenency in topoSort, ignoring') + output.push(node) + } else if (blacks.has(node)) { + // do nothing + } else { + throw new Error('Unintended condition in topoSort!') + } + } + while (unprocessed.length > 0) { + step(unprocessed.pop()) + } + return output.sort((a, b) => { + const depsA = getDeps(a, inheritance).length + const depsB = getDeps(b, inheritance).length + + if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return 0 + if (depsA === 0 && depsB !== 0) return -1 + if (depsB === 0 && depsA !== 0) return 1 + }) +} + +const expandSlotValue = (value) => { + if (typeof value === 'object') return value + return { + depends: value.startsWith('--') ? [value.substring(2)] : [], + default: value.startsWith('#') ? value : undefined + } +} +/** + * retrieves opacity slot for given slot. This goes up the depenency graph + * to find which parent has opacity slot defined for it. + * TODO refactor this + */ +export const getOpacitySlot = ( + k, + inheritance = SLOT_INHERITANCE, + getDeps = getDependencies +) => { + const value = expandSlotValue(inheritance[k]) + if (value.opacity === null) return + if (value.opacity) return value.opacity + const findInheritedOpacity = (key, visited = [k]) => { + const depSlot = getDeps(key, inheritance)[0] + if (depSlot === undefined) return + const dependency = inheritance[depSlot] + if (dependency === undefined) return + if (dependency.opacity || dependency === null) { + return dependency.opacity + } else if (dependency.depends && visited.includes(depSlot)) { + return findInheritedOpacity(depSlot, [...visited, depSlot]) + } else { + return null + } + } + if (value.depends) { + return findInheritedOpacity(k) + } +} + +/** + * retrieves layer slot for given slot. This goes up the depenency graph + * to find which parent has opacity slot defined for it. + * this is basically copypaste of getOpacitySlot except it checks if key is + * in LAYERS + * TODO refactor this + */ +export const getLayerSlot = ( + k, + inheritance = SLOT_INHERITANCE, + getDeps = getDependencies +) => { + const value = expandSlotValue(inheritance[k]) + if (LAYERS[k]) return k + if (value.layer === null) return + if (value.layer) return value.layer + const findInheritedLayer = (key, visited = [k]) => { + const depSlot = getDeps(key, inheritance)[0] + if (depSlot === undefined) return + const dependency = inheritance[depSlot] + if (dependency === undefined) return + if (dependency.layer || dependency === null) { + return dependency.layer + } else if (dependency.depends) { + return findInheritedLayer(dependency, [...visited, depSlot]) + } else { + return null + } + } + if (value.depends) { + return findInheritedLayer(k) + } +} + +/** + * topologically sorted SLOT_INHERITANCE + */ +export const SLOT_ORDERED = topoSort( + Object.entries(SLOT_INHERITANCE) + .sort(([aK, aV], [bK, bV]) => ((aV && aV.priority) || 0) - ((bV && bV.priority) || 0)) + .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}) +) + +/** + * All opacity slots used in color slots, their default values and affected + * color slots. + */ +export const OPACITIES = Object.entries(SLOT_INHERITANCE).reduce((acc, [k, v]) => { + const opacity = getOpacitySlot(k, SLOT_INHERITANCE, getDependencies) + if (opacity) { + return { + ...acc, + [opacity]: { + defaultValue: DEFAULT_OPACITY[opacity] || 1, + affectedSlots: [...((acc[opacity] && acc[opacity].affectedSlots) || []), k] + } + } + } else { + return acc + } +}, {}) + +/** + * Handle dynamic color + */ +export const computeDynamicColor = (sourceColor, getColor, mod) => { + if (typeof sourceColor !== 'string' || !sourceColor.startsWith('--')) return sourceColor + let targetColor = null + // Color references other color + const [variable, modifier] = sourceColor.split(/,/g).map(str => str.trim()) + const variableSlot = variable.substring(2) + targetColor = getColor(variableSlot) + if (modifier) { + targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb + } + return targetColor +} + +/** + * THE function you want to use. Takes provided colors and opacities + * value and uses inheritance data to figure out color needed for the slot. + */ +export const getColors = (sourceColors, sourceOpacity) => SLOT_ORDERED.reduce(({ colors, opacity }, key) => { + const sourceColor = sourceColors[key] + const value = expandSlotValue(SLOT_INHERITANCE[key]) + const deps = getDependencies(key, SLOT_INHERITANCE) + const isTextColor = !!value.textColor + const variant = value.variant || value.layer + + let backgroundColor = null + + if (isTextColor) { + backgroundColor = alphaBlendLayers( + { ...(colors[deps[0]] || convert(sourceColors[key] || '#FF00FF').rgb) }, + getLayers( + getLayerSlot(key) || 'bg', + variant || 'bg', + getOpacitySlot(variant), + colors, + opacity + ) + ) + } else if (variant && variant !== key) { + backgroundColor = colors[variant] || convert(sourceColors[variant]).rgb + } else { + backgroundColor = colors.bg || convert(sourceColors.bg) + } + + const isLightOnDark = relativeLuminance(backgroundColor) < 0.5 + const mod = isLightOnDark ? 1 : -1 + + let outputColor = null + if (sourceColor) { + // Color is defined in source color + let targetColor = sourceColor + if (targetColor === 'transparent') { + // We take only layers below current one + const layers = getLayers( + getLayerSlot(key), + key, + getOpacitySlot(key) || key, + colors, + opacity + ).slice(0, -1) + targetColor = { + ...alphaBlendLayers( + convert('#FF00FF').rgb, + layers + ), + a: 0 + } + } else if (typeof sourceColor === 'string' && sourceColor.startsWith('--')) { + targetColor = computeDynamicColor( + sourceColor, + variableSlot => colors[variableSlot] || sourceColors[variableSlot], + mod + ) + } else if (typeof sourceColor === 'string' && sourceColor.startsWith('#')) { + targetColor = convert(targetColor).rgb + } + outputColor = { ...targetColor } + } else if (value.default) { + // same as above except in object form + outputColor = convert(value.default).rgb + } else { + // calculate color + const defaultColorFunc = (mod, dep) => ({ ...dep }) + const colorFunc = value.color || defaultColorFunc + + if (value.textColor) { + if (value.textColor === 'bw') { + outputColor = contrastRatio(backgroundColor).rgb + } else { + let color = { ...colors[deps[0]] } + if (value.color) { + color = colorFunc(mod, ...deps.map((dep) => ({ ...colors[dep] }))) + } + outputColor = getTextColor( + backgroundColor, + { ...color }, + value.textColor === 'preserve' + ) + } + } else { + // background color case + outputColor = colorFunc( + mod, + ...deps.map((dep) => ({ ...colors[dep] })) + ) + } + } + if (!outputColor) { + throw new Error('Couldn\'t generate color for ' + key) + } + const opacitySlot = getOpacitySlot(key) + if (opacitySlot && outputColor.a === undefined) { + const dependencySlot = deps[0] + if (dependencySlot && colors[dependencySlot] === 'transparent') { + outputColor.a = 0 + } else { + outputColor.a = Number(sourceOpacity[opacitySlot]) || OPACITIES[opacitySlot].defaultValue || 1 + } + } + if (opacitySlot) { + return { + colors: { ...colors, [key]: outputColor }, + opacity: { ...opacity, [opacitySlot]: outputColor.a } + } + } else { + return { + colors: { ...colors, [key]: outputColor }, + opacity + } + } +}, { colors: {}, opacity: {} }) |
