aboutsummaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/color_convert/color_convert.js128
-rw-r--r--src/services/style_setter/style_setter.js532
-rw-r--r--src/services/theme_data/pleromafe.js615
-rw-r--r--src/services/theme_data/theme_data.service.js373
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: {} })