aboutsummaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/api/api.service.js106
-rw-r--r--src/services/color_convert/color_convert.js2
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js1
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js3
-rw-r--r--src/services/style_setter/style_setter.js506
-rw-r--r--src/services/theme_data/css_utils.js163
-rw-r--r--src/services/theme_data/iss_utils.js129
-rw-r--r--src/services/theme_data/pleromafe.t3.js2
-rw-r--r--src/services/theme_data/theme2_keys.js177
-rw-r--r--src/services/theme_data/theme2_to_theme3.js536
-rw-r--r--src/services/theme_data/theme3_slot_functions.js103
-rw-r--r--src/services/theme_data/theme_data.service.js346
-rw-r--r--src/services/theme_data/theme_data_3.service.js474
13 files changed, 2184 insertions, 364 deletions
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index b99dfa92..fa417193 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -116,6 +116,15 @@ const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions'
const PLEROMA_ADMIN_FRONTENDS_URL = '/api/pleroma/admin/frontends'
const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL = '/api/pleroma/admin/frontends/install'
+const PLEROMA_EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
+const PLEROMA_EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
+const PLEROMA_EMOJI_PACKS_URL = (page, pageSize) => `/api/v1/pleroma/emoji/packs?page=${page}&page_size=${pageSize}`
+const PLEROMA_EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
+const PLEROMA_EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
+const PLEROMA_EMOJI_PACKS_LS_REMOTE_URL =
+ (url, page, pageSize) => `/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
+const PLEROMA_EMOJI_UPDATE_FILE_URL = (name) => `/api/v1/pleroma/emoji/packs/files?name=${name}`
+
const oldfetch = window.fetch
const fetch = (url, options) => {
@@ -1800,6 +1809,90 @@ const fetchScrobbles = ({ accountId, limit = 1 }) => {
})
}
+const deleteEmojiPack = ({ name }) => {
+ return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'DELETE' })
+}
+
+const reloadEmoji = () => {
+ return fetch(PLEROMA_EMOJI_RELOAD_URL, { method: 'POST' })
+}
+
+const importEmojiFromFS = () => {
+ return fetch(PLEROMA_EMOJI_IMPORT_FS_URL)
+}
+
+const createEmojiPack = ({ name }) => {
+ return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'POST' })
+}
+
+const listEmojiPacks = ({ page, pageSize }) => {
+ return fetch(PLEROMA_EMOJI_PACKS_URL(page, pageSize))
+}
+
+const listRemoteEmojiPacks = ({ instance, page, pageSize }) => {
+ if (!instance.startsWith('http')) {
+ instance = 'https://' + instance
+ }
+
+ return fetch(
+ PLEROMA_EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
+ {
+ headers: { 'Content-Type': 'application/json' }
+ }
+ )
+}
+
+const downloadRemoteEmojiPack = ({ instance, packName, as }) => {
+ return fetch(
+ PLEROMA_EMOJI_PACKS_DL_REMOTE_URL,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ url: instance, name: packName, as
+ })
+ }
+ )
+}
+
+const saveEmojiPackMetadata = ({ name, newData }) => {
+ return fetch(
+ PLEROMA_EMOJI_PACK_URL(name),
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ metadata: newData })
+ }
+ )
+}
+
+const addNewEmojiFile = ({ packName, file, shortcode, filename }) => {
+ const data = new FormData()
+ if (filename.trim() !== '') { data.set('filename', filename) }
+ if (shortcode.trim() !== '') { data.set('shortcode', shortcode) }
+ data.set('file', file)
+
+ return fetch(
+ PLEROMA_EMOJI_UPDATE_FILE_URL(packName),
+ { method: 'POST', body: data }
+ )
+}
+
+const updateEmojiFile = ({ packName, shortcode, newShortcode, newFilename, force }) => {
+ return fetch(
+ PLEROMA_EMOJI_UPDATE_FILE_URL(packName),
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ shortcode, new_shortcode: newShortcode, new_filename: newFilename, force })
+ }
+ )
+}
+
+const deleteEmojiFile = ({ packName, shortcode }) => {
+ return fetch(`${PLEROMA_EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`, { method: 'DELETE' })
+}
+
const apiService = {
verifyCredentials,
fetchTimeline,
@@ -1919,7 +2012,18 @@ const apiService = {
fetchInstanceConfigDescriptions,
fetchAvailableFrontends,
pushInstanceDBConfig,
- installFrontend
+ installFrontend,
+ importEmojiFromFS,
+ reloadEmoji,
+ listEmojiPacks,
+ createEmojiPack,
+ deleteEmojiPack,
+ saveEmojiPackMetadata,
+ addNewEmojiFile,
+ updateEmojiFile,
+ deleteEmojiFile,
+ listRemoteEmojiPacks,
+ downloadRemoteEmojiPack
}
export default apiService
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index 47d6344e..d92bbbe0 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -173,7 +173,7 @@ export const mixrgb = (a, b) => {
* @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})`
+ return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a ?? 1})`
}
/**
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 860f20c6..e41e7125 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -166,6 +166,7 @@ export const parseUser = (data) => {
output.show_role = data.source.pleroma.show_role
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
+ output.actor_type = data.source.pleroma.actor_type
}
}
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index c91a86c8..6403693c 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -8,8 +8,11 @@ const mastoApiNotificationTypes = [
'favourite',
'reblog',
'follow',
+ 'follow_request',
'move',
+ 'poll',
'pleroma:emoji_reaction',
+ 'pleroma:chat_mention',
'pleroma:report'
]
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 43fe3c73..369d2c9f 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,24 +1,151 @@
-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'
+import { hex2rgb } from '../color_convert/color_convert.js'
+import { generatePreset } from '../theme_data/theme_data.service.js'
+import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
+import { convertTheme2To3 } from '../theme_data/theme2_to_theme3.js'
+import { getCssRules } from '../theme_data/css_utils.js'
import { defaultState } from '../../modules/config.js'
+import { chunk } from 'lodash'
+
+export const generateTheme = async (input, callbacks) => {
+ const {
+ onNewRule = (rule, isLazy) => {},
+ onLazyFinished = () => {},
+ onEagerFinished = () => {}
+ } = callbacks
+
+ let extraRules
+ if (input.themeFileVersion === 1) {
+ extraRules = convertTheme2To3(input)
+ } else {
+ const { theme } = generatePreset(input)
+ extraRules = convertTheme2To3(theme)
+ }
-export const applyTheme = (input) => {
- const { rules } = generatePreset(input)
- const head = document.head
- const body = document.body
- body.classList.add('hidden')
+ // Assuming that "worst case scenario background" is panel background since it's the most likely one
+ const themes3 = init(extraRules, extraRules[0].directives['--bg'].split('|')[1].trim())
+
+ getCssRules(themes3.eager, themes3.staticVars).forEach(rule => {
+ // Hacks to support multiple selectors on same component
+ if (rule.match(/::-webkit-scrollbar-button/)) {
+ const parts = rule.split(/[{}]/g)
+ const newRule = [
+ parts[0],
+ ', ',
+ parts[0].replace(/button/, 'thumb'),
+ ', ',
+ parts[0].replace(/scrollbar-button/, 'resizer'),
+ ' {',
+ parts[1],
+ '}'
+ ].join('')
+ onNewRule(newRule, false)
+ } else {
+ onNewRule(rule, false)
+ }
+ })
+ onEagerFinished()
+
+ // Optimization - instead of processing all lazy rules in one go, process them in small chunks
+ // so that UI can do other things and be somewhat responsive while less important rules are being
+ // processed
+ let counter = 0
+ const chunks = chunk(themes3.lazy, 200)
+ // let t0 = performance.now()
+ const processChunk = () => {
+ const chunk = chunks[counter]
+ Promise.all(chunk.map(x => x())).then(result => {
+ getCssRules(result.filter(x => x), themes3.staticVars).forEach(rule => {
+ if (rule.match(/\.modal-view/)) {
+ const parts = rule.split(/[{}]/g)
+ const newRule = [
+ parts[0],
+ ', ',
+ parts[0].replace(/\.modal-view/, '#modal'),
+ ', ',
+ parts[0].replace(/\.modal-view/, '.shout-panel'),
+ ' {',
+ parts[1],
+ '}'
+ ].join('')
+ onNewRule(newRule, true)
+ } else {
+ onNewRule(rule, true)
+ }
+ })
+ // const t1 = performance.now()
+ // console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
+ // t0 = t1
+ counter += 1
+ if (counter < chunks.length) {
+ setTimeout(processChunk, 0)
+ } else {
+ onLazyFinished()
+ }
+ })
+ }
- const styleEl = document.createElement('style')
- head.appendChild(styleEl)
- const styleSheet = styleEl.sheet
+ return { lazyProcessFunc: processChunk }
+}
- styleSheet.toString()
- styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max')
- styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max')
- styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max')
- styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max')
- body.classList.remove('hidden')
+export const tryLoadCache = () => {
+ const json = localStorage.getItem('pleroma-fe-theme-cache')
+ if (!json) return null
+ let cache
+ try {
+ cache = JSON.parse(json)
+ } catch (e) {
+ console.error('Failed to decode theme cache:', e)
+ return false
+ }
+ if (cache.engineChecksum === getEngineChecksum()) {
+ const styleSheet = new CSSStyleSheet()
+ const lazyStyleSheet = new CSSStyleSheet()
+
+ cache.data[0].forEach(rule => styleSheet.insertRule(rule, 'index-max'))
+ cache.data[1].forEach(rule => lazyStyleSheet.insertRule(rule, 'index-max'))
+
+ document.adoptedStyleSheets = [styleSheet, lazyStyleSheet]
+
+ return true
+ } else {
+ console.warn('Engine checksum doesn\'t match, cache not usable, clearing')
+ localStorage.removeItem('pleroma-fe-theme-cache')
+ }
+}
+
+export const applyTheme = async (input, onFinish = (data) => {}) => {
+ const styleSheet = new CSSStyleSheet()
+ const styleArray = []
+ const lazyStyleSheet = new CSSStyleSheet()
+ const lazyStyleArray = []
+
+ const { lazyProcessFunc } = await generateTheme(
+ input,
+ {
+ onNewRule (rule, isLazy) {
+ if (isLazy) {
+ lazyStyleSheet.insertRule(rule, 'index-max')
+ lazyStyleArray.push(rule)
+ } else {
+ styleSheet.insertRule(rule, 'index-max')
+ styleArray.push(rule)
+ }
+ },
+ onEagerFinished () {
+ document.adoptedStyleSheets = [styleSheet]
+ },
+ onLazyFinished () {
+ document.adoptedStyleSheets = [styleSheet, lazyStyleSheet]
+ const cache = { engineChecksum: getEngineChecksum(), data: [styleArray, lazyStyleArray] }
+ onFinish(cache)
+ localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
+ }
+ }
+ )
+
+ setTimeout(lazyProcessFunc, 0)
+
+ return Promise.resolve()
}
const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) =>
@@ -51,308 +178,6 @@ export const applyConfig = (config) => {
body.classList.remove('hidden')
}
-export const getCssShadow = (input, usesDropShadow) => {
- if (input.length === 0) {
- return 'none'
- }
-
- return input
- .filter(_ => usesDropShadow ? _.inset : _)
- .map((shad) => [
- shad.x,
- shad.y,
- shad.blur,
- shad.spread
- ].map(_ => _ + 'px').concat([
- getCssColor(shad.color, shad.alpha),
- shad.inset ? 'inset' : ''
- ]).join(' ')).join(', ')
-}
-
-const getCssShadowFilter = (input) => {
- if (input.length === 0) {
- return 'none'
- }
-
- return input
- // drop-shadow doesn't support inset or spread
- .filter((shad) => !shad.inset && Number(shad.spread) === 0)
- .map((shad) => [
- shad.x,
- shad.y,
- // drop-shadow's blur is twice as strong compared to box-shadow
- shad.blur / 2
- ].map(_ => _ + 'px').concat([
- getCssColor(shad.color, shad.alpha)
- ]).join(' '))
- .map(_ => `drop-shadow(${_})`)
- .join(' ')
-}
-
-export const generateColors = (themeData) => {
- const sourceColors = !themeData.themeEngineVersion
- ? colors2to3(themeData.colors || themeData)
- : themeData.colors || themeData
-
- 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) : rgba2css(v)
- return acc
- }, { complete: {}, solid: {} })
- return {
- rules: {
- colors: Object.entries(htmlColors.complete)
- .filter(([k, v]) => v)
- .map(([k, v]) => `--${k}: ${v}`)
- .join(';')
- },
- theme: {
- colors: htmlColors.solid,
- opacity
- }
- }
-}
-
-export const generateRadii = (input) => {
- let inputRadii = input.radii || {}
- // v1 -> v2
- if (typeof input.btnRadius !== 'undefined') {
- inputRadii = Object
- .entries(input)
- .filter(([k, v]) => k.endsWith('Radius'))
- .reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {})
- }
- const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => {
- acc[k] = v
- return acc
- }, {
- btn: 4,
- input: 4,
- checkbox: 2,
- panel: 10,
- avatar: 5,
- avatarAlt: 50,
- tooltip: 2,
- attachment: 5,
- chatMessage: inputRadii.panel
- })
-
- return {
- rules: {
- radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';')
- },
- theme: {
- radii
- }
- }
-}
-
-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
- return acc
- }, acc[k])
- return acc
- }, {
- interface: {
- family: 'sans-serif'
- },
- input: {
- family: 'inherit'
- },
- post: {
- family: 'inherit'
- },
- postCode: {
- family: 'monospace'
- }
- })
-
- return {
- rules: {
- fonts: Object
- .entries(fonts)
- .filter(([k, v]) => v)
- .map(([k, v]) => `--${k}Font: ${v.family}`).join(';')
- },
- theme: {
- fonts
- }
- }
-}
-
-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: '#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
- }]
-}
-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 cleanInputShadows = Object.fromEntries(
- Object.entries(input.shadows || {})
- .map(([name, shadowSlot]) => [
- name,
- // defaulting color to black to avoid potential problems
- shadowSlot.map(shadowDef => ({ color: '#000000', ...shadowDef }))
- ])
- )
- const inputShadows = cleanInputShadows && !input.themeEngineVersion
- ? shadows2to3(cleanInputShadows, input.opacity)
- : cleanInputShadows || {}
- 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.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)}`,
- `--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
- `--${k}ShadowInset: ${getCssShadow(v, true)}`
- ].join(';'))
- .join(';')
- },
- theme: {
- shadows
- }
- }
-}
-
-export const composePreset = (colors, radii, shadows, fonts) => {
- return {
- rules: {
- ...shadows.rules,
- ...colors.rules,
- ...radii.rules,
- ...fonts.rules
- },
- theme: {
- ...shadows.theme,
- ...colors.theme,
- ...radii.theme,
- ...fonts.theme
- }
- }
-}
-
-export const generatePreset = (input) => {
- const colors = generateColors(input)
- return composePreset(
- colors,
- generateRadii(input),
- generateShadows(input, colors.theme.colors, colors.mod),
- generateFonts(input)
- )
-}
-
export const getThemes = () => {
const cache = 'no-store'
@@ -382,47 +207,6 @@ export const getThemes = () => {
}, {})
})
}
-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 }
- }
- }, {})
-}
-
-/**
- * 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 = '#000000' }) => 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 }
- }, {})
-}
export const getPreset = (val) => {
return getThemes()
@@ -449,4 +233,4 @@ export const getPreset = (val) => {
})
}
-export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme))
+export const setPreset = (val) => getPreset(val).then(data => applyTheme(data))
diff --git a/src/services/theme_data/css_utils.js b/src/services/theme_data/css_utils.js
new file mode 100644
index 00000000..a89eac3b
--- /dev/null
+++ b/src/services/theme_data/css_utils.js
@@ -0,0 +1,163 @@
+import { convert } from 'chromatism'
+
+import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
+
+// This changes what backgrounds are used to "stacked" solid colors so you can see
+// what theme engine "thinks" is actual background color is for purposes of text color
+// generation and for when --stacked variable is used
+const DEBUG = false
+
+export const parseCssShadow = (text) => {
+ const dimensions = /(\d[a-z]*\s?){2,4}/.exec(text)?.[0]
+ const inset = /inset/.exec(text)?.[0]
+ const color = text.replace(dimensions, '').replace(inset, '')
+
+ const [x, y, blur = 0, spread = 0] = dimensions.split(/ /).filter(x => x).map(x => x.trim())
+ const isInset = inset?.trim() === 'inset'
+ const colorString = color.split(/ /).filter(x => x).map(x => x.trim())[0]
+
+ return {
+ x,
+ y,
+ blur,
+ spread,
+ inset: isInset,
+ color: colorString
+ }
+}
+
+export const getCssColorString = (color, alpha = 1) => rgba2css({ ...convert(color).rgb, a: alpha })
+
+export const getCssShadow = (input, usesDropShadow) => {
+ if (input.length === 0) {
+ return 'none'
+ }
+
+ return input
+ .filter(_ => usesDropShadow ? _.inset : _)
+ .map((shad) => [
+ shad.x,
+ shad.y,
+ shad.blur,
+ shad.spread
+ ].map(_ => _ + 'px ').concat([
+ getCssColorString(shad.color, shad.alpha),
+ shad.inset ? 'inset' : ''
+ ]).join(' ')).join(', ')
+}
+
+export const getCssShadowFilter = (input) => {
+ if (input.length === 0) {
+ return 'none'
+ }
+
+ return input
+ // drop-shadow doesn't support inset or spread
+ .filter((shad) => !shad.inset && Number(shad.spread) === 0)
+ .map((shad) => [
+ shad.x,
+ shad.y,
+ // drop-shadow's blur is twice as strong compared to box-shadow
+ shad.blur / 2
+ ].map(_ => _ + 'px').concat([
+ getCssColorString(shad.color, shad.alpha)
+ ]).join(' '))
+ .map(_ => `drop-shadow(${_})`)
+ .join(' ')
+}
+
+export const getCssRules = (rules) => rules.map(rule => {
+ let selector = rule.selector
+ if (!selector) {
+ selector = 'html'
+ }
+ const header = selector + ' {'
+ const footer = '}'
+
+ const virtualDirectives = Object.entries(rule.virtualDirectives || {}).map(([k, v]) => {
+ return ' ' + k + ': ' + v
+ }).join(';\n')
+
+ const directives = Object.entries(rule.directives).map(([k, v]) => {
+ switch (k) {
+ case 'roundness': {
+ return ' ' + [
+ '--roundness: ' + v + 'px'
+ ].join(';\n ')
+ }
+ case 'shadow': {
+ return ' ' + [
+ '--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
+ '--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow),
+ '--shadowInset: ' + getCssShadow(rule.dynamicVars.shadow, true)
+ ].join(';\n ')
+ }
+ case 'background': {
+ if (DEBUG) {
+ return `
+ --background: ${getCssColorString(rule.dynamicVars.stacked)};
+ background-color: ${getCssColorString(rule.dynamicVars.stacked)};
+ `
+ }
+ if (v === 'transparent') {
+ if (rule.component === 'Root') return []
+ return [
+ rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '',
+ ' --background: ' + v
+ ].filter(x => x).join(';\n')
+ }
+ const color = getCssColorString(rule.dynamicVars.background, rule.directives.opacity)
+ const cssDirectives = ['--background: ' + color]
+ if (rule.directives.backgroundNoCssColor !== 'yes') {
+ cssDirectives.push('background-color: ' + color)
+ }
+ return cssDirectives.filter(x => x).join(';\n')
+ }
+ case 'blur': {
+ const cssDirectives = []
+ if (rule.directives.opacity < 1) {
+ cssDirectives.push(`--backdrop-filter: blur(${v}) `)
+ if (rule.directives.backgroundNoCssColor !== 'yes') {
+ cssDirectives.push(`backdrop-filter: blur(${v}) `)
+ }
+ }
+ return cssDirectives.join(';\n')
+ }
+ case 'font': {
+ return 'font-family: ' + v
+ }
+ case 'textColor': {
+ if (rule.directives.textNoCssColor === 'yes') { return '' }
+ return 'color: ' + v
+ }
+ default:
+ if (k.startsWith('--')) {
+ const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme!
+ switch (type) {
+ case 'color': {
+ const color = rule.dynamicVars[k]
+ if (typeof color === 'string') {
+ return k + ': ' + rgba2css(hex2rgb(color))
+ } else {
+ return k + ': ' + rgba2css(color)
+ }
+ }
+ case 'generic':
+ return k + ': ' + value
+ default:
+ return ''
+ }
+ }
+ return ''
+ }
+ }).filter(x => x).map(x => ' ' + x).join(';\n')
+
+ return [
+ header,
+ directives + ';',
+ (rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '',
+ '',
+ virtualDirectives,
+ footer
+ ].join('\n')
+}).filter(x => x)
diff --git a/src/services/theme_data/iss_utils.js b/src/services/theme_data/iss_utils.js
new file mode 100644
index 00000000..2d9dd0b5
--- /dev/null
+++ b/src/services/theme_data/iss_utils.js
@@ -0,0 +1,129 @@
+// "Unrolls" a tree structure of item: { parent: { ...item2, parent: { ...item3, parent: {...} } }}
+// into an array [item2, item3] for iterating
+export const unroll = (item) => {
+ const out = []
+ let currentParent = item
+ while (currentParent) {
+ out.push(currentParent)
+ currentParent = currentParent.parent
+ }
+ return out
+}
+
+// This gives you an array of arrays of all possible unique (i.e. order-insensitive) combinations
+// Can only accept primitives. Duplicates are not supported and can cause unexpected behavior
+export const getAllPossibleCombinations = (array) => {
+ const combos = [array.map(x => [x])]
+ for (let comboSize = 2; comboSize <= array.length; comboSize++) {
+ const previous = combos[combos.length - 1]
+ const newCombos = previous.map(self => {
+ const selfSet = new Set()
+ self.forEach(x => selfSet.add(x))
+ const nonSelf = array.filter(x => !selfSet.has(x))
+ return nonSelf.map(x => [...self, x])
+ })
+ const flatCombos = newCombos.reduce((acc, x) => [...acc, ...x], [])
+ const uniqueComboStrings = new Set()
+ const uniqueCombos = flatCombos.map(x => x.toSorted()).filter(x => {
+ if (uniqueComboStrings.has(x.join())) {
+ return false
+ } else {
+ uniqueComboStrings.add(x.join())
+ return true
+ }
+ })
+ combos.push(uniqueCombos)
+ }
+ return combos.reduce((acc, x) => [...acc, ...x], [])
+}
+
+// Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true) selector
+export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => {
+ if (!rule && !isParent) return null
+ const component = components[rule.component]
+ const { states = {}, variants = {}, selector, outOfTreeSelector } = component
+
+ const applicableStates = ((rule.state || []).filter(x => x !== 'normal')).map(state => states[state])
+
+ const applicableVariantName = (rule.variant || 'normal')
+ let applicableVariant = ''
+ if (applicableVariantName !== 'normal') {
+ applicableVariant = variants[applicableVariantName]
+ } else {
+ applicableVariant = variants?.normal ?? ''
+ }
+
+ let realSelector
+ if (selector === ':root') {
+ realSelector = ''
+ } else if (isParent) {
+ realSelector = selector
+ } else {
+ if (outOfTreeSelector && !ignoreOutOfTreeSelector) realSelector = outOfTreeSelector
+ else realSelector = selector
+ }
+
+ const selectors = [realSelector, applicableVariant, ...applicableStates]
+ .toSorted((a, b) => {
+ if (a.startsWith(':')) return 1
+ if (/^[a-z]/.exec(a)) return -1
+ else return 0
+ })
+ .join('')
+
+ if (rule.parent) {
+ return (genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, true) + ' ' + selectors).trim()
+ }
+ return selectors.trim()
+}
+
+export const combinationsMatch = (criteria, subject, strict) => {
+ if (criteria.component !== subject.component) return false
+
+ // All variants inherit from normal
+ if (subject.variant !== 'normal' || strict) {
+ if (criteria.variant !== subject.variant) return false
+ }
+
+ // Subject states > 1 essentially means state is "normal" and therefore matches
+ if (subject.state.length > 1 || strict) {
+ const subjectStatesSet = new Set(subject.state)
+ const criteriaStatesSet = new Set(criteria.state)
+
+ const setsAreEqual =
+ [...criteriaStatesSet].every(state => subjectStatesSet.has(state)) &&
+ [...subjectStatesSet].every(state => criteriaStatesSet.has(state))
+
+ if (!setsAreEqual) return false
+ }
+ return true
+}
+
+export const findRules = (criteria, strict) => subject => {
+ // If we searching for "general" rules - ignore "specific" ones
+ if (criteria.parent === null && !!subject.parent) return false
+ if (!combinationsMatch(criteria, subject, strict)) return false
+
+ if (criteria.parent !== undefined && criteria.parent !== null) {
+ if (!subject.parent && !strict) return true
+ const pathCriteria = unroll(criteria)
+ const pathSubject = unroll(subject)
+ if (pathCriteria.length < pathSubject.length) return false
+
+ // Search: .a .b .c
+ // Matches: .a .b .c; .b .c; .c; .z .a .b .c
+ // Does not match .a .b .c .d, .a .b .e
+ for (let i = 0; i < pathCriteria.length; i++) {
+ const criteriaParent = pathCriteria[i]
+ const subjectParent = pathSubject[i]
+ if (!subjectParent) return true
+ if (!combinationsMatch(criteriaParent, subjectParent, strict)) return false
+ }
+ }
+ return true
+}
+
+export const normalizeCombination = rule => {
+ rule.variant = rule.variant ?? 'normal'
+ rule.state = [...new Set(['normal', ...(rule.state || [])])]
+}
diff --git a/src/services/theme_data/pleromafe.t3.js b/src/services/theme_data/pleromafe.t3.js
new file mode 100644
index 00000000..db612a5b
--- /dev/null
+++ b/src/services/theme_data/pleromafe.t3.js
@@ -0,0 +1,2 @@
+export const sampleRules = [
+]
diff --git a/src/services/theme_data/theme2_keys.js b/src/services/theme_data/theme2_keys.js
new file mode 100644
index 00000000..ffc5627c
--- /dev/null
+++ b/src/services/theme_data/theme2_keys.js
@@ -0,0 +1,177 @@
+export default [
+ 'bg',
+ 'wallpaper',
+ 'fg',
+ 'text',
+ 'underlay',
+ 'link',
+ 'accent',
+ 'faint',
+ 'faintLink',
+ 'postFaintLink',
+
+ 'cBlue',
+ 'cRed',
+ 'cGreen',
+ 'cOrange',
+
+ 'profileBg',
+ 'profileTint',
+
+ 'highlight',
+ 'highlightLightText',
+ 'highlightPostLink',
+ 'highlightFaintText',
+ 'highlightFaintLink',
+ 'highlightPostFaintLink',
+ 'highlightText',
+ 'highlightLink',
+ 'highlightIcon',
+
+ 'popover',
+ 'popoverLightText',
+ 'popoverPostLink',
+ 'popoverFaintText',
+ 'popoverFaintLink',
+ 'popoverPostFaintLink',
+ 'popoverText',
+ 'popoverLink',
+ 'popoverIcon',
+
+ 'selectedPost',
+ 'selectedPostFaintText',
+ 'selectedPostLightText',
+ 'selectedPostPostLink',
+ 'selectedPostFaintLink',
+ 'selectedPostText',
+ 'selectedPostLink',
+ 'selectedPostIcon',
+
+ 'selectedMenu',
+ 'selectedMenuLightText',
+ 'selectedMenuFaintText',
+ 'selectedMenuFaintLink',
+ 'selectedMenuText',
+ 'selectedMenuLink',
+ 'selectedMenuIcon',
+
+ 'selectedMenuPopover',
+ 'selectedMenuPopoverLightText',
+ 'selectedMenuPopoverFaintText',
+ 'selectedMenuPopoverFaintLink',
+ 'selectedMenuPopoverText',
+ 'selectedMenuPopoverLink',
+ 'selectedMenuPopoverIcon',
+
+ 'lightText',
+
+ 'postLink',
+
+ 'postGreentext',
+
+ 'postCyantext',
+
+ 'border',
+
+ 'poll',
+ 'pollText',
+
+ 'icon',
+
+ // Foreground,
+ 'fgText',
+ 'fgLink',
+
+ // Panel header,
+ 'panel',
+ 'panelText',
+ 'panelFaint',
+ 'panelLink',
+
+ // Top bar,
+ 'topBar',
+ 'topBarText',
+ 'topBarLink',
+
+ // Tabs,
+ 'tab',
+ 'tabText',
+ 'tabActiveText',
+
+ // Buttons,
+ 'btn',
+ 'btnText',
+ 'btnPanelText',
+ 'btnTopBarText',
+
+ // Buttons: pressed,
+ 'btnPressed',
+ 'btnPressedText',
+ 'btnPressedPanel',
+ 'btnPressedPanelText',
+ 'btnPressedTopBar',
+ 'btnPressedTopBarText',
+
+ // Buttons: toggled,
+ 'btnToggled',
+ 'btnToggledText',
+ 'btnToggledPanelText',
+ 'btnToggledTopBarText',
+
+ // Buttons: disabled,
+ 'btnDisabled',
+ 'btnDisabledText',
+ 'btnDisabledPanelText',
+ 'btnDisabledTopBarText',
+
+ // Input fields,
+ 'input',
+ 'inputText',
+ 'inputPanelText',
+ 'inputTopbarText',
+
+ 'alertError',
+ 'alertErrorText',
+ 'alertErrorPanelText',
+
+ 'alertWarning',
+ 'alertWarningText',
+ 'alertWarningPanelText',
+
+ 'alertSuccess',
+ 'alertSuccessText',
+ 'alertSuccessPanelText',
+
+ 'alertNeutral',
+ 'alertNeutralText',
+ 'alertNeutralPanelText',
+
+ 'alertPopupError',
+ 'alertPopupErrorText',
+
+ 'alertPopupWarning',
+ 'alertPopupWarningText',
+
+ 'alertPopupSuccess',
+ 'alertPopupSuccessText',
+
+ 'alertPopupNeutral',
+ 'alertPopupNeutralText',
+
+ 'badgeNeutral',
+ 'badgeNeutralText',
+
+ 'badgeNotification',
+ 'badgeNotificationText',
+
+ 'chatBg',
+
+ 'chatMessageIncomingBg',
+ 'chatMessageIncomingText',
+ 'chatMessageIncomingLink',
+ 'chatMessageIncomingBorder',
+ 'chatMessageOutgoingBg',
+ 'chatMessageOutgoingText',
+ 'chatMessageOutgoingLink',
+ 'chatMessageOutgoingBorder'
+]
diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js
new file mode 100644
index 00000000..6ea12836
--- /dev/null
+++ b/src/services/theme_data/theme2_to_theme3.js
@@ -0,0 +1,536 @@
+import { convert } from 'chromatism'
+import allKeys from './theme2_keys'
+
+// keys that are meant to be used globally, i.e. what's the rest of the theme is based upon.
+export const basePaletteKeys = new Set([
+ 'bg',
+ 'fg',
+ 'text',
+ 'link',
+ 'accent',
+
+ 'cBlue',
+ 'cRed',
+ 'cGreen',
+ 'cOrange'
+])
+
+export const fontsKeys = new Set([
+ 'interface',
+ 'input',
+ 'post',
+ 'postCode'
+])
+
+export const opacityKeys = new Set([
+ 'alert',
+ 'alertPopup',
+ 'bg',
+ 'border',
+ 'btn',
+ 'faint',
+ 'input',
+ 'panel',
+ 'popover',
+ 'profileTint',
+ 'underlay'
+])
+
+export const shadowsKeys = new Set([
+ 'panel',
+ 'topBar',
+ 'popup',
+ 'avatar',
+ 'avatarStatus',
+ 'panelHeader',
+ 'button',
+ 'buttonHover',
+ 'buttonPressed',
+ 'input'
+])
+
+export const radiiKeys = new Set([
+ 'btn',
+ 'input',
+ 'checkbox',
+ 'panel',
+ 'avatar',
+ 'avatarAlt',
+ 'tooltip',
+ 'attachment',
+ 'chatMessage'
+])
+
+// Keys that are not available in editor and never meant to be edited
+export const hiddenKeys = new Set([
+ 'profileBg',
+ 'profileTint'
+])
+
+export const extendedBasePrefixes = [
+ 'border',
+ 'icon',
+ 'highlight',
+ 'lightText',
+
+ 'popover',
+
+ 'panel',
+ 'topBar',
+ 'tab',
+ 'btn',
+ 'input',
+ 'selectedMenu',
+
+ 'alert',
+ 'alertPopup',
+ 'badge',
+
+ 'post',
+ 'selectedPost', // wrong nomenclature
+ 'poll',
+
+ 'chatBg',
+ 'chatMessage'
+]
+export const nonComponentPrefixes = new Set([
+ 'border',
+ 'icon',
+ 'highlight',
+ 'lightText',
+ 'chatBg'
+])
+
+export const extendedBaseKeys = Object.fromEntries(
+ extendedBasePrefixes.map(prefix => [
+ prefix,
+ allKeys.filter(k => {
+ if (prefix === 'alert') {
+ return k.startsWith(prefix) && !k.startsWith('alertPopup')
+ }
+ return k.startsWith(prefix)
+ })
+ ])
+)
+
+// Keysets that are only really used intermideately, i.e. to generate other colors
+export const temporary = new Set([
+ '',
+ 'highlight'
+])
+
+export const temporaryColors = {}
+
+export const convertTheme2To3 = (data) => {
+ data.colors.accent = data.colors.accent || data.colors.link
+ data.colors.link = data.colors.link || data.colors.accent
+ const generateRoot = () => {
+ const directives = {}
+ basePaletteKeys.forEach(key => { directives['--' + key] = 'color | ' + convert(data.colors[key]).hex })
+ return {
+ component: 'Root',
+ directives
+ }
+ }
+
+ const convertOpacity = () => {
+ const newRules = []
+ Object.keys(data.opacity || {}).forEach(key => {
+ if (!opacityKeys.has(key) || data.opacity[key] === undefined) return null
+ const originalOpacity = data.opacity[key]
+ const rule = {}
+
+ switch (key) {
+ case 'alert':
+ rule.component = 'Alert'
+ break
+ case 'alertPopup':
+ rule.component = 'Alert'
+ rule.parent = { component: 'Popover' }
+ break
+ case 'bg':
+ rule.component = 'Panel'
+ break
+ case 'border':
+ rule.component = 'Border'
+ break
+ case 'btn':
+ rule.component = 'Button'
+ break
+ case 'faint':
+ rule.component = 'Text'
+ rule.state = ['faint']
+ break
+ case 'input':
+ rule.component = 'Input'
+ break
+ case 'panel':
+ rule.component = 'PanelHeader'
+ break
+ case 'popover':
+ rule.component = 'Popover'
+ break
+ case 'profileTint':
+ return null
+ case 'underlay':
+ rule.component = 'Underlay'
+ break
+ }
+
+ switch (key) {
+ case 'alert':
+ case 'alertPopup':
+ case 'bg':
+ case 'btn':
+ case 'input':
+ case 'panel':
+ case 'popover':
+ case 'underlay':
+ rule.directives = { opacity: originalOpacity }
+ break
+ case 'faint':
+ case 'border':
+ rule.directives = { textOpacity: originalOpacity }
+ break
+ }
+
+ newRules.push(rule)
+
+ if (rule.component === 'Button') {
+ newRules.push({ ...rule, component: 'ScrollbarElement' })
+ newRules.push({ ...rule, component: 'Tab' })
+ newRules.push({ ...rule, component: 'Tab', state: ['active'], directives: { opacity: 0 } })
+ }
+ if (rule.component === 'Panel') {
+ newRules.push({ ...rule, component: 'Post' })
+ }
+ })
+ return newRules
+ }
+
+ const convertRadii = () => {
+ const newRules = []
+ Object.keys(data.radii || {}).forEach(key => {
+ if (!radiiKeys.has(key) || data.radii[key] === undefined) return null
+ const originalRadius = data.radii[key]
+ const rule = {}
+
+ switch (key) {
+ case 'btn':
+ rule.component = 'Button'
+ break
+ case 'tab':
+ rule.component = 'Tab'
+ break
+ case 'input':
+ rule.component = 'Input'
+ break
+ case 'checkbox':
+ rule.component = 'Input'
+ rule.variant = 'checkbox'
+ break
+ case 'panel':
+ rule.component = 'Panel'
+ break
+ case 'avatar':
+ rule.component = 'Avatar'
+ break
+ case 'avatarAlt':
+ rule.component = 'Avatar'
+ rule.variant = 'compact'
+ break
+ case 'tooltip':
+ rule.component = 'Popover'
+ break
+ case 'attachment':
+ rule.component = 'Attachment'
+ break
+ case 'ChatMessage':
+ rule.component = 'Button'
+ break
+ }
+ rule.directives = {
+ roundness: originalRadius
+ }
+ newRules.push(rule)
+ if (rule.component === 'Button') {
+ newRules.push({ ...rule, component: 'ScrollbarElement' })
+ newRules.push({ ...rule, component: 'Tab' })
+ }
+ })
+ return newRules
+ }
+
+ const convertFonts = () => {
+ const newRules = []
+ Object.keys(data.fonts || {}).forEach(key => {
+ if (!fontsKeys.has(key)) return
+ const originalFont = data.fonts[key].family
+ const rule = {}
+
+ switch (key) {
+ case 'interface':
+ case 'postCode':
+ rule.component = 'Root'
+ break
+ case 'input':
+ rule.component = 'Input'
+ break
+ case 'post':
+ rule.component = 'RichContent'
+ break
+ }
+ switch (key) {
+ case 'interface':
+ case 'input':
+ case 'post':
+ rule.directives = { '--font': 'generic | ' + originalFont }
+ break
+ case 'postCode':
+ rule.directives = { '--monoFont': 'generic | ' + originalFont }
+ newRules.push({ ...rule, component: 'RichContent' })
+ break
+ }
+ newRules.push(rule)
+ })
+ return newRules
+ }
+ const convertShadows = () => {
+ const newRules = []
+ Object.keys(data.shadows || {}).forEach(key => {
+ if (!shadowsKeys.has(key)) return
+ const originalShadow = data.shadows[key]
+ const rule = {}
+
+ switch (key) {
+ case 'panel':
+ rule.component = 'Panel'
+ break
+ case 'topBar':
+ rule.component = 'TopBar'
+ break
+ case 'popup':
+ rule.component = 'Popover'
+ break
+ case 'avatar':
+ rule.component = 'Avatar'
+ break
+ case 'avatarStatus':
+ rule.component = 'Avatar'
+ rule.parent = { component: 'Post' }
+ break
+ case 'panelHeader':
+ rule.component = 'PanelHeader'
+ break
+ case 'button':
+ rule.component = 'Button'
+ break
+ case 'buttonHover':
+ rule.component = 'Button'
+ rule.state = ['hover']
+ break
+ case 'buttonPressed':
+ rule.component = 'Button'
+ rule.state = ['pressed']
+ break
+ case 'input':
+ rule.component = 'Input'
+ break
+ }
+ rule.directives = {
+ shadow: originalShadow
+ }
+ newRules.push(rule)
+ if (key === 'topBar') {
+ newRules.push({ ...rule, component: 'PanelHeader', parent: { component: 'MobileDrawer' } })
+ }
+ if (key === 'avatarStatus') {
+ newRules.push({ ...rule, parent: { component: 'Notification' } })
+ }
+ if (key === 'buttonPressed') {
+ newRules.push({ ...rule, state: ['toggled'] })
+ newRules.push({ ...rule, state: ['toggled', 'focus'] })
+ newRules.push({ ...rule, state: ['pressed', 'focus'] })
+ }
+ if (key === 'buttonHover') {
+ newRules.push({ ...rule, state: ['toggled', 'hover'] })
+ newRules.push({ ...rule, state: ['pressed', 'hover'] })
+ newRules.push({ ...rule, state: ['toggled', 'focus', 'hover'] })
+ newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] })
+ }
+
+ if (rule.component === 'Button') {
+ newRules.push({ ...rule, component: 'ScrollbarElement' })
+ newRules.push({ ...rule, component: 'Tab' })
+ }
+ })
+ return newRules
+ }
+
+ const extendedRules = Object.entries(extendedBaseKeys).map(([prefix, keys]) => {
+ if (nonComponentPrefixes.has(prefix)) return null
+ const rule = {}
+ if (prefix === 'alertPopup') {
+ rule.component = 'Alert'
+ rule.parent = { component: 'Popover' }
+ } else if (prefix === 'selectedPost') {
+ rule.component = 'Post'
+ rule.state = ['selected']
+ } else if (prefix === 'selectedMenu') {
+ rule.component = 'MenuItem'
+ rule.state = ['hover']
+ } else if (prefix === 'chatMessageIncoming') {
+ rule.component = 'ChatMessage'
+ } else if (prefix === 'chatMessageOutgoing') {
+ rule.component = 'ChatMessage'
+ rule.variant = 'outgoing'
+ } else if (prefix === 'panel') {
+ rule.component = 'PanelHeader'
+ } else if (prefix === 'topBar') {
+ rule.component = 'TopBar'
+ } else if (prefix === 'chatMessage') {
+ rule.component = 'ChatMessage'
+ } else if (prefix === 'poll') {
+ rule.component = 'PollGraph'
+ } else if (prefix === 'btn') {
+ rule.component = 'Button'
+ } else {
+ rule.component = prefix[0].toUpperCase() + prefix.slice(1).toLowerCase()
+ }
+ return keys.map((key) => {
+ if (!data.colors[key]) return null
+ const leftoverKey = key.replace(prefix, '')
+ const parts = (leftoverKey || 'Bg').match(/[A-Z][a-z]*/g)
+ const last = parts.slice(-1)[0]
+ let newRule = { directives: {} }
+ let variantArray = []
+
+ switch (last) {
+ case 'Text':
+ case 'Faint': // typo
+ case 'Link':
+ case 'Icon':
+ case 'Greentext':
+ case 'Cyantext':
+ case 'Border':
+ newRule.parent = rule
+ newRule.directives.textColor = data.colors[key]
+ newRule.directives.textAuto = 'no-auto'
+ variantArray = parts.slice(0, -1)
+ break
+ default:
+ newRule = { ...rule, directives: {} }
+ newRule.directives.background = data.colors[key]
+ variantArray = parts
+ break
+ }
+
+ if (last === 'Text' || last === 'Link') {
+ const secondLast = parts.slice(-2)[0]
+ if (secondLast === 'Light') {
+ return null // unsupported
+ } else if (secondLast === 'Faint') {
+ newRule.state = ['faint']
+ variantArray = parts.slice(0, -2)
+ }
+ }
+
+ switch (last) {
+ case 'Text':
+ case 'Link':
+ case 'Icon':
+ case 'Border':
+ newRule.component = last
+ break
+ case 'Greentext':
+ case 'Cyantext':
+ newRule.component = 'FunText'
+ newRule.variant = last.toLowerCase()
+ break
+ case 'Faint':
+ newRule.component = 'Text'
+ newRule.state = ['faint']
+ break
+ }
+
+ variantArray = variantArray.filter(x => x !== 'Bg')
+
+ if (last === 'Link' && prefix === 'selectedPost') {
+ // selectedPost has typo - duplicate 'Post'
+ variantArray = variantArray.filter(x => x !== 'Post')
+ }
+
+ if (prefix === 'popover' && variantArray[0] === 'Post') {
+ newRule.component = 'Post'
+ newRule.parent = { component: 'Popover' }
+ variantArray = variantArray.filter(x => x !== 'Post')
+ }
+
+ if (prefix === 'selectedMenu' && variantArray[0] === 'Popover') {
+ newRule.parent = { component: 'Popover' }
+ variantArray = variantArray.filter(x => x !== 'Popover')
+ }
+
+ switch (prefix) {
+ case 'btn':
+ case 'input':
+ case 'alert': {
+ const hasPanel = variantArray.find(x => x === 'Panel')
+ if (hasPanel) {
+ newRule.parent = { component: 'PanelHeader' }
+ variantArray = variantArray.filter(x => x !== 'Panel')
+ }
+ const hasTop = variantArray.find(x => x === 'Top') // TopBar
+ if (hasTop) {
+ newRule.parent = { component: 'TopBar' }
+ variantArray = variantArray.filter(x => x !== 'Top' && x !== 'Bar')
+ }
+ break
+ }
+ }
+
+ if (variantArray.length > 0) {
+ if (prefix === 'btn') {
+ newRule.state = variantArray.map(x => x.toLowerCase())
+ } else {
+ newRule.variant = variantArray[0].toLowerCase()
+ }
+ }
+
+ if (newRule.component === 'Panel') {
+ return [newRule, { ...newRule, component: 'MobileDrawer' }]
+ } else if (newRule.component === 'Button') {
+ const rules = [
+ newRule,
+ { ...newRule, component: 'Tab' },
+ { ...newRule, component: 'ScrollbarElement' }
+ ]
+ if (newRule.state?.indexOf('toggled') >= 0) {
+ rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
+ rules.push({ ...newRule, state: [...newRule.state, 'hover'] })
+ rules.push({ ...newRule, state: [...newRule.state, 'hover', 'focused'] })
+ }
+ if (newRule.state?.indexOf('hover') >= 0) {
+ rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
+ }
+ return rules
+ } else if (newRule.component === 'Badge') {
+ if (newRule.variant === 'notification') {
+ return [newRule, { component: 'Root', directives: { '--badgeNotification': 'color | ' + newRule.directives.background } }]
+ } else if (newRule.variant === 'neutral') {
+ return [{ ...newRule, variant: 'normal' }]
+ } else {
+ return [newRule]
+ }
+ } else if (newRule.component === 'TopBar') {
+ return [newRule, { ...newRule, parent: { component: 'MobileDrawer' }, component: 'PanelHeader' }]
+ } else {
+ return [newRule]
+ }
+ })
+ })
+
+ const flatExtRules = extendedRules.filter(x => x).reduce((acc, x) => [...acc, ...x], []).filter(x => x).reduce((acc, x) => [...acc, ...x], [])
+
+ return [generateRoot(), ...convertShadows(), ...convertRadii(), ...convertOpacity(), ...convertFonts(), ...flatExtRules]
+}
diff --git a/src/services/theme_data/theme3_slot_functions.js b/src/services/theme_data/theme3_slot_functions.js
new file mode 100644
index 00000000..074a88f0
--- /dev/null
+++ b/src/services/theme_data/theme3_slot_functions.js
@@ -0,0 +1,103 @@
+import { convert, brightness } from 'chromatism'
+import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/color_convert.js'
+
+export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => {
+ const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups
+ const args = argsString.split(/,/g).map(a => a.trim())
+
+ const func = functions[funcName]
+ if (args.length < func.argsNeeded) {
+ throw new Error(`$${funcName} requires at least ${func.argsNeeded} arguments, but ${args.length} were provided`)
+ }
+ return func.exec(args, { findColor, findShadow }, { dynamicVars, staticVars })
+}
+
+export const colorFunctions = {
+ alpha: {
+ argsNeeded: 2,
+ exec: (args, { findColor }, { dynamicVars, staticVars }) => {
+ const [color, amountArg] = args
+
+ const colorArg = convert(findColor(color, { dynamicVars, staticVars })).rgb
+ const amount = Number(amountArg)
+ return { ...colorArg, a: amount }
+ }
+ },
+ textColor: {
+ argsNeeded: 2,
+ exec: (args, { findColor }, { dynamicVars, staticVars }) => {
+ const [backgroundArg, foregroundArg, preserve = 'preserve'] = args
+
+ const background = convert(findColor(backgroundArg, { dynamicVars, staticVars })).rgb
+ const foreground = convert(findColor(foregroundArg, { dynamicVars, staticVars })).rgb
+
+ return getTextColor(background, foreground, preserve === 'preserve')
+ }
+ },
+ blend: {
+ argsNeeded: 3,
+ exec: (args, { findColor }, { dynamicVars, staticVars }) => {
+ const [backgroundArg, amountArg, foregroundArg] = args
+
+ const background = convert(findColor(backgroundArg, { dynamicVars, staticVars })).rgb
+ const foreground = convert(findColor(foregroundArg, { dynamicVars, staticVars })).rgb
+ const amount = Number(amountArg)
+
+ return alphaBlend(background, amount, foreground)
+ }
+ },
+ mod: {
+ argsNeeded: 2,
+ exec: (args, { findColor }, { dynamicVars, staticVars }) => {
+ const [colorArg, amountArg] = args
+
+ const color = convert(findColor(colorArg, { dynamicVars, staticVars })).rgb
+ const amount = Number(amountArg)
+
+ const effectiveBackground = dynamicVars.lowerLevelBackground
+ const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5
+ const mod = isLightOnDark ? 1 : -1
+ return brightness(amount * mod, color).rgb
+ }
+ }
+}
+
+export const shadowFunctions = {
+ borderSide: {
+ argsNeeded: 3,
+ exec: (args, { findColor }) => {
+ const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args
+
+ const width = Number(widthArg)
+ const isInset = inset === 'inset'
+
+ const targetShadow = {
+ x: 0,
+ y: 0,
+ blur: 0,
+ spread: 0,
+ color,
+ alpha: Number(alpha),
+ inset: isInset
+ }
+
+ side.split('-').forEach((position) => {
+ switch (position) {
+ case 'left':
+ targetShadow.x = width * (inset ? 1 : -1)
+ break
+ case 'right':
+ targetShadow.x = -1 * width * (inset ? 1 : -1)
+ break
+ case 'top':
+ targetShadow.y = width * (inset ? 1 : -1)
+ break
+ case 'bottom':
+ targetShadow.y = -1 * width * (inset ? 1 : -1)
+ break
+ }
+ })
+ return [targetShadow]
+ }
+ }
+}
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index b376ef4d..6e477674 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -1,5 +1,5 @@
import { convert, brightness, contrastRatio } from 'chromatism'
-import { alphaBlendLayers, getTextColor, relativeLuminance } from '../color_convert/color_convert.js'
+import { rgb2hex, rgba2css, alphaBlendLayers, getTextColor, relativeLuminance, getCssColor } from '../color_convert/color_convert.js'
import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js'
/*
@@ -407,3 +407,347 @@ export const getColors = (sourceColors, sourceOpacity) => SLOT_ORDERED.reduce(({
}
}
}, { colors: {}, opacity: {} })
+
+export const composePreset = (colors, radii, shadows, fonts) => {
+ return {
+ rules: {
+ ...shadows.rules,
+ ...colors.rules,
+ ...radii.rules,
+ ...fonts.rules
+ },
+ theme: {
+ ...shadows.theme,
+ ...colors.theme,
+ ...radii.theme,
+ ...fonts.theme
+ }
+ }
+}
+
+export const generatePreset = (input) => {
+ const colors = generateColors(input)
+ return composePreset(
+ colors,
+ generateRadii(input),
+ generateShadows(input, colors.theme.colors, colors.mod),
+ generateFonts(input)
+ )
+}
+
+export const getCssShadow = (input, usesDropShadow) => {
+ if (input.length === 0) {
+ return 'none'
+ }
+
+ return input
+ .filter(_ => usesDropShadow ? _.inset : _)
+ .map((shad) => [
+ shad.x,
+ shad.y,
+ shad.blur,
+ shad.spread
+ ].map(_ => _ + 'px').concat([
+ getCssColor(shad.color, shad.alpha),
+ shad.inset ? 'inset' : ''
+ ]).join(' ')).join(', ')
+}
+
+const getCssShadowFilter = (input) => {
+ if (input.length === 0) {
+ return 'none'
+ }
+
+ return input
+ // drop-shadow doesn't support inset or spread
+ .filter((shad) => !shad.inset && Number(shad.spread) === 0)
+ .map((shad) => [
+ shad.x,
+ shad.y,
+ // drop-shadow's blur is twice as strong compared to box-shadow
+ shad.blur / 2
+ ].map(_ => _ + 'px').concat([
+ getCssColor(shad.color, shad.alpha)
+ ]).join(' '))
+ .map(_ => `drop-shadow(${_})`)
+ .join(' ')
+}
+
+export const generateColors = (themeData) => {
+ const sourceColors = !themeData.themeEngineVersion
+ ? colors2to3(themeData.colors || themeData)
+ : themeData.colors || themeData
+
+ 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) : rgba2css(v)
+ return acc
+ }, { complete: {}, solid: {} })
+ return {
+ rules: {
+ colors: Object.entries(htmlColors.complete)
+ .filter(([k, v]) => v)
+ .map(([k, v]) => `--${k}: ${v}`)
+ .join(';')
+ },
+ theme: {
+ colors: htmlColors.solid,
+ opacity
+ }
+ }
+}
+
+export const generateRadii = (input) => {
+ let inputRadii = input.radii || {}
+ // v1 -> v2
+ if (typeof input.btnRadius !== 'undefined') {
+ inputRadii = Object
+ .entries(input)
+ .filter(([k, v]) => k.endsWith('Radius'))
+ .reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {})
+ }
+ const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => {
+ acc[k] = v
+ return acc
+ }, {
+ btn: 4,
+ input: 4,
+ checkbox: 2,
+ panel: 10,
+ avatar: 5,
+ avatarAlt: 50,
+ tooltip: 2,
+ attachment: 5,
+ chatMessage: inputRadii.panel
+ })
+
+ return {
+ rules: {
+ radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';')
+ },
+ theme: {
+ radii
+ }
+ }
+}
+
+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
+ return acc
+ }, acc[k])
+ return acc
+ }, {
+ interface: {
+ family: 'sans-serif'
+ },
+ input: {
+ family: 'inherit'
+ },
+ post: {
+ family: 'inherit'
+ },
+ postCode: {
+ family: 'monospace'
+ }
+ })
+
+ return {
+ rules: {
+ fonts: Object
+ .entries(fonts)
+ .filter(([k, v]) => v)
+ .map(([k, v]) => `--${k}Font: ${v.family}`).join(';')
+ },
+ theme: {
+ fonts
+ }
+ }
+}
+
+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: '#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
+ }]
+}
+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 cleanInputShadows = Object.fromEntries(
+ Object.entries(input.shadows || {})
+ .map(([name, shadowSlot]) => [
+ name,
+ // defaulting color to black to avoid potential problems
+ shadowSlot.map(shadowDef => ({ color: '#000000', ...shadowDef }))
+ ])
+ )
+ const inputShadows = cleanInputShadows && !input.themeEngineVersion
+ ? shadows2to3(cleanInputShadows, input.opacity)
+ : cleanInputShadows || {}
+ 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.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)}`,
+ `--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
+ `--${k}ShadowInset: ${getCssShadow(v, true)}`
+ ].join(';'))
+ .join(';')
+ },
+ theme: {
+ shadows
+ }
+ }
+}
+
+/**
+ * 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 = '#000000' }) => 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 }
+ }, {})
+}
+
+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 }
+ }
+ }, {})
+}
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
new file mode 100644
index 00000000..15b4493e
--- /dev/null
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -0,0 +1,474 @@
+import { convert, brightness } from 'chromatism'
+import sum from 'hash-sum'
+import { flattenDeep } from 'lodash'
+import {
+ alphaBlend,
+ getTextColor,
+ rgba2css,
+ mixrgb,
+ relativeLuminance
+} from '../color_convert/color_convert.js'
+
+import {
+ colorFunctions,
+ shadowFunctions,
+ process
+} from './theme3_slot_functions.js'
+
+import {
+ unroll,
+ getAllPossibleCombinations,
+ genericRuleToSelector,
+ normalizeCombination,
+ findRules
+} from './iss_utils.js'
+import { parseCssShadow } from './css_utils.js'
+
+// Ensuring the order of components
+const components = {
+ Root: null,
+ Text: null,
+ FunText: null,
+ Link: null,
+ Icon: null,
+ Border: null,
+ Panel: null,
+ Chat: null,
+ ChatMessage: null
+}
+
+const findShadow = (shadows, { dynamicVars, staticVars }) => {
+ return (shadows || []).map(shadow => {
+ let targetShadow
+ if (typeof shadow === 'string') {
+ if (shadow.startsWith('$')) {
+ targetShadow = process(shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars })
+ } else if (shadow.startsWith('--')) {
+ const [variable] = shadow.split(/,/g).map(str => str.trim()) // discarding modifier since it's not supported
+ const variableSlot = variable.substring(2)
+ return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
+ } else {
+ targetShadow = parseCssShadow(shadow)
+ }
+ } else {
+ targetShadow = shadow
+ }
+
+ const shadowArray = Array.isArray(targetShadow) ? targetShadow : [targetShadow]
+ return shadowArray.map(s => ({
+ ...s,
+ color: findColor(s.color, { dynamicVars, staticVars })
+ }))
+ })
+}
+
+const findColor = (color, { dynamicVars, staticVars }) => {
+ if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color
+ let targetColor = null
+ if (color.startsWith('--')) {
+ const [variable, modifier] = color.split(/,/g).map(str => str.trim())
+ const variableSlot = variable.substring(2)
+ if (variableSlot === 'stack') {
+ const { r, g, b } = dynamicVars.stacked
+ targetColor = { r, g, b }
+ } else if (variableSlot.startsWith('parent')) {
+ if (variableSlot === 'parent') {
+ const { r, g, b } = dynamicVars.lowerLevelBackground
+ targetColor = { r, g, b }
+ } else {
+ const virtualSlot = variableSlot.replace(/^parent/, '')
+ targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb
+ }
+ } else {
+ switch (variableSlot) {
+ case 'inheritedBackground':
+ targetColor = convert(dynamicVars.inheritedBackground).rgb
+ break
+ case 'background':
+ targetColor = convert(dynamicVars.background).rgb
+ break
+ default:
+ targetColor = convert(staticVars[variableSlot]).rgb
+ }
+ }
+
+ if (modifier) {
+ const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor
+ const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5
+ const mod = isLightOnDark ? 1 : -1
+ targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
+ }
+ }
+
+ if (color.startsWith('$')) {
+ try {
+ targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars })
+ } catch (e) {
+ console.error('Failure executing color function', e)
+ targetColor = '#FF00FF'
+ }
+ }
+ // Color references other color
+ return targetColor
+}
+
+const getTextColorAlpha = (directives, intendedTextColor, dynamicVars, staticVars) => {
+ const opacity = directives.textOpacity
+ const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
+ const textColor = convert(findColor(intendedTextColor, { dynamicVars, staticVars })).rgb
+ if (opacity === null || opacity === undefined || opacity >= 1) {
+ return convert(textColor).hex
+ }
+ if (opacity === 0) {
+ return convert(backgroundColor).hex
+ }
+ const opacityMode = directives.textOpacityMode
+ switch (opacityMode) {
+ case 'fake':
+ return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
+ case 'mixrgb':
+ return convert(mixrgb(backgroundColor, textColor)).hex
+ default:
+ return rgba2css({ a: opacity, ...textColor })
+ }
+}
+
+// Loading all style.js[on] files dynamically
+const componentsContext = require.context('src', true, /\.style.js(on)?$/)
+componentsContext.keys().forEach(key => {
+ const component = componentsContext(key).default
+ if (components[component.name] != null) {
+ console.warn(`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`)
+ }
+ components[component.name] = component
+})
+
+const engineChecksum = sum(components)
+
+const ruleToSelector = genericRuleToSelector(components)
+
+export const getEngineChecksum = () => engineChecksum
+
+export const init = (extraRuleset, ultimateBackgroundColor) => {
+ const staticVars = {}
+ const stacked = {}
+ const computed = {}
+
+ const rulesetUnsorted = [
+ ...Object.values(components)
+ .map(c => (c.defaultRules || []).map(r => ({ component: c.name, ...r })))
+ .reduce((acc, arr) => [...acc, ...arr], []),
+ ...extraRuleset
+ ].map(rule => {
+ normalizeCombination(rule)
+ let currentParent = rule.parent
+ while (currentParent) {
+ normalizeCombination(currentParent)
+ currentParent = currentParent.parent
+ }
+
+ return rule
+ })
+
+ const ruleset = rulesetUnsorted
+ .map((data, index) => ({ data, index }))
+ .sort(({ data: a, index: ai }, { data: b, index: bi }) => {
+ const parentsA = unroll(a).length
+ const parentsB = unroll(b).length
+
+ if (parentsA === parentsB) {
+ if (a.component === 'Text') return -1
+ if (b.component === 'Text') return 1
+ return ai - bi
+ }
+ if (parentsA === 0 && parentsB !== 0) return -1
+ if (parentsB === 0 && parentsA !== 0) return 1
+ return parentsA - parentsB
+ })
+ .map(({ data }) => data)
+
+ const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name))
+
+ const processCombination = (combination) => {
+ const selector = ruleToSelector(combination, true)
+ const cssSelector = ruleToSelector(combination)
+
+ const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
+ const soloSelector = selector.split(/ /g).slice(-1)[0]
+
+ const lowerLevelSelector = parentSelector
+ const lowerLevelBackground = computed[lowerLevelSelector]?.background
+ const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives
+ const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw
+
+ const dynamicVars = computed[selector] || {
+ lowerLevelBackground,
+ lowerLevelVirtualDirectives,
+ lowerLevelVirtualDirectivesRaw
+ }
+
+ // Inheriting all of the applicable rules
+ const existingRules = ruleset.filter(findRules(combination))
+ const computedDirectives = existingRules.map(r => r.directives).reduce((acc, directives) => ({ ...acc, ...directives }), {})
+ const computedRule = {
+ ...combination,
+ directives: computedDirectives
+ }
+
+ computed[selector] = computed[selector] || {}
+ computed[selector].computedRule = computedRule
+ computed[selector].dynamicVars = dynamicVars
+
+ if (virtualComponents.has(combination.component)) {
+ const virtualName = [
+ '--',
+ combination.component.toLowerCase(),
+ combination.variant === 'normal'
+ ? ''
+ : combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(),
+ ...combination.state.filter(x => x !== 'normal').toSorted().map(state => state[0].toUpperCase() + state.slice(1).toLowerCase())
+ ].join('')
+
+ let inheritedTextColor = computedDirectives.textColor
+ let inheritedTextAuto = computedDirectives.textAuto
+ let inheritedTextOpacity = computedDirectives.textOpacity
+ let inheritedTextOpacityMode = computedDirectives.textOpacityMode
+ const lowerLevelTextSelector = [...selector.split(/ /g).slice(0, -1), soloSelector].join(' ')
+ const lowerLevelTextRule = computed[lowerLevelTextSelector]
+
+ if (inheritedTextColor == null || inheritedTextOpacity == null || inheritedTextOpacityMode == null) {
+ inheritedTextColor = computedDirectives.textColor ?? lowerLevelTextRule.textColor
+ inheritedTextAuto = computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
+ inheritedTextOpacity = computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
+ inheritedTextOpacityMode = computedDirectives.textOpacityMode ?? lowerLevelTextRule.textOpacityMode
+ }
+
+ const newTextRule = {
+ ...computedRule,
+ directives: {
+ ...computedRule.directives,
+ textColor: inheritedTextColor,
+ textAuto: inheritedTextAuto ?? 'preserve',
+ textOpacity: inheritedTextOpacity,
+ textOpacityMode: inheritedTextOpacityMode
+ }
+ }
+
+ dynamicVars.inheritedBackground = lowerLevelBackground
+ dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
+
+ const intendedTextColor = convert(findColor(inheritedTextColor, { dynamicVars, staticVars })).rgb
+ const textColor = newTextRule.directives.textAuto === 'no-auto'
+ ? intendedTextColor
+ : getTextColor(
+ convert(stacked[lowerLevelSelector]).rgb,
+ intendedTextColor,
+ newTextRule.directives.textAuto === 'preserve'
+ )
+ const virtualDirectives = computed[lowerLevelSelector].virtualDirectives || {}
+ const virtualDirectivesRaw = computed[lowerLevelSelector].virtualDirectivesRaw || {}
+
+ // Storing color data in lower layer to use as custom css properties
+ virtualDirectives[virtualName] = getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
+ virtualDirectivesRaw[virtualName] = textColor
+
+ computed[lowerLevelSelector].virtualDirectives = virtualDirectives
+ computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
+
+ return {
+ dynamicVars,
+ selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
+ ...combination,
+ directives: {},
+ virtualDirectives: {
+ [virtualName]: getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
+ },
+ virtualDirectivesRaw: {
+ [virtualName]: textColor
+ }
+ }
+ } else {
+ computed[selector] = computed[selector] || {}
+
+ // TODO: DEFAULT TEXT COLOR
+ const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
+
+ if (computedDirectives.background) {
+ let inheritRule = null
+ const variantRules = ruleset.filter(
+ findRules({
+ component: combination.component,
+ variant: combination.variant,
+ parent: combination.parent
+ })
+ )
+ const lastVariantRule = variantRules[variantRules.length - 1]
+ if (lastVariantRule) {
+ inheritRule = lastVariantRule
+ } else {
+ const normalRules = ruleset.filter(findRules({
+ component: combination.component,
+ parent: combination.parent
+ }))
+ const lastNormalRule = normalRules[normalRules.length - 1]
+ inheritRule = lastNormalRule
+ }
+
+ const inheritSelector = ruleToSelector({ ...inheritRule, parent: combination.parent }, true)
+ const inheritedBackground = computed[inheritSelector].background
+
+ dynamicVars.inheritedBackground = inheritedBackground
+
+ const rgb = convert(findColor(computedDirectives.background, { dynamicVars, staticVars })).rgb
+
+ if (!stacked[selector]) {
+ let blend
+ const alpha = computedDirectives.opacity ?? 1
+ if (alpha >= 1) {
+ blend = rgb
+ } else if (alpha <= 0) {
+ blend = lowerLevelStackedBackground
+ } else {
+ blend = alphaBlend(rgb, computedDirectives.opacity, lowerLevelStackedBackground)
+ }
+ stacked[selector] = blend
+ computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1 }
+ }
+ }
+
+ if (computedDirectives.shadow) {
+ dynamicVars.shadow = flattenDeep(findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars }))
+ }
+
+ if (!stacked[selector]) {
+ computedDirectives.background = 'transparent'
+ computedDirectives.opacity = 0
+ stacked[selector] = lowerLevelStackedBackground
+ computed[selector].background = { ...lowerLevelStackedBackground, a: 0 }
+ }
+
+ dynamicVars.stacked = stacked[selector]
+ dynamicVars.background = computed[selector].background
+
+ const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--'))
+
+ dynamicSlots.forEach(([k, v]) => {
+ const [type, ...value] = v.split('|').map(x => x.trim()) // woah, Extreme!
+ switch (type) {
+ case 'color': {
+ const color = findColor(value[0], { dynamicVars, staticVars })
+ dynamicVars[k] = color
+ if (combination.component === 'Root') {
+ staticVars[k.substring(2)] = color
+ }
+ break
+ }
+ case 'shadow': {
+ const shadow = value
+ dynamicVars[k] = shadow
+ if (combination.component === 'Root') {
+ staticVars[k.substring(2)] = shadow
+ }
+ break
+ }
+ case 'generic': {
+ dynamicVars[k] = value
+ if (combination.component === 'Root') {
+ staticVars[k.substring(2)] = value
+ }
+ break
+ }
+ }
+ })
+
+ const rule = {
+ dynamicVars,
+ selector: cssSelector,
+ ...combination,
+ directives: computedDirectives
+ }
+
+ return rule
+ }
+ }
+
+ const processInnerComponent = (component, parent) => {
+ const combinations = []
+ const {
+ validInnerComponents = [],
+ states: originalStates = {},
+ variants: originalVariants = {}
+ } = component
+
+ // Normalizing states and variants to always include "normal"
+ const states = { normal: '', ...originalStates }
+ const variants = { normal: '', ...originalVariants }
+ const innerComponents = (validInnerComponents).map(name => {
+ const result = components[name]
+ if (result === undefined) console.error(`Component ${component.name} references a component ${name} which does not exist!`)
+ return result
+ })
+
+ // Optimization: we only really need combinations without "normal" because all states implicitly have it
+ const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal')
+ const stateCombinations = [
+ ['normal'],
+ ...getAllPossibleCombinations(permutationStateKeys)
+ .map(combination => ['normal', ...combination])
+ .filter(combo => {
+ // Optimization: filter out some hard-coded combinations that don't make sense
+ if (combo.indexOf('disabled') >= 0) {
+ return !(
+ combo.indexOf('hover') >= 0 ||
+ combo.indexOf('focused') >= 0 ||
+ combo.indexOf('pressed') >= 0
+ )
+ }
+ return true
+ })
+ ]
+
+ const stateVariantCombination = Object.keys(variants).map(variant => {
+ return stateCombinations.map(state => ({ variant, state }))
+ }).reduce((acc, x) => [...acc, ...x], [])
+
+ stateVariantCombination.forEach(combination => {
+ combination.component = component.name
+ combination.lazy = component.lazy || parent?.lazy
+ combination.parent = parent
+ if (combination.state.indexOf('hover') >= 0) {
+ combination.lazy = true
+ }
+
+ combinations.push(combination)
+
+ innerComponents.forEach(innerComponent => {
+ combinations.push(...processInnerComponent(innerComponent, combination))
+ })
+ })
+
+ return combinations
+ }
+
+ const t0 = performance.now()
+ const combinations = processInnerComponent(components.Root)
+ const t1 = performance.now()
+ console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
+
+ const result = combinations.map((combination) => {
+ if (combination.lazy) {
+ return async () => processCombination(combination)
+ } else {
+ return processCombination(combination)
+ }
+ }).filter(x => x)
+ const t2 = performance.now()
+ console.debug('Eager processing took ' + (t2 - t1) + ' ms')
+
+ return {
+ lazy: result.filter(x => typeof x === 'function'),
+ eager: result.filter(x => typeof x !== 'function'),
+ staticVars,
+ engineChecksum
+ }
+}