+
{{ $t('settings.theme') }}
-
+
+
+
-
-
- {{ $t("settings.style.appearance_tab_note") }}
+
+
+
+
+
{{ $t('settings.style.themes3.palette.label') }}
+
+
+ {{ $t('settings.style.themes3.palette.bundled') }}
+
+
+ {{ $t('settings.style.themes3.palette.style') }}
+
+
+
+ {{ $t('settings.style.themes3.palette.user') }}
+
+ setPaletteCustom(data)"
+ />
+
+
+
+ {{ $t('settings.style.themes3.palette.v2_unsupported') }}
+
+
+
+
{{ $t('settings.scale_and_layout') }}
+
+ {{ $t("settings.style.appearance_tab_note") }}
+
-
px
rem
-
+
-
+
diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.js b/src/components/settings_modal/tabs/style_tab/style_tab.js
new file mode 100644
index 00000000..d38915c0
--- /dev/null
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.js
@@ -0,0 +1,835 @@
+import { ref, reactive, computed, watch, watchEffect, provide, getCurrentInstance } from 'vue'
+import { useStore } from 'vuex'
+import { get, set, unset, throttle } from 'lodash'
+
+import Select from 'src/components/select/select.vue'
+import SelectMotion from 'src/components/select/select_motion.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import ComponentPreview from 'src/components/component_preview/component_preview.vue'
+import StringSetting from '../../helpers/string_setting.vue'
+import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
+import ColorInput from 'src/components/color_input/color_input.vue'
+import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
+import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
+import RoundnessInput from 'src/components/roundness_input/roundness_input.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import Tooltip from 'src/components/tooltip/tooltip.vue'
+import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
+import Preview from '../theme_tab/theme_preview.vue'
+
+import VirtualDirectivesTab from './virtual_directives_tab.vue'
+
+import { init, findColor } from 'src/services/theme_data/theme_data_3.service.js'
+import {
+ getCssRules,
+ getScopedVersion
+} from 'src/services/theme_data/css_utils.js'
+import { serialize } from 'src/services/theme_data/iss_serializer.js'
+import { deserializeShadow, deserialize } from 'src/services/theme_data/iss_deserializer.js'
+import {
+ rgb2hex,
+ hex2rgb,
+ getContrastRatio
+} from 'src/services/color_convert/color_convert.js'
+import {
+ newImporter,
+ newExporter
+} from 'src/services/export_import/export_import.js'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faFloppyDisk,
+ faFolderOpen,
+ faFile,
+ faArrowsRotate,
+ faCheck
+} from '@fortawesome/free-solid-svg-icons'
+
+// helper for debugging
+// eslint-disable-next-line no-unused-vars
+const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
+
+// helper to make states comparable
+const normalizeStates = (states) => ['normal', ...(states?.filter(x => x !== 'normal') || [])].join(':')
+
+library.add(
+ faFile,
+ faFloppyDisk,
+ faFolderOpen,
+ faArrowsRotate,
+ faCheck
+)
+
+export default {
+ components: {
+ Select,
+ SelectMotion,
+ Checkbox,
+ Tooltip,
+ StringSetting,
+ ComponentPreview,
+ TabSwitcher,
+ ShadowControl,
+ ColorInput,
+ PaletteEditor,
+ OpacityInput,
+ RoundnessInput,
+ ContrastRatio,
+ Preview,
+ VirtualDirectivesTab
+ },
+ setup (props, context) {
+ const exports = {}
+ const store = useStore()
+ // All rules that are made by editor
+ const allEditedRules = ref(store.state.interface.styleDataUsed || {})
+ const styleDataUsed = computed(() => store.state.interface.styleDataUsed)
+
+ watch([styleDataUsed], (value) => {
+ onImport(store.state.interface.styleDataUsed)
+ }, { once: true })
+
+ exports.isActive = computed(() => {
+ const tabSwitcher = getCurrentInstance().parent.ctx
+ return tabSwitcher ? tabSwitcher.isActive('style') : false
+ })
+
+ // ## Meta stuff
+ exports.name = ref('')
+ exports.author = ref('')
+ exports.license = ref('')
+ exports.website = ref('')
+
+ const metaOut = computed(() => {
+ return [
+ '@meta {',
+ ` name: ${exports.name.value};`,
+ ` author: ${exports.author.value};`,
+ ` license: ${exports.license.value};`,
+ ` website: ${exports.website.value};`,
+ '}'
+ ].join('\n')
+ })
+
+ const metaRule = computed(() => ({
+ component: '@meta',
+ directives: {
+ name: exports.name.value,
+ author: exports.author.value,
+ license: exports.license.value,
+ website: exports.website.value
+ }
+ }))
+
+ // ## Palette stuff
+ const palettes = reactive([
+ {
+ name: 'default',
+ bg: '#121a24',
+ fg: '#182230',
+ text: '#b9b9ba',
+ link: '#d8a070',
+ accent: '#d8a070',
+ cRed: '#FF0000',
+ cBlue: '#0095ff',
+ cGreen: '#0fa00f',
+ cOrange: '#ffa500'
+ },
+ {
+ name: 'light',
+ bg: '#f2f6f9',
+ fg: '#d6dfed',
+ text: '#304055',
+ underlay: '#5d6086',
+ accent: '#f55b1b',
+ cBlue: '#0095ff',
+ cRed: '#d31014',
+ cGreen: '#0fa00f',
+ cOrange: '#ffa500',
+ border: '#d8e6f9'
+ }
+ ])
+ exports.palettes = palettes
+
+ // This is kinda dumb but you cannot "replace" reactive() object
+ // and so v-model simply fails when you try to chage (increase only?)
+ // length of the array. Since linter complains about mutating modelValue
+ // inside SelectMotion, the next best thing is to just wipe existing array
+ // and replace it with new one.
+
+ const onPalettesUpdate = (e) => {
+ palettes.splice(0, palettes.length)
+ palettes.push(...e)
+ }
+ exports.onPalettesUpdate = onPalettesUpdate
+
+ const selectedPaletteId = ref(0)
+ const selectedPalette = computed({
+ get () {
+ return palettes[selectedPaletteId.value]
+ },
+ set (newPalette) {
+ palettes[selectedPaletteId.value] = newPalette
+ }
+ })
+ exports.selectedPaletteId = selectedPaletteId
+ exports.selectedPalette = selectedPalette
+ provide('selectedPalette', selectedPalette)
+
+ watch([selectedPalette], () => updateOverallPreview())
+
+ exports.getNewPalette = () => ({
+ name: 'new palette',
+ bg: '#121a24',
+ fg: '#182230',
+ text: '#b9b9ba',
+ link: '#d8a070',
+ accent: '#d8a070',
+ cRed: '#FF0000',
+ cBlue: '#0095ff',
+ cGreen: '#0fa00f',
+ cOrange: '#ffa500'
+ })
+
+ // Raw format
+ const palettesRule = computed(() => {
+ return palettes.map(palette => {
+ const { name, ...rest } = palette
+ return {
+ component: '@palette',
+ variant: name,
+ directives: Object
+ .entries(rest)
+ .filter(([k, v]) => v && k)
+ .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
+ }
+ })
+ })
+
+ // Text format
+ const palettesOut = computed(() => {
+ return palettes.map(({ name, ...palette }) => {
+ const entries = Object
+ .entries(palette)
+ .filter(([k, v]) => v && k)
+ .map(([slot, data]) => ` ${slot}: ${data};`)
+ .join('\n')
+
+ return `@palette.${name} {\n${entries}\n}`
+ }).join('\n\n')
+ })
+
+ // ## Components stuff
+ // Getting existing components
+ const componentsContext = require.context('src', true, /\.style.js(on)?$/)
+ const componentKeysAll = componentsContext.keys()
+ const componentsMap = new Map(
+ componentKeysAll
+ .map(
+ key => [key, componentsContext(key).default]
+ ).filter(([key, component]) => !component.virtual && !component.notEditable)
+ )
+ exports.componentsMap = componentsMap
+ const componentKeys = [...componentsMap.keys()]
+ exports.componentKeys = componentKeys
+
+ // Component list and selection
+ const selectedComponentKey = ref(componentsMap.keys().next().value)
+ exports.selectedComponentKey = selectedComponentKey
+
+ const selectedComponent = computed(() => componentsMap.get(selectedComponentKey.value))
+ const selectedComponentName = computed(() => selectedComponent.value.name)
+
+ // Selection basis
+ exports.selectedComponentVariants = computed(() => {
+ return Object.keys({ normal: null, ...(selectedComponent.value.variants || {}) })
+ })
+ exports.selectedComponentStates = computed(() => {
+ const all = Object.keys({ normal: null, ...(selectedComponent.value.states || {}) })
+ return all.filter(x => x !== 'normal')
+ })
+
+ // selection
+ const selectedVariant = ref('normal')
+ exports.selectedVariant = selectedVariant
+ const selectedState = reactive(new Set())
+ exports.selectedState = selectedState
+ exports.updateSelectedStates = (state, v) => {
+ if (v) {
+ selectedState.add(state)
+ } else {
+ selectedState.delete(state)
+ }
+ }
+
+ // Reset variant and state on component change
+ const updateSelectedComponent = () => {
+ selectedVariant.value = 'normal'
+ selectedState.clear()
+ }
+
+ watch(
+ selectedComponentName,
+ updateSelectedComponent
+ )
+
+ // ### Rules stuff aka meat and potatoes
+ // The native structure of separate rules and the child -> parent
+ // relation isn't very convenient for editor, we replace the array
+ // and child -> parent structure with map and parent -> child structure
+ const rulesToEditorFriendly = (rules, root = {}) => rules.reduce((acc, rule) => {
+ const { parent: rParent, component: rComponent } = rule
+ const parent = rParent ?? rule
+ const hasChildren = !!rParent
+ const child = hasChildren ? rule : null
+
+ const {
+ component: pComponent,
+ variant: pVariant = 'normal',
+ state: pState = [] // no relation to Intel CPUs whatsoever
+ } = parent
+
+ const pPath = `${hasChildren ? pComponent : rComponent}.${pVariant}.${normalizeStates(pState)}`
+
+ let output = get(acc, pPath)
+ if (!output) {
+ set(acc, pPath, {})
+ output = get(acc, pPath)
+ }
+
+ if (hasChildren) {
+ output._children = output._children ?? {}
+ const {
+ component: cComponent,
+ variant: cVariant = 'normal',
+ state: cState = [],
+ directives
+ } = child
+
+ const cPath = `${cComponent}.${cVariant}.${normalizeStates(cState)}`
+ set(output._children, cPath, { directives })
+ } else {
+ output.directives = parent.directives
+ }
+ return acc
+ }, root)
+
+ const editorFriendlyFallbackStructure = computed(() => {
+ const root = {}
+
+ componentKeys.forEach((componentKey) => {
+ const componentValue = componentsMap.get(componentKey)
+ const { defaultRules, name } = componentValue
+ rulesToEditorFriendly(
+ defaultRules.map((rule) => ({ ...rule, component: name })),
+ root
+ )
+ })
+
+ return root
+ })
+
+ // Checking whether component can support some "directives" which
+ // are actually virtual subcomponents, i.e. Text, Link etc
+ exports.componentHas = (subComponent) => {
+ return !!selectedComponent.value.validInnerComponents?.find(x => x === subComponent)
+ }
+
+ // Path for lodash's get and set
+ const getPath = (component, directive) => {
+ const pathSuffix = component ? `._children.${component}.normal.normal` : ''
+ const path = `${selectedComponentName.value}.${selectedVariant.value}.${normalizeStates([...selectedState])}${pathSuffix}.directives.${directive}`
+ return path
+ }
+
+ // Templates for directives
+ const isElementPresent = (component, directive, defaultValue = '') => computed({
+ get () {
+ return get(allEditedRules.value, getPath(component, directive)) != null
+ },
+ set (value) {
+ if (value) {
+ const fallback = get(
+ editorFriendlyFallbackStructure.value,
+ getPath(component, directive)
+ )
+ set(allEditedRules.value, getPath(component, directive), fallback ?? defaultValue)
+ } else {
+ unset(allEditedRules.value, getPath(component, directive))
+ }
+ exports.updateOverallPreview()
+ }
+ })
+
+ const getEditedElement = (component, directive, postProcess = x => x) => computed({
+ get () {
+ let usedRule
+ const fallback = editorFriendlyFallbackStructure.value
+ const real = allEditedRules.value
+ const path = getPath(component, directive)
+
+ usedRule = get(real, path) // get real
+ if (!usedRule) {
+ usedRule = get(fallback, path)
+ }
+
+ return postProcess(usedRule)
+ },
+ set (value) {
+ if (value) {
+ set(allEditedRules.value, getPath(component, directive), value)
+ } else {
+ unset(allEditedRules.value, getPath(component, directive))
+ }
+ exports.updateOverallPreview()
+ }
+ })
+
+ // All the editable stuff for the component
+ exports.editedBackgroundColor = getEditedElement(null, 'background')
+ exports.isBackgroundColorPresent = isElementPresent(null, 'background', '#FFFFFF')
+ exports.editedOpacity = getEditedElement(null, 'opacity')
+ exports.isOpacityPresent = isElementPresent(null, 'opacity', 1)
+ exports.editedRoundness = getEditedElement(null, 'roundness')
+ exports.isRoundnessPresent = isElementPresent(null, 'roundness', '0')
+ exports.editedTextColor = getEditedElement('Text', 'textColor')
+ exports.isTextColorPresent = isElementPresent('Text', 'textColor', '#000000')
+ exports.editedTextAuto = getEditedElement('Text', 'textAuto')
+ exports.isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000')
+ exports.editedLinkColor = getEditedElement('Link', 'textColor')
+ exports.isLinkColorPresent = isElementPresent('Link', 'textColor', '#000080')
+ exports.editedIconColor = getEditedElement('Icon', 'textColor')
+ exports.isIconColorPresent = isElementPresent('Icon', 'textColor', '#909090')
+ exports.editedBorderColor = getEditedElement('Border', 'textColor')
+ exports.isBorderColorPresent = isElementPresent('Border', 'textColor', '#909090')
+
+ const getContrast = (bg, text) => {
+ try {
+ const bgRgb = hex2rgb(bg)
+ const textRgb = hex2rgb(text)
+
+ const ratio = getContrastRatio(bgRgb, textRgb)
+ return {
+ // TODO this ideally should be part of
+ ratio,
+ text: ratio.toPrecision(3) + ':1',
+ // AA level, AAA level
+ aa: ratio >= 4.5,
+ aaa: ratio >= 7,
+ // same but for 18pt+ texts
+ laa: ratio >= 3,
+ laaa: ratio >= 4.5
+ }
+ } catch (e) {
+ console.warn('Failure computing contrast', e)
+ return { error: e }
+ }
+ }
+
+ const normalizeShadows = (shadows) => {
+ return shadows?.map(shadow => {
+ if (typeof shadow === 'object') {
+ return shadow
+ }
+ if (typeof shadow === 'string') {
+ try {
+ return deserializeShadow(shadow)
+ } catch (e) {
+ console.warn(e)
+ return shadow
+ }
+ }
+ return null
+ })
+ }
+ provide('normalizeShadows', normalizeShadows)
+
+ // Shadow is partially edited outside the ShadowControl
+ // for better space utilization
+ const editedShadow = getEditedElement(null, 'shadow', normalizeShadows)
+ exports.editedShadow = editedShadow
+ const editedSubShadowId = ref(null)
+ exports.editedSubShadowId = editedSubShadowId
+ const editedSubShadow = computed(() => {
+ if (editedShadow.value == null || editedSubShadowId.value == null) return null
+ return editedShadow.value[editedSubShadowId.value]
+ })
+ exports.editedSubShadow = editedSubShadow
+ exports.isShadowPresent = isElementPresent(null, 'shadow', [])
+ exports.onSubShadow = (id) => {
+ if (id != null) {
+ editedSubShadowId.value = id
+ } else {
+ editedSubShadow.value = null
+ }
+ }
+ exports.updateSubShadow = (axis, value) => {
+ if (!editedSubShadow.value || editedSubShadowId.value == null) return
+ const newEditedShadow = [...editedShadow.value]
+
+ newEditedShadow[editedSubShadowId.value] = {
+ ...newEditedShadow[editedSubShadowId.value],
+ [axis]: value
+ }
+
+ editedShadow.value = newEditedShadow
+ }
+ exports.isShadowTabOpen = ref(false)
+ exports.onTabSwitch = (tab) => {
+ exports.isShadowTabOpen.value = tab === 'shadow'
+ }
+
+ // component preview
+ exports.editorHintStyle = computed(() => {
+ const editorHint = selectedComponent.value.editor
+ const styles = []
+ if (editorHint && Object.keys(editorHint).length > 0) {
+ if (editorHint.aspect != null) {
+ styles.push(`aspect-ratio: ${editorHint.aspect} !important;`)
+ }
+ if (editorHint.border != null) {
+ styles.push(`border-width: ${editorHint.border}px !important;`)
+ }
+ }
+ return styles.join('; ')
+ })
+
+ const editorFriendlyToOriginal = computed(() => {
+ const resultRules = []
+
+ const convert = (component, data = {}, parent) => {
+ const variants = Object.entries(data || {})
+
+ variants.forEach(([variant, variantData]) => {
+ const states = Object.entries(variantData)
+
+ states.forEach(([jointState, stateData]) => {
+ const state = jointState.split(/:/g)
+ const result = {
+ component,
+ variant,
+ state,
+ directives: stateData.directives || {}
+ }
+
+ if (parent) {
+ result.parent = {
+ component: parent
+ }
+ }
+
+ resultRules.push(result)
+
+ // Currently we only support single depth for simplicity's sake
+ if (!parent) {
+ Object.entries(stateData._children || {}).forEach(([cName, child]) => convert(cName, child, component))
+ }
+ })
+ })
+ }
+
+ [...componentsMap.values()].forEach(({ name }) => {
+ convert(name, allEditedRules.value[name])
+ })
+
+ return resultRules
+ })
+
+ const allCustomVirtualDirectives = [...componentsMap.values()]
+ .map(c => {
+ return c
+ .defaultRules
+ .filter(c => c.component === 'Root')
+ .map(x => Object.entries(x.directives))
+ .flat()
+ })
+ .filter(x => x)
+ .flat()
+ .map(([name, value]) => {
+ const [valType, valVal] = value.split('|')
+ return {
+ name: name.substring(2),
+ valType: valType?.trim(),
+ value: valVal?.trim()
+ }
+ })
+
+ const virtualDirectives = ref(allCustomVirtualDirectives)
+ exports.virtualDirectives = virtualDirectives
+ exports.updateVirtualDirectives = (value) => {
+ virtualDirectives.value = value
+ }
+
+ // Raw format
+ const virtualDirectivesRule = computed(() => ({
+ component: 'Root',
+ directives: Object.fromEntries(
+ virtualDirectives.value.map(vd => [`--${vd.name}`, `${vd.valType} | ${vd.value}`])
+ )
+ }))
+
+ // Text format
+ const virtualDirectivesOut = computed(() => {
+ return [
+ 'Root {',
+ ...virtualDirectives.value
+ .filter(vd => vd.name && vd.valType && vd.value)
+ .map(vd => ` --${vd.name}: ${vd.valType} | ${vd.value};`),
+ '}'
+ ].join('\n')
+ })
+
+ exports.computeColor = (color) => {
+ let computedColor
+ try {
+ computedColor = findColor(color, { dynamicVars: dynamicVars.value, staticVars: staticVars.value })
+ if (computedColor) {
+ return rgb2hex(computedColor)
+ }
+ } catch (e) {
+ console.warn(e)
+ }
+ return null
+ }
+ provide('computeColor', exports.computeColor)
+
+ exports.contrast = computed(() => {
+ return getContrast(
+ exports.computeColor(previewColors.value.background),
+ exports.computeColor(previewColors.value.text)
+ )
+ })
+
+ // ## Export and Import
+ const styleExporter = newExporter({
+ filename: () => exports.name.value ?? 'pleroma_theme',
+ mime: 'text/plain',
+ extension: 'piss',
+ getExportedObject: () => exportStyleData.value
+ })
+
+ const onImport = parsed => {
+ const editorComponents = parsed.filter(x => x.component.startsWith('@'))
+ const rootComponent = parsed.find(x => x.component === 'Root')
+ const rules = parsed.filter(x => !x.component.startsWith('@') && x.component !== 'Root')
+ const metaIn = editorComponents.find(x => x.component === '@meta').directives
+ const palettesIn = editorComponents.filter(x => x.component === '@palette')
+
+ exports.name.value = metaIn.name
+ exports.license.value = metaIn.license
+ exports.author.value = metaIn.author
+ exports.website.value = metaIn.website
+
+ const newVirtualDirectives = Object
+ .entries(rootComponent.directives)
+ .map(([name, value]) => {
+ const [valType, valVal] = value.split('|').map(x => x.trim())
+ return { name: name.substring(2), valType, value: valVal }
+ })
+ virtualDirectives.value = newVirtualDirectives
+
+ onPalettesUpdate(palettesIn.map(x => ({ name: x.variant, ...x.directives })))
+
+ allEditedRules.value = rulesToEditorFriendly(rules)
+
+ exports.updateOverallPreview()
+ }
+
+ const styleImporter = newImporter({
+ accept: '.piss',
+ parser (string) { return deserialize(string) },
+ onImportFailure (result) {
+ console.error('Failure importing style:', result)
+ this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
+ },
+ onImport
+ })
+
+ // Raw format
+ const exportRules = computed(() => [
+ metaRule.value,
+ ...palettesRule.value,
+ virtualDirectivesRule.value,
+ ...editorFriendlyToOriginal.value
+ ])
+
+ // Text format
+ const exportStyleData = computed(() => {
+ return [
+ metaOut.value,
+ palettesOut.value,
+ virtualDirectivesOut.value,
+ serialize(editorFriendlyToOriginal.value)
+ ].join('\n\n')
+ })
+
+ exports.clearStyle = () => {
+ onImport(store.state.interface.styleDataUsed)
+ }
+
+ exports.exportStyle = () => {
+ styleExporter.exportData()
+ }
+
+ exports.importStyle = () => {
+ styleImporter.importData()
+ }
+
+ exports.applyStyle = () => {
+ store.dispatch('setStyleCustom', exportRules.value)
+ }
+
+ const overallPreviewRules = ref([])
+ exports.overallPreviewRules = overallPreviewRules
+
+ const overallPreviewCssRules = ref([])
+ watchEffect(throttle(() => {
+ try {
+ overallPreviewCssRules.value = getScopedVersion(
+ getCssRules(overallPreviewRules.value),
+ '#edited-style-preview'
+ ).join('\n')
+ } catch (e) {
+ console.error(e)
+ }
+ }, 500))
+
+ exports.overallPreviewCssRules = overallPreviewCssRules
+
+ const updateOverallPreview = throttle(() => {
+ try {
+ overallPreviewRules.value = init({
+ inputRuleset: [
+ ...exportRules.value,
+ {
+ component: 'Root',
+ directives: Object.fromEntries(
+ Object
+ .entries(selectedPalette.value)
+ .filter(([k, v]) => k && v && k !== 'name')
+ .map(([k, v]) => [`--${k}`, `color | ${v}`])
+ )
+ }
+ ],
+ ultimateBackgroundColor: '#000000',
+ debug: true
+ }).eager
+ } catch (e) {
+ console.error('Could not compile preview theme', e)
+ return null
+ }
+ }, 5000)
+ //
+ // Apart from "hover" we can't really show how component looks like in
+ // certain states, so we have to fake them.
+ const simulatePseudoSelectors = (css, prefix) => css
+ .replace(prefix, '.component-preview .preview-block')
+ .replace(':active', '.preview-active')
+ .replace(':hover', '.preview-hover')
+ .replace(':active', '.preview-active')
+ .replace(':focus', '.preview-focus')
+ .replace(':focus-within', '.preview-focus-within')
+ .replace(':disabled', '.preview-disabled')
+
+ const previewRules = computed(() => {
+ const filtered = overallPreviewRules.value.filter(r => {
+ const componentMatch = r.component === selectedComponentName.value
+ const parentComponentMatch = r.parent?.component === selectedComponentName.value
+ if (!componentMatch && !parentComponentMatch) return false
+ const rule = parentComponentMatch ? r.parent : r
+ if (rule.component !== selectedComponentName.value) return false
+ if (rule.variant !== selectedVariant.value) return false
+ const ruleState = new Set(rule.state.filter(x => x !== 'normal'))
+ const differenceA = [...ruleState].filter(x => !selectedState.has(x))
+ const differenceB = [...selectedState].filter(x => !ruleState.has(x))
+ return (differenceA.length + differenceB.length) === 0
+ })
+ const sorted = [...filtered]
+ .filter(x => x.component === selectedComponentName.value)
+ .sort((a, b) => {
+ const aSelectorLength = a.selector.split(/ /g).length
+ const bSelectorLength = b.selector.split(/ /g).length
+ return aSelectorLength - bSelectorLength
+ })
+
+ const prefix = sorted[0].selector
+
+ return filtered.filter(x => x.selector.startsWith(prefix))
+ })
+
+ exports.previewClass = computed(() => {
+ const selectors = []
+ if (!!selectedComponent.value.variants?.normal || selectedVariant.value !== 'normal') {
+ selectors.push(selectedComponent.value.variants[selectedVariant.value])
+ }
+ if (selectedState.size > 0) {
+ selectedState.forEach(state => {
+ const original = selectedComponent.value.states[state]
+ selectors.push(simulatePseudoSelectors(original))
+ })
+ }
+ return selectors.map(x => x.substring(1)).join('')
+ })
+
+ exports.previewCss = computed(() => {
+ try {
+ const prefix = previewRules.value[0].selector
+ const scoped = getCssRules(previewRules.value).map(x => simulatePseudoSelectors(x, prefix))
+ return scoped.join('\n')
+ } catch (e) {
+ console.error('Invalid ruleset', e)
+ return null
+ }
+ })
+
+ const dynamicVars = computed(() => {
+ return previewRules.value[0].dynamicVars
+ })
+
+ const staticVars = computed(() => {
+ const rootComponent = overallPreviewRules.value.find(r => {
+ return r.component === 'Root'
+ })
+ const rootDirectivesEntries = Object.entries(rootComponent.directives)
+ const directives = {}
+ rootDirectivesEntries
+ .filter(([k, v]) => k.startsWith('--') && v.startsWith('color | '))
+ .map(([k, v]) => [k.substring(2), v.substring('color | '.length)])
+ .forEach(([k, v]) => {
+ directives[k] = findColor(v, { dynamicVars: {}, staticVars: directives })
+ })
+ return directives
+ })
+ provide('staticVars', staticVars)
+ exports.staticVars = staticVars
+
+ const previewColors = computed(() => {
+ const stacked = dynamicVars.value.stacked
+ const background = typeof stacked === 'string' ? stacked : rgb2hex(stacked)
+ return {
+ text: previewRules.value.find(r => r.component === 'Text')?.virtualDirectives['--text'],
+ link: previewRules.value.find(r => r.component === 'Link')?.virtualDirectives['--link'],
+ border: previewRules.value.find(r => r.component === 'Border')?.virtualDirectives['--border'],
+ icon: previewRules.value.find(r => r.component === 'Icon')?.virtualDirectives['--icon'],
+ background
+ }
+ })
+ exports.previewColors = previewColors
+ exports.updateOverallPreview = updateOverallPreview
+
+ updateOverallPreview()
+
+ watch(
+ [
+ allEditedRules.value,
+ palettes,
+ selectedPalette,
+ selectedState,
+ selectedVariant
+ ],
+ updateOverallPreview
+ )
+
+ return exports
+ }
+}
diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.scss b/src/components/settings_modal/tabs/style_tab/style_tab.scss
new file mode 100644
index 00000000..a9c35c08
--- /dev/null
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.scss
@@ -0,0 +1,264 @@
+.StyleTab {
+ .style-control {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ margin-bottom: 0.5em;
+
+ .label {
+ margin-right: 0.5em;
+ flex: 1 1 0;
+ line-height: 2;
+ min-height: 2em;
+ }
+
+ &.suboption {
+ margin-left: 1em;
+ }
+
+ .color-input {
+ flex: 0 0 0;
+ }
+
+ input,
+ select {
+ min-width: 3em;
+ margin: 0;
+ flex: 0;
+
+ &[type="number"] {
+ min-width: 9em;
+
+ &.-small {
+ min-width: 5em;
+ }
+ }
+
+ &[type="range"] {
+ flex: 1;
+ min-width: 9em;
+ align-self: center;
+ margin: 0 0.25em;
+ }
+
+ &[type="checkbox"] + i {
+ height: 1.1em;
+ align-self: center;
+ }
+ }
+ }
+
+ .meta-preview {
+ display: grid;
+ grid-template:
+ "meta meta preview preview"
+ "meta meta preview preview"
+ "meta meta preview preview"
+ "meta meta preview preview";
+ grid-gap: 0.5em;
+ grid-template-columns: min-content min-content 6fr max-content;
+
+ ul.setting-list {
+ padding: 0;
+ margin: 0;
+ display: grid;
+ grid-template-rows: subgrid;
+ grid-area: meta;
+
+ > li {
+ margin: 0;
+ }
+
+ .meta-field {
+ margin: 0;
+
+ .setting-label {
+ display: inline-block;
+ margin-bottom: 0.5em;
+ }
+ }
+ }
+
+ #edited-style-preview {
+ grid-area: preview;
+ }
+ }
+
+ .setting-item {
+ padding-bottom: 0;
+
+ .btn {
+ padding: 0 0.5em;
+ }
+
+ &:not(:first-child) {
+ margin-top: 0.5em;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 0.5em;
+ }
+ }
+
+ .list-editor {
+ display: grid;
+ grid-template-areas:
+ "label editor"
+ "selector editor"
+ "movement editor";
+ grid-template-columns: 10em 1fr;
+ grid-template-rows: auto 1fr auto;
+ grid-gap: 0.5em;
+
+ .list-edit-area {
+ grid-area: editor;
+ }
+
+ .list-select {
+ grid-area: selector;
+ margin: 0;
+
+ &-label {
+ font-weight: bold;
+ grid-area: label;
+ margin: 0;
+ align-self: baseline;
+ }
+
+ &-movement {
+ grid-area: movement;
+ margin: 0;
+ }
+ }
+ }
+
+ .palette-editor {
+ width: min-content;
+
+ .list-edit-area {
+ display: grid;
+ align-self: baseline;
+ grid-template-rows: subgrid;
+ grid-template-columns: 1fr;
+ }
+
+ .palette-editor-single {
+ grid-row: 2 / span 2;
+ }
+ }
+
+ .variables-editor {
+ .variable-selector {
+ display: grid;
+ grid-template-columns: auto 1fr auto 10em;
+ grid-template-rows: subgrid;
+ align-items: baseline;
+ grid-gap: 0 0.5em;
+ }
+
+ .list-edit-area {
+ display: grid;
+ grid-template-rows: subgrid;
+ }
+
+ .shadow-control {
+ grid-row: 2 / span 2;
+ }
+ }
+
+ .component-editor {
+ display: grid;
+ grid-template-columns: 6fr 3fr 4fr;
+ grid-template-rows: auto auto 1fr;
+ grid-gap: 0.5em;
+ grid-template-areas:
+ "component component variant"
+ "state state state"
+ "preview settings settings";
+
+ .component-selector {
+ grid-area: component;
+ align-self: center;
+ }
+
+ .component-selector,
+ .state-selector,
+ .variant-selector {
+ display: grid;
+ grid-template-columns: 1fr minmax(1fr, 10em);
+ grid-template-rows: auto;
+ grid-auto-flow: column;
+ grid-gap: 0.5em;
+ align-items: baseline;
+
+ > label:not(.Select) {
+ font-weight: bold;
+ justify-self: right;
+ }
+ }
+
+ .state-selector {
+ grid-area: state;
+ grid-template-columns: minmax(min-content, 7em) 1fr;
+ }
+
+ .variant-selector {
+ grid-area: variant;
+ }
+
+ .state-selector-list {
+ display: grid;
+ list-style: none;
+ grid-auto-flow: dense;
+ grid-template-columns: repeat(5, minmax(min-content, 1fr));
+ grid-auto-rows: 1fr;
+ grid-gap: 0.5em;
+ padding: 0;
+ margin: 0;
+ }
+
+ .preview-container {
+ --border: none;
+ --shadow: none;
+ --roundness: none;
+
+ grid-area: preview;
+ }
+
+ .component-settings {
+ grid-area: settings;
+ }
+
+ .editor-tab {
+ display: grid;
+ grid-template-columns: 1fr 2em;
+ grid-column-gap: 0.5em;
+ align-items: center;
+ grid-auto-rows: min-content;
+ grid-auto-flow: dense;
+ border-left: 1px solid var(--border);
+ border-right: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ padding: 0.5em;
+ }
+
+ .shadow-tab {
+ grid-template-columns: 1fr;
+ justify-items: center;
+ }
+ }
+}
+
+.extra-content {
+ .style-actions-container {
+ width: 100%;
+ display: flex;
+ justify-content: end;
+
+ .style-actions {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(7em, 1fr));
+ grid-gap: 0.25em;
+ }
+ }
+}
diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.vue b/src/components/settings_modal/tabs/style_tab/style_tab.vue
new file mode 100644
index 00000000..68a0193f
--- /dev/null
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.vue
@@ -0,0 +1,383 @@
+
+
+
+
+
+
{{ $t('settings.style.themes3.editor.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ updateSelectedStates(state, v)"
+ >
+ {{ state }}
+
+
+
+
+
+
+
+
+ updateSubShadow(axis, value)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('settings.style.themes3.editor.include_in_rule') }}
+
+
+
+
+
+
+
+
+
selectedPaletteId = e"
+ />
+
+
+ {{ $t('settings.style.themes3.palette.name_label') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.js b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.js
new file mode 100644
index 00000000..d98dea61
--- /dev/null
+++ b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.js
@@ -0,0 +1,132 @@
+import { ref, computed, watch, inject } from 'vue'
+
+import Select from 'src/components/select/select.vue'
+import SelectMotion from 'src/components/select/select_motion.vue'
+import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
+import ColorInput from 'src/components/color_input/color_input.vue'
+
+import { serializeShadow } from 'src/services/theme_data/iss_serializer.js'
+
+// helper for debugging
+// eslint-disable-next-line no-unused-vars
+const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
+
+export default {
+ components: {
+ Select,
+ SelectMotion,
+ ShadowControl,
+ ColorInput
+ },
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ setup (props, context) {
+ const exports = {}
+ const emit = context.emit
+
+ exports.emit = emit
+ exports.computeColor = inject('computeColor')
+ exports.staticVars = inject('staticVars')
+
+ const selectedVirtualDirectiveId = ref(0)
+ exports.selectedVirtualDirectiveId = selectedVirtualDirectiveId
+
+ const selectedVirtualDirective = computed({
+ get () {
+ return props.modelValue[selectedVirtualDirectiveId.value]
+ },
+ set (value) {
+ const newVD = [...props.modelValue]
+ newVD[selectedVirtualDirectiveId.value] = value
+
+ emit('update:modelValue', newVD)
+ }
+ })
+ exports.selectedVirtualDirective = selectedVirtualDirective
+
+ exports.selectedVirtualDirectiveValType = computed({
+ get () {
+ return props.modelValue[selectedVirtualDirectiveId.value].valType
+ },
+ set (value) {
+ const newValType = value
+ let newValue
+ switch (value) {
+ case 'shadow':
+ newValue = '0 0 0 #000000 / 1'
+ break
+ case 'color':
+ newValue = '#000000'
+ break
+ default:
+ newValue = 'none'
+ }
+ const newName = props.modelValue[selectedVirtualDirectiveId.value].name
+ props.modelValue[selectedVirtualDirectiveId.value] = {
+ name: newName,
+ value: newValue,
+ valType: newValType
+ }
+ }
+ })
+
+ const draftVirtualDirectiveValid = ref(true)
+ const draftVirtualDirective = ref({})
+ exports.draftVirtualDirective = draftVirtualDirective
+ const normalizeShadows = inject('normalizeShadows')
+
+ watch(
+ selectedVirtualDirective,
+ (directive) => {
+ switch (directive.valType) {
+ case 'shadow': {
+ if (Array.isArray(directive.value)) {
+ draftVirtualDirective.value = normalizeShadows(directive.value)
+ } else {
+ const splitShadow = directive.value.split(/,/g).map(x => x.trim())
+ draftVirtualDirective.value = normalizeShadows(splitShadow)
+ }
+ break
+ }
+ case 'color':
+ draftVirtualDirective.value = directive.value
+ break
+ default:
+ draftVirtualDirective.value = directive.value
+ break
+ }
+ },
+ { immediate: true }
+ )
+
+ watch(
+ draftVirtualDirective,
+ (directive) => {
+ try {
+ switch (selectedVirtualDirective.value.valType) {
+ case 'shadow': {
+ props.modelValue[selectedVirtualDirectiveId.value].value =
+ directive.map(x => serializeShadow(x)).join(', ')
+ break
+ }
+ default:
+ props.modelValue[selectedVirtualDirectiveId.value].value = directive
+ }
+ draftVirtualDirectiveValid.value = true
+ } catch (e) {
+ console.error('Invalid virtual directive value', e)
+ draftVirtualDirectiveValid.value = false
+ }
+ },
+ { immediate: true }
+ )
+
+ exports.getNewVirtualDirective = () => ({
+ name: 'newDirective',
+ valType: 'generic',
+ value: 'foobar'
+ })
+
+ return exports
+ }
+}
diff --git a/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue
new file mode 100644
index 00000000..cf3345e5
--- /dev/null
+++ b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
emit('update:modelValue', e)"
+ :selected-id="selectedVirtualDirectiveId"
+ @update:selectedId="e => selectedVirtualDirectiveId = e"
+ :get-add-value="getNewVirtualDirective"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 64de28bc..e534a777 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -4,9 +4,6 @@ import {
getContrastRatioLayers,
relativeLuminance
} from 'src/services/color_convert/color_convert.js'
-import {
- getThemes
-} from 'src/services/style_setter/style_setter.js'
import {
newImporter,
newExporter
@@ -123,31 +120,24 @@ export default {
}
},
created () {
- const self = this
+ const currentIndex = this.$store.state.instance.themesIndex
- getThemes()
- .then((promises) => {
- return Promise.all(
- Object.entries(promises)
- .map(([k, v]) => v.then(res => [k, res]))
- )
- })
- .then(themes => themes.reduce((acc, [k, v]) => {
- if (v) {
- return {
- ...acc,
- [k]: v
- }
- } else {
- return acc
- }
- }, {}))
- .then((themesComplete) => {
- self.availableStyles = themesComplete
- })
+ let promise
+ if (currentIndex) {
+ promise = Promise.resolve(currentIndex)
+ } else {
+ promise = this.$store.dispatch('fetchThemesIndex')
+ }
+
+ promise.then(themesIndex => {
+ Object
+ .values(themesIndex)
+ .forEach(themeFunc => {
+ themeFunc().then(themeData => this.availableStyles.push(themeData))
+ })
+ })
},
mounted () {
- this.loadThemeFromLocalStorage()
if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0]
}
@@ -305,6 +295,9 @@ export default {
return {}
}
},
+ themeDataUsed () {
+ return this.$store.state.interface.themeDataUsed
+ },
shadowsAvailable () {
return Object.keys(DEFAULT_SHADOWS).sort()
},
@@ -412,9 +405,6 @@ export default {
forceUseSource = false
) {
this.dismissWarning()
- if (!source && !theme) {
- throw new Error('Can\'t load theme: empty')
- }
const version = (origin === 'localStorage' && !theme.colors)
? 'l1'
: fileVersion
@@ -490,22 +480,11 @@ export default {
this.dismissWarning()
},
loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) {
- const {
- customTheme: theme,
- customThemeSource: source
- } = this.$store.getters.mergedConfig
- if (!theme && !source) {
- // Anon user or never touched themes
- this.loadTheme(
- this.$store.state.instance.themeData,
- 'defaults',
- confirmLoadSource
- )
- } else {
+ const theme = this.themeDataUsed?.source
+ if (theme) {
this.loadTheme(
{
- theme,
- source: forceSnapshot ? theme : source
+ theme
},
'localStorage',
confirmLoadSource
@@ -724,6 +703,9 @@ export default {
}
},
watch: {
+ themeDataUsed () {
+ this.loadThemeFromLocalStorage()
+ },
currentRadii () {
try {
this.previewTheme.radii = generateRadii({ radii: this.currentRadii }).theme.radii
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
index e86e61da..c2e33e75 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -45,12 +45,16 @@
flex: 0;
&[type="number"] {
- min-width: 5em;
+ min-width: 9em;
+
+ &.-small {
+ min-width: 5em;
+ }
}
&[type="range"] {
flex: 1;
- min-width: 2em;
+ min-width: 9em;
align-self: center;
margin: 0 0.5em;
}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 00a55832..886d9776 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -187,14 +187,14 @@
name="accentColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.accent')"
- :show-optional-tickbox="typeof linkColorLocal !== 'undefined'"
+ :show-optional-checkbox="typeof linkColorLocal !== 'undefined'"
/>
@@ -957,6 +957,8 @@
v-model="currentShadow"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
:fallback="currentShadowFallback"
+ :static-vars="previewTheme.colors"
+ :compact="true"
/>
({
- x: 0,
- y: 0,
- blur: 0,
- spread: 0,
- inset: false,
- color: '#000000',
- alpha: 1,
- ...object
-})
+const toModel = (input) => {
+ if (typeof input === 'object') {
+ return {
+ x: 0,
+ y: 0,
+ blur: 0,
+ spread: 0,
+ inset: false,
+ color: '#000000',
+ alpha: 1,
+ ...input
+ }
+ } else if (typeof input === 'string') {
+ return input
+ }
+}
export default {
props: [
- 'modelValue', 'fallback', 'separateInset', 'noPreview', 'disabled'
+ 'modelValue',
+ 'fallback',
+ 'separateInset',
+ 'noPreview',
+ 'disabled',
+ 'staticVars',
+ 'compact'
],
emits: ['update:modelValue', 'subShadowSelected'],
data () {
return {
selectedId: 0,
- // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason)
- cValue: (this.modelValue ?? this.fallback ?? []).map(toModel)
+ invalid: false
}
},
components: {
ColorInput,
OpacityInput,
Select,
+ SelectMotion,
Checkbox,
Popover,
ComponentPreview
},
- beforeUpdate () {
- this.cValue = (this.modelValue ?? this.fallback ?? []).map(toModel)
- },
computed: {
- selected () {
- const selected = this.cValue[this.selectedId]
- if (selected) {
- return { ...selected }
+ cValue: {
+ get () {
+ return (this.modelValue ?? this.fallback ?? []).map(toModel)
+ },
+ set (newVal) {
+ this.$emit('update:modelValue', newVal)
+ }
+ },
+ selectedType: {
+ get () {
+ return typeof this.selected
+ },
+ set (newType) {
+ this.selected = toModel(newType === 'object' ? {} : '')
+ }
+ },
+ selected: {
+ get () {
+ const selected = this.cValue[this.selectedId]
+ if (selected && typeof selected === 'object') {
+ return { ...selected }
+ } else if (typeof selected === 'string') {
+ return selected
+ }
+ return null
+ },
+ set (value) {
+ this.cValue[this.selectedId] = toModel(value)
+ this.$emit('update:modelValue', this.cValue)
}
- return null
},
present () {
- return this.selected != null && !this.usingFallback
+ return this.selected != null && this.modelValue != null
},
shadowsAreNull () {
return this.modelValue == null
@@ -72,24 +110,43 @@ export default {
currentFallback () {
return this.fallback?.[this.selectedId]
},
- moveUpValid () {
- return this.selectedId > 0
- },
- moveDnValid () {
- return this.selectedId < this.cValue.length - 1
- },
- usingFallback () {
- return this.modelValue == null
+ getColorFallback () {
+ if (this.staticVars && this.selected?.color) {
+ try {
+ const computedColor = findColor(this.selected.color, { dynamicVars: {}, staticVars: this.staticVars }, true)
+ if (computedColor) return rgb2hex(computedColor)
+ return null
+ } catch (e) {
+ console.warn(e)
+ return null
+ }
+ } else {
+ return this.currentFallback?.color
+ }
},
style () {
- if (this.separateInset) {
- return {
- filter: getCssShadowFilter(this.cValue),
- boxShadow: getCssShadow(this.cValue, true)
+ try {
+ let result
+ const serialized = this.cValue.map(x => serializeShadow(x)).join(',')
+ serialized.split(/,/).map(deserializeShadow) // validate
+ const expandedShadow = flattenDeep(findShadow(this.cValue, { dynamicVars: {}, staticVars: this.staticVars }))
+ const fixedShadows = expandedShadow.map(x => ({ ...x, color: console.log(x) || rgb2hex(x.color) }))
+
+ if (this.separateInset) {
+ result = {
+ filter: getCssShadowFilter(fixedShadows),
+ boxShadow: getCssShadow(fixedShadows, true)
+ }
+ } else {
+ result = {
+ boxShadow: getCssShadow(fixedShadows)
+ }
}
- }
- return {
- boxShadow: getCssShadow(this.cValue)
+ this.invalid = false
+ return result
+ } catch (e) {
+ console.error('Invalid shadow', e)
+ this.invalid = true
}
}
},
@@ -99,34 +156,25 @@ export default {
}
},
methods: {
+ getNewSubshadow () {
+ return toModel(this.selected)
+ },
+ onSelectChange (id) {
+ this.selectedId = id
+ },
+ getSubshadowLabel (shadow, index) {
+ if (typeof shadow === 'object') {
+ return shadow?.name ?? this.$t('settings.style.shadows.shadow_id', { value: index })
+ } else if (typeof shadow === 'string') {
+ return shadow || this.$t('settings.style.shadows.empty_expression')
+ }
+ },
updateProperty: throttle(function (prop, value) {
this.cValue[this.selectedId][prop] = value
if (prop === 'inset' && value === false && this.separateInset) {
this.cValue[this.selectedId].spread = 0
}
this.$emit('update:modelValue', this.cValue)
- }, 100),
- add () {
- this.cValue.push(toModel(this.selected))
- this.selectedId = Math.max(this.cValue.length - 1, 0)
- this.$emit('update:modelValue', this.cValue)
- },
- del () {
- this.cValue.splice(this.selectedId, 1)
- this.selectedId = this.cValue.length === 0 ? undefined : Math.max(this.selectedId - 1, 0)
- this.$emit('update:modelValue', this.cValue)
- },
- moveUp () {
- const movable = this.cValue.splice(this.selectedId, 1)[0]
- this.cValue.splice(this.selectedId - 1, 0, movable)
- this.selectedId -= 1
- this.$emit('update:modelValue', this.cValue)
- },
- moveDn () {
- const movable = this.cValue.splice(this.selectedId, 1)[0]
- this.cValue.splice(this.selectedId + 1, 0, movable)
- this.selectedId += 1
- this.$emit('update:modelValue', this.cValue)
- }
+ }, 100)
}
}
diff --git a/src/components/shadow_control/shadow_control.scss b/src/components/shadow_control/shadow_control.scss
index dd049023..7e998cdf 100644
--- a/src/components/shadow_control/shadow_control.scss
+++ b/src/components/shadow_control/shadow_control.scss
@@ -1,11 +1,29 @@
-.settings-modal .settings-modal-panel .shadow-control {
- display: flex;
- flex-wrap: wrap;
+.ShadowControl {
+ display: grid;
+ grid-template-columns: 10em 1fr 1fr;
+ grid-template-rows: 1fr;
+ grid-template-areas: "selector preview tweak";
+ grid-gap: 0.5em;
justify-content: stretch;
- grid-gap: 0.25em;
- margin-bottom: 1em;
+
+ &.-compact {
+ grid-template-columns: 10em 1fr;
+ grid-template-rows: auto auto;
+ grid-template-areas:
+ "selector preview"
+ "tweak tweak";
+
+ &.-no-preview {
+ grid-template-columns: 1fr;
+ grid-template-rows: 10em 1fr;
+ grid-template-areas:
+ "selector"
+ "tweak";
+ }
+ }
.shadow-switcher {
+ grid-area: selector;
order: 1;
flex: 1 0 6em;
min-width: 6em;
@@ -16,27 +34,18 @@
.shadow-list {
flex: 1 0 auto;
}
-
- .arrange-buttons {
- flex: 0 0 auto;
- display: grid;
- grid-auto-columns: 1fr;
- grid-auto-flow: column;
- margin-top: 0.25em;
-
- .button-default {
- margin: 0;
- padding: 0;
- }
- }
}
.shadow-tweak {
+ grid-area: tweak;
order: 3;
flex: 2 0 10em;
min-width: 10em;
margin-left: 0.125em;
margin-right: 0.125em;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ grid-gap: 0.25em;
/* hack */
.input-boolean {
@@ -52,6 +61,11 @@
flex: 1 0 5em;
}
+ .shadow-expression {
+ width: 100%;
+ height: 100%;
+ }
+
.id-control {
align-items: stretch;
@@ -69,6 +83,10 @@
}
&.-no-preview {
+ grid-template-columns: 10em 1fr;
+ grid-template-rows: 1fr;
+ grid-template-areas: "selector tweak";
+
.shadow-tweak {
order: 0;
flex: 2 0 8em;
@@ -91,15 +109,14 @@
}
.shadow-preview {
- order: 2;
- flex: 3 3 15em;
- min-width: 10em;
+ grid-area: preview;
+ min-width: 25em;
margin-left: 0.125em;
align-self: start;
+ justify-self: center;
}
}
.inset-tooltip {
- padding: 0.5em;
max-width: 30em;
}
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index e1d20191..d5fb80dc 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -1,10 +1,11 @@
-
-
-
-
-
-
+
-
-
-
+ {{ $t('settings.style.shadows.raw') }}
+
+
+
+
+
+
+
+ updateProperty('name', e.target.value)"
+ class="name-control style-control"
>
-
-
- updateProperty('inset', e.target.checked)"
- >
-
- {{ $t('settings.style.shadows.inset') }}
-
-
-
-
-
- updateProperty('blur', e.target.value)"
- >
- updateProperty('blur', e.target.value)"
- >
-
-
-
- updateProperty('spread', e.target.value)"
- >
- updateProperty('spread', e.target.value)"
- >
-
- updateProperty('color', e)"
- />
- updateProperty('alpha', e)"
- />
-
- --variable,mod
-
-
-
-
-
-
- {{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }}
-
-
-
-
-
+ updateProperty('name', e.target.value)"
+ >
+
+
+ updateProperty('inset', e.target.checked)"
+ >
+
+ {{ $t('settings.style.shadows.inset') }}
+
+
+
+
+
+ updateProperty('blur', e.target.value)"
+ >
+ updateProperty('blur', e.target.value)"
+ >
+
+
+
+ updateProperty('spread', e.target.value)"
+ >
+ updateProperty('spread', e.target.value)"
+ >
+
+ updateProperty('color', e)"
+ />
+ updateProperty('alpha', e)"
+ />
+
+ --variable,mod
+
+
+
+
-
filter: drop-shadow()
-
-
{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}
-
- drop-shadow
- spread-radius
- inset
-
-
- box-shadow
-
-
{{ $t('settings.style.shadows.filter_hint.spread_zero') }}
-
-
-
+
+
+ {{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }}
+
+
+
+
+
+
+
diff --git a/src/components/tab_switcher/tab.style.js b/src/components/tab_switcher/tab.style.js
index eac8aaeb..814d3304 100644
--- a/src/components/tab_switcher/tab.style.js
+++ b/src/components/tab_switcher/tab.style.js
@@ -14,14 +14,14 @@ export default {
{
directives: {
background: '--fg',
- shadow: ['--defaultButtonShadow', '--defaultButtonBevel'],
+ shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3
}
},
{
state: ['hover'],
directives: {
- shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel']
+ shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel']
}
},
{
@@ -33,14 +33,14 @@ export default {
{
state: ['hover', 'active'],
directives: {
- shadow: ['--defaultButtonShadow', '--defaultButtonBevel']
+ shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel']
}
},
{
state: ['disabled'],
directives: {
- background: '$blend(--inheritedBackground, 0.25, --parent)',
- shadow: ['--defaultButtonBevel']
+ background: '$blend(--inheritedBackground 0.25 --parent)',
+ shadow: ['--buttonDefaultBevel']
}
},
{
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index 4e242b91..b78cb732 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -119,7 +119,7 @@
.tab {
flex: 1;
box-sizing: content-box;
- min-width: 10em;
+ max-width: 9em;
min-width: 1px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -128,12 +128,22 @@
margin-right: -200px;
margin-left: 1em;
+ &:not(.active) {
+ margin-top: 0;
+ margin-left: 1.5em;
+ }
+
@media all and (max-width: 800px) {
padding-left: 0.25em;
padding-right: calc(0.25em + 200px);
margin-right: calc(0.25em - 200px);
margin-left: 0.25em;
+ &:not(.active) {
+ margin-top: 0;
+ margin-left: 0.5em;
+ }
+
.text {
display: none;
}
@@ -181,6 +191,7 @@
&:not(.active) {
z-index: 4;
+ margin-top: 0.25em;
&:hover {
z-index: 6;
diff --git a/src/components/tooltip/tooltip.vue b/src/components/tooltip/tooltip.vue
new file mode 100644
index 00000000..db0a8bef
--- /dev/null
+++ b/src/components/tooltip/tooltip.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ {{ props.text }}
+
+
+
+
+
+
+
+
diff --git a/src/components/user_card/user_card.style.js b/src/components/user_card/user_card.style.js
index 34eaa176..3d5bb298 100644
--- a/src/components/user_card/user_card.style.js
+++ b/src/components/user_card/user_card.style.js
@@ -1,6 +1,7 @@
export default {
name: 'UserCard',
selector: '.user-card',
+ notEditable: true,
validInnerComponents: [
'Text',
'Link',
@@ -25,7 +26,7 @@ export default {
color: '#000000',
alpha: 0.6
}],
- '--profileTint': 'color | $alpha(--background, 0.5)'
+ '--profileTint': 'color | $alpha(--background 0.5)'
}
},
{
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c86bdbab..7febbf9f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -701,6 +701,7 @@
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
+ "theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
@@ -750,11 +751,81 @@
"more_settings": "More settings",
"style": {
"custom_theme_used": "(Custom theme)",
+ "custom_style_used": "(Custom style)",
+ "stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
+ "palette": {
+ "label": "Color schemes",
+ "name_label": "Color scheme name",
+ "import": "Import palette",
+ "export": "Export palette",
+ "apply": "Apply palette",
+ "bg": "Panel background",
+ "fg": "Buttons etc.",
+ "text": "Text",
+ "link": "Links",
+ "accent": "Accent color",
+ "cRed": "Red color",
+ "cBlue": "Blue color",
+ "cGreen": "Green color",
+ "cOrange": "Orange color",
+ "wallpaper": "Wallpaper",
+ "v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes",
+ "bundled": "Bundled palettes",
+ "style": "Palettes provided by selected style",
+ "user": "Custom palette",
+ "imported": "Imported"
+ },
+ "editor": {
+ "title": "Style editor",
+ "reset_style": "Reset",
+ "load_style": "Open from file",
+ "save_style": "Save",
+ "style_name": "Stylesheet name",
+ "style_author": "Made by",
+ "style_license": "License",
+ "style_website": "Website",
+ "component_selector": "Component",
+ "variant_selector": "Variant",
+ "states_selector": "States",
+ "main_tab": "Main",
+ "shadows_tab": "Shadows",
+ "background": "Background color",
+ "text_color": "Text color",
+ "icon_color": "Icon color",
+ "link_color": "Link color",
+ "contrast": "Text contrast",
+ "roundness": "Roundness",
+ "opacity": "Opacity",
+ "border_color": "Border color",
+ "include_in_rule": "Add to rule",
+ "test_string": "TEST",
+ "invalid": "Invalid",
+ "refresh_preview": "Refresh preview",
+ "apply_preview": "Apply",
+ "text_auto": {
+ "label": "Auto-contrast",
+ "no-preserve": "Black or White",
+ "preserve": "Keep color",
+ "no-auto": "Disabled"
+ },
+ "component_tab": "Components style",
+ "palette_tab": "Color schemes",
+ "variables_tab": "Variables (Advanced)",
+ "variables": {
+ "label": "Variables",
+ "name_label": "Name:",
+ "type_label": "Type:",
+ "type_shadow": "Shadow",
+ "type_color": "Color",
+ "type_generic": "Generic",
+ "virtual_color": "Variable color value"
+ }
+ },
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
@@ -877,11 +948,18 @@
"override": "Override",
"shadow_id": "Shadow #{value}",
"offset": "Shadow offset",
+ "zoom": "Zoom",
+ "offset-x": "x:",
+ "offset-y": "y:",
"light_grid": "Use light checkerboard",
+ "color_override": "Use different color",
"name": "Name",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
+ "raw": "Plain shadow",
+ "expression": "Expression (advanced)",
+ "empty_expression": "Empty expression",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
diff --git a/src/modules/config.js b/src/modules/config.js
index 2a2ee2be..c92a730d 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -47,6 +47,10 @@ export const defaultState = {
customThemeSource: undefined, // "source", stores original theme data
// V3
+ style: null,
+ styleCustomData: null,
+ palette: null,
+ paletteCustomData: null,
themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions
forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
theme3hacks: { // Hacks, user overrides that are independent of theme used
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 1396b052..23d022c6 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -42,6 +42,9 @@ const defaultState = {
registrationOpen: true,
server: 'http://localhost:4040/',
textlimit: 5000,
+ themesIndex: undefined,
+ stylesIndex: undefined,
+ palettesIndex: undefined,
themeData: undefined, // used for theme editor v2
vapidPublicKey: undefined,
@@ -96,6 +99,8 @@ const defaultState = {
sidebarRight: false,
subjectLineBehavior: 'email',
theme: 'pleroma-dark',
+ palette: null,
+ style: null,
emojiReactionsScale: 0.5,
textSize: '14px',
emojiSize: '2.2rem',
diff --git a/src/modules/interface.js b/src/modules/interface.js
index 57bfe0c6..442b482a 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -1,10 +1,23 @@
-import { getPreset, applyTheme, tryLoadCache } from '../services/style_setter/style_setter.js'
+import { getResourcesIndex, applyTheme, tryLoadCache } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION, generatePreset } from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
+import { deserialize } from '../services/theme_data/iss_deserializer.js'
+
+// helper for debugging
+// eslint-disable-next-line no-unused-vars
+const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
const defaultState = {
localFonts: null,
themeApplied: false,
+ themeVersion: 'v3',
+ styleNameUsed: null,
+ styleDataUsed: null,
+ useStylePalette: false, // hack for applying styles from appearance tab
+ paletteNameUsed: null,
+ paletteDataUsed: null,
+ themeNameUsed: null,
+ themeDataUsed: null,
temporaryChangesTimeoutId: null, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {}, // used for applying temporary options
temporaryChangesRevert: () => {}, // used for reverting temporary options
@@ -212,142 +225,450 @@ const interfaceMod = {
setLastTimeline ({ commit }, value) {
commit('setLastTimeline', value)
},
- setTheme ({ commit, rootState }, { themeName, themeData, recompile, saveData } = {}) {
+ async fetchPalettesIndex ({ commit, state }) {
+ try {
+ const value = await getResourcesIndex('/static/palettes/index.json')
+ commit('setInstanceOption', { name: 'palettesIndex', value })
+ return value
+ } catch (e) {
+ console.error('Could not fetch palettes index', e)
+ commit('setInstanceOption', { name: 'palettesIndex', value: { _error: e } })
+ return Promise.resolve({})
+ }
+ },
+ setPalette ({ dispatch, commit }, value) {
+ dispatch('resetThemeV3Palette')
+ dispatch('resetThemeV2')
+
+ commit('setOption', { name: 'palette', value })
+
+ dispatch('applyTheme', { recompile: true })
+ },
+ setPaletteCustom ({ dispatch, commit }, value) {
+ dispatch('resetThemeV3Palette')
+ dispatch('resetThemeV2')
+
+ commit('setOption', { name: 'paletteCustomData', value })
+
+ dispatch('applyTheme', { recompile: true })
+ },
+ async fetchStylesIndex ({ commit, state }) {
+ try {
+ const value = await getResourcesIndex(
+ '/static/styles/index.json',
+ deserialize
+ )
+ commit('setInstanceOption', { name: 'stylesIndex', value })
+ return value
+ } catch (e) {
+ console.error('Could not fetch styles index', e)
+ commit('setInstanceOption', { name: 'stylesIndex', value: { _error: e } })
+ return Promise.resolve({})
+ }
+ },
+ setStyle ({ dispatch, commit, state }, value) {
+ dispatch('resetThemeV3')
+ dispatch('resetThemeV2')
+ dispatch('resetThemeV3Palette')
+
+ commit('setOption', { name: 'style', value })
+ state.useStylePalette = true
+
+ dispatch('applyTheme', { recompile: true }).then(() => {
+ state.useStylePalette = false
+ })
+ },
+ setStyleCustom ({ dispatch, commit, state }, value) {
+ dispatch('resetThemeV3')
+ dispatch('resetThemeV2')
+ dispatch('resetThemeV3Palette')
+
+ commit('setOption', { name: 'styleCustomData', value })
+
+ state.useStylePalette = true
+ dispatch('applyTheme', { recompile: true }).then(() => {
+ state.useStylePalette = false
+ })
+ },
+ async fetchThemesIndex ({ commit, state }) {
+ try {
+ const value = await getResourcesIndex('/static/styles.json')
+ commit('setInstanceOption', { name: 'themesIndex', value })
+ return value
+ } catch (e) {
+ console.error('Could not fetch themes index', e)
+ commit('setInstanceOption', { name: 'themesIndex', value: { _error: e } })
+ return Promise.resolve({})
+ }
+ },
+ setTheme ({ dispatch, commit }, value) {
+ dispatch('resetThemeV3')
+ dispatch('resetThemeV3Palette')
+ dispatch('resetThemeV2')
+
+ commit('setOption', { name: 'theme', value })
+
+ dispatch('applyTheme', { recompile: true })
+ },
+ setThemeCustom ({ dispatch, commit }, value) {
+ dispatch('resetThemeV3')
+ dispatch('resetThemeV3Palette')
+ dispatch('resetThemeV2')
+
+ commit('setOption', { name: 'customTheme', value })
+ commit('setOption', { name: 'customThemeSource', value })
+
+ dispatch('applyTheme', { recompile: true })
+ },
+ resetThemeV3 ({ dispatch, commit }) {
+ commit('setOption', { name: 'style', value: null })
+ commit('setOption', { name: 'styleCustomData', value: null })
+ },
+ resetThemeV3Palette ({ dispatch, commit }) {
+ commit('setOption', { name: 'palette', value: null })
+ commit('setOption', { name: 'paletteCustomData', value: null })
+ },
+ resetThemeV2 ({ dispatch, commit }) {
+ commit('setOption', { name: 'theme', value: null })
+ commit('setOption', { name: 'customTheme', value: null })
+ commit('setOption', { name: 'customThemeSource', value: null })
+ },
+ async getThemeData ({ dispatch, commit, rootState, state }) {
+ const getData = async (resource, index, customData, name) => {
+ const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
+ const result = {}
+
+ if (customData) {
+ result.nameUsed = 'custom' // custom data overrides name
+ result.dataUsed = customData
+ } else {
+ result.nameUsed = name
+
+ if (result.nameUsed == null) {
+ result.dataUsed = null
+ return result
+ }
+
+ let fetchFunc = index[result.nameUsed]
+ // Fallbacks
+ if (!fetchFunc) {
+ if (resource === 'style' || resource === 'palette') {
+ return result
+ }
+ const newName = Object.keys(index)[0]
+ fetchFunc = index[newName]
+ console.warn(`${capitalizedResource} with id '${state.styleNameUsed}' not found, trying back to '${newName}'`)
+ if (!fetchFunc) {
+ console.warn(`${capitalizedResource} doesn't have a fallback, defaulting to stock.`)
+ fetchFunc = () => Promise.resolve(null)
+ }
+ }
+ result.dataUsed = await fetchFunc()
+ }
+ return result
+ }
+
const {
- theme: instanceThemeName
+ style: instanceStyleName,
+ palette: instancePaletteName
+ } = rootState.instance
+
+ let {
+ theme: instanceThemeV2Name,
+ themesIndex,
+ stylesIndex,
+ palettesIndex
} = rootState.instance
const {
- theme: userThemeName,
- customTheme: userThemeSnapshot,
- customThemeSource: userThemeSource,
+ style: userStyleName,
+ styleCustomData: userStyleCustomData,
+ palette: userPaletteName,
+ paletteCustomData: userPaletteCustomData
+ } = rootState.config
+
+ let {
+ theme: userThemeV2Name,
+ customTheme: userThemeV2Snapshot,
+ customThemeSource: userThemeV2Source
+ } = rootState.config
+
+ let majorVersionUsed
+
+ console.debug(
+ `User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`
+ )
+ console.debug(
+ `User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`
+ )
+
+ console.debug(`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`)
+ console.debug('Instance V2 theme: ' + instanceThemeV2Name)
+
+ if (userPaletteName || userPaletteCustomData ||
+ userStyleName || userStyleCustomData ||
+ (
+ // User V2 overrides instance V3
+ (instancePaletteName ||
+ instanceStyleName) &&
+ instanceThemeV2Name == null &&
+ userThemeV2Name == null
+ )
+ ) {
+ // Palette and/or style overrides V2 themes
+ instanceThemeV2Name = null
+ userThemeV2Name = null
+ userThemeV2Source = null
+ userThemeV2Snapshot = null
+
+ majorVersionUsed = 'v3'
+ } else if (
+ (userThemeV2Name ||
+ userThemeV2Snapshot ||
+ userThemeV2Source ||
+ instanceThemeV2Name)
+ ) {
+ majorVersionUsed = 'v2'
+ } else {
+ // if all fails fallback to v3
+ majorVersionUsed = 'v3'
+ }
+
+ if (majorVersionUsed === 'v3') {
+ const result = await Promise.all([
+ dispatch('fetchPalettesIndex'),
+ dispatch('fetchStylesIndex')
+ ])
+
+ palettesIndex = result[0]
+ stylesIndex = result[1]
+ } else {
+ // Promise.all just to be uniform with v3
+ const result = await Promise.all([
+ dispatch('fetchThemesIndex')
+ ])
+
+ themesIndex = result[0]
+ }
+
+ state.themeVersion = majorVersionUsed
+
+ console.debug('Version used', majorVersionUsed)
+
+ if (majorVersionUsed === 'v3') {
+ state.themeDataUsed = null
+ state.themeNameUsed = null
+
+ const style = await getData(
+ 'style',
+ stylesIndex,
+ userStyleCustomData,
+ userStyleName || instanceStyleName
+ )
+ state.styleNameUsed = style.nameUsed
+ state.styleDataUsed = style.dataUsed
+
+ let firstStylePaletteName = null
+ style
+ .dataUsed
+ ?.filter(x => x.component === '@palette')
+ .map(x => {
+ const cleanDirectives = Object.fromEntries(
+ Object
+ .entries(x.directives)
+ .filter(([k, v]) => k)
+ )
+
+ return { name: x.variant, ...cleanDirectives }
+ })
+ .forEach(palette => {
+ const key = 'style.' + palette.name.toLowerCase().replace(/ /g, '_')
+ if (!firstStylePaletteName) firstStylePaletteName = key
+ palettesIndex[key] = () => Promise.resolve(palette)
+ })
+
+ const palette = await getData(
+ 'palette',
+ palettesIndex,
+ userPaletteCustomData,
+ state.useStylePalette ? firstStylePaletteName : (userPaletteName || instancePaletteName)
+ )
+
+ if (state.useStylePalette) {
+ commit('setOption', { name: 'palette', value: firstStylePaletteName })
+ }
+
+ state.paletteNameUsed = palette.nameUsed
+ state.paletteDataUsed = palette.dataUsed
+
+ if (state.paletteDataUsed) {
+ state.paletteDataUsed.link = state.paletteDataUsed.link || state.paletteDataUsed.accent
+ state.paletteDataUsed.accent = state.paletteDataUsed.accent || state.paletteDataUsed.link
+ }
+ if (Array.isArray(state.paletteDataUsed)) {
+ const [
+ name,
+ bg,
+ fg,
+ text,
+ link,
+ cRed = '#FF0000',
+ cGreen = '#00FF00',
+ cBlue = '#0000FF',
+ cOrange = '#E3FF00'
+ ] = palette.dataUsed
+ state.paletteDataUsed = {
+ name,
+ bg,
+ fg,
+ text,
+ link,
+ accent: link,
+ cRed,
+ cBlue,
+ cGreen,
+ cOrange
+ }
+ }
+ console.debug('Palette data used', palette.dataUsed)
+ } else {
+ state.styleNameUsed = null
+ state.styleDataUsed = null
+ state.paletteNameUsed = null
+ state.paletteDataUsed = null
+
+ const theme = await getData(
+ 'theme',
+ themesIndex,
+ userThemeV2Source || userThemeV2Snapshot,
+ userThemeV2Name || instanceThemeV2Name
+ )
+ state.themeNameUsed = theme.nameUsed
+ state.themeDataUsed = theme.dataUsed
+ }
+ },
+ async applyTheme (
+ { dispatch, commit, rootState, state },
+ { recompile = false } = {}
+ ) {
+ const {
forceThemeRecompilation,
themeDebug,
theme3hacks
} = rootState.config
-
- const actualThemeName = userThemeName || instanceThemeName
-
- const forceRecompile = forceThemeRecompilation || recompile
-
- let promise = null
-
- if (themeData) {
- promise = Promise.resolve(normalizeThemeData(themeData))
- } else if (themeName) {
- promise = getPreset(themeName).then(themeData => normalizeThemeData(themeData))
- } else if (userThemeSource || userThemeSnapshot) {
- promise = Promise.resolve(normalizeThemeData({
- _pleroma_theme_version: 2,
- theme: userThemeSnapshot,
- source: userThemeSource
- }))
- } else if (actualThemeName && actualThemeName !== 'custom') {
- promise = getPreset(actualThemeName).then(themeData => {
- const realThemeData = normalizeThemeData(themeData)
- if (actualThemeName === instanceThemeName) {
- // This sole line is the reason why this whole block is above the recompilation check
- commit('setInstanceOption', { name: 'themeData', value: { theme: realThemeData } })
- }
- return realThemeData
- })
- } else {
- throw new Error('Cannot load any theme!')
- }
-
// If we're not not forced to recompile try using
// cache (tryLoadCache return true if load successful)
- if (!forceRecompile && !themeDebug && tryLoadCache()) {
- commit('setThemeApplied')
- return
+
+ const forceRecompile = forceThemeRecompilation || recompile
+ if (!forceRecompile && !themeDebug && await tryLoadCache()) {
+ return commit('setThemeApplied')
}
+ await dispatch('getThemeData')
- promise
- .then(realThemeData => {
- const theme2ruleset = convertTheme2To3(realThemeData)
+ const paletteIss = (() => {
+ if (!state.paletteDataUsed) return null
+ const result = {
+ component: 'Root',
+ directives: {}
+ }
- if (saveData) {
- commit('setOption', { name: 'theme', value: themeName || actualThemeName })
- commit('setOption', { name: 'customTheme', value: realThemeData })
- commit('setOption', { name: 'customThemeSource', value: realThemeData })
- }
- const hacks = []
-
- Object.entries(theme3hacks).forEach(([key, value]) => {
- switch (key) {
- case 'fonts': {
- Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
- if (!font?.family) return
- switch (fontKey) {
- case 'interface':
- hacks.push({
- component: 'Root',
- directives: {
- '--font': 'generic | ' + font.family
- }
- })
- break
- case 'input':
- hacks.push({
- component: 'Input',
- directives: {
- '--font': 'generic | ' + font.family
- }
- })
- break
- case 'post':
- hacks.push({
- component: 'RichContent',
- directives: {
- '--font': 'generic | ' + font.family
- }
- })
- break
- case 'monospace':
- hacks.push({
- component: 'Root',
- directives: {
- '--monoFont': 'generic | ' + font.family
- }
- })
- break
- }
- })
+ Object
+ .entries(state.paletteDataUsed)
+ .filter(([k]) => k !== 'name')
+ .forEach(([k, v]) => {
+ let issRootDirectiveName
+ switch (k) {
+ case 'background':
+ issRootDirectiveName = 'bg'
break
- }
- case 'underlay': {
- if (value !== 'none') {
- const newRule = {
- component: 'Underlay',
- directives: {}
- }
- if (value === 'opaque') {
- newRule.directives.opacity = 1
- newRule.directives.background = '--wallpaper'
- }
- if (value === 'transparent') {
- newRule.directives.opacity = 0
- }
- hacks.push(newRule)
- }
+ case 'foreground':
+ issRootDirectiveName = 'fg'
break
- }
+ default:
+ issRootDirectiveName = k
}
+ result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
+ return result
+ })()
- const ruleset = [
- ...theme2ruleset,
- ...hacks
- ]
+ const theme2ruleset = state.themeDataUsed && convertTheme2To3(normalizeThemeData(state.themeDataUsed))
+ const hacks = []
- applyTheme(
- ruleset,
- () => commit('setThemeApplied'),
- themeDebug
- )
- })
+ Object.entries(theme3hacks).forEach(([key, value]) => {
+ switch (key) {
+ case 'fonts': {
+ Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
+ if (!font?.family) return
+ switch (fontKey) {
+ case 'interface':
+ hacks.push({
+ component: 'Root',
+ directives: {
+ '--font': 'generic | ' + font.family
+ }
+ })
+ break
+ case 'input':
+ hacks.push({
+ component: 'Input',
+ directives: {
+ '--font': 'generic | ' + font.family
+ }
+ })
+ break
+ case 'post':
+ hacks.push({
+ component: 'RichContent',
+ directives: {
+ '--font': 'generic | ' + font.family
+ }
+ })
+ break
+ case 'monospace':
+ hacks.push({
+ component: 'Root',
+ directives: {
+ '--monoFont': 'generic | ' + font.family
+ }
+ })
+ break
+ }
+ })
+ break
+ }
+ case 'underlay': {
+ if (value !== 'none') {
+ const newRule = {
+ component: 'Underlay',
+ directives: {}
+ }
+ if (value === 'opaque') {
+ newRule.directives.opacity = 1
+ newRule.directives.background = '--wallpaper'
+ }
+ if (value === 'transparent') {
+ newRule.directives.opacity = 0
+ }
+ hacks.push(newRule)
+ }
+ break
+ }
+ }
+ })
- return promise
+ const rulesetArray = [
+ theme2ruleset,
+ state.styleDataUsed,
+ paletteIss,
+ hacks
+ ].filter(x => x)
+
+ return applyTheme(
+ rulesetArray.flat(),
+ () => commit('setThemeApplied'),
+ themeDebug
+ )
}
}
}
@@ -355,19 +676,6 @@ const interfaceMod = {
export default interfaceMod
export const normalizeThemeData = (input) => {
- if (Array.isArray(input)) {
- const themeData = { colors: {} }
- themeData.colors.bg = input[1]
- themeData.colors.fg = input[2]
- themeData.colors.text = input[3]
- themeData.colors.link = input[4]
- themeData.colors.cRed = input[5]
- themeData.colors.cGreen = input[6]
- themeData.colors.cBlue = input[7]
- themeData.colors.cOrange = input[8]
- return generatePreset(themeData).theme
- }
-
let themeData, themeSource
if (input.themeFileVerison === 1) {
@@ -381,7 +689,10 @@ export const normalizeThemeData = (input) => {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
- } else if (Object.prototype.hasOwnProperty.call(input, 'themeEngineVersion')) {
+ } else if (
+ Object.prototype.hasOwnProperty.call(input, 'themeEngineVersion') ||
+ Object.prototype.hasOwnProperty.call(input, 'colors')
+ ) {
// We got passed a source/snapshot
themeData = input
themeSource = input
diff --git a/src/services/export_import/export_import.js b/src/services/export_import/export_import.js
index 7fee0ad3..348a9d00 100644
--- a/src/services/export_import/export_import.js
+++ b/src/services/export_import/export_import.js
@@ -2,15 +2,23 @@ import utf8 from 'utf8'
export const newExporter = ({
filename = 'data',
+ mime = 'application/json',
+ extension = '.json',
getExportedObject
}) => ({
exportData () {
- const stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
+ let stringified
+ if (mime === 'application/json') {
+ stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
+ } else {
+ stringified = utf8.encode(getExportedObject()) // Pretty-print and indent with 2 spaces
+ }
// Create an invisible link with a data url and simulate a click
const e = document.createElement('a')
- e.setAttribute('download', `${filename}.json`)
- e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
+ const realFilename = typeof filename === 'function' ? filename() : filename
+ e.setAttribute('download', `${realFilename}.${extension}`)
+ e.setAttribute('href', `data:${mime};base64, ${window.btoa(stringified)}`)
e.style.display = 'none'
document.body.appendChild(e)
@@ -20,6 +28,8 @@ export const newExporter = ({
})
export const newImporter = ({
+ accept = '.json',
+ parser = (string) => JSON.parse(string),
onImport,
onImportFailure,
validator = () => true
@@ -27,18 +37,19 @@ export const newImporter = ({
importData () {
const filePicker = document.createElement('input')
filePicker.setAttribute('type', 'file')
- filePicker.setAttribute('accept', '.json')
+ filePicker.setAttribute('accept', accept)
filePicker.addEventListener('change', event => {
if (event.target.files[0]) {
+ const filename = event.target.files[0].name
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
try {
- const parsed = JSON.parse(target.result)
- const validationResult = validator(parsed)
+ const parsed = parser(target.result, filename)
+ const validationResult = validator(parsed, filename)
if (validationResult === true) {
- onImport(parsed)
+ onImport(parsed, filename)
} else {
onImportFailure({ validationResult })
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index c1603f39..b5014ff1 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,8 +1,9 @@
-import { hex2rgb } from '../color_convert/color_convert.js'
import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
import { getCssRules } from '../theme_data/css_utils.js'
import { defaultState } from '../../modules/config.js'
import { chunk } from 'lodash'
+import pako from 'pako'
+import localforage from 'localforage'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
@@ -52,29 +53,12 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
const themes3 = init({
inputRuleset,
- // Assuming that "worst case scenario background" is panel background since it's the most likely one
- ultimateBackgroundColor: inputRuleset[0].directives['--bg'].split('|')[1].trim(),
debug
})
getCssRules(themes3.eager, debug).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)
- }
+ onNewRule(rule, false)
})
onEagerFinished()
@@ -88,22 +72,7 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
const chunk = chunks[counter]
Promise.all(chunk.map(x => x())).then(result => {
getCssRules(result.filter(x => x), debug).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)
- }
+ onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
@@ -120,12 +89,15 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
return { lazyProcessFunc: processChunk }
}
-export const tryLoadCache = () => {
- const json = localStorage.getItem('pleroma-fe-theme-cache')
- if (!json) return null
+export const tryLoadCache = async () => {
+ console.info('Trying to load compiled theme data from cache')
+ const data = await localforage.getItem('pleromafe-theme-cache')
+ if (!data) return null
let cache
try {
- cache = JSON.parse(json)
+ const decoded = new TextDecoder().decode(pako.inflate(data))
+ cache = JSON.parse(decoded)
+ console.info(`Loaded theme from cache, size=${cache}`)
} catch (e) {
console.error('Failed to decode theme cache:', e)
return false
@@ -150,16 +122,28 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
+ const insertRule = (styles, rule) => {
+ if (rule.indexOf('webkit') >= 0) {
+ try {
+ styles.sheet.insertRule(rule, 'index-max')
+ styles.rules.push(rule)
+ } catch (e) {
+ console.warn('Can\'t insert rule due to lack of support', e)
+ }
+ } else {
+ styles.sheet.insertRule(rule, 'index-max')
+ styles.rules.push(rule)
+ }
+ }
+
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule (rule, isLazy) {
if (isLazy) {
- lazyStyles.sheet.insertRule(rule, 'index-max')
- lazyStyles.rules.push(rule)
+ insertRule(lazyStyles, rule)
} else {
- eagerStyles.sheet.insertRule(rule, 'index-max')
- eagerStyles.rules.push(rule)
+ insertRule(eagerStyles, rule)
}
},
onEagerFinished () {
@@ -169,16 +153,10 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
adoptStyleSheets([eagerStyles, lazyStyles])
const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] }
onFinish(cache)
- try {
- localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
- } catch (e) {
- localStorage.removeItem('pleroma-fe-theme-cache')
- try {
- localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
- } catch (e) {
- console.warn('cannot save cache!', e)
- }
+ const compress = (js) => {
+ return pako.deflate(JSON.stringify(js))
}
+ localforage.setItem('pleromafe-theme-cache', compress(cache))
}
},
debug
@@ -252,64 +230,66 @@ export const applyConfig = (input, i18n) => {
styleSheet.toString()
styleSheet.insertRule(`:root { ${rules} }`, 'index-max')
+ // TODO find a way to make this not apply to theme previews
if (Object.prototype.hasOwnProperty.call(config, 'forcedRoundness')) {
- styleSheet.insertRule(` * {
+ styleSheet.insertRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`, 'index-max')
}
}
-export const getThemes = () => {
+export const getResourcesIndex = async (url, parser = JSON.parse) => {
const cache = 'no-store'
+ const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
+ let builtin
+ let custom
- return window.fetch('/static/styles.json', { cache })
- .then((data) => data.json())
- .then((themes) => {
- return Object.entries(themes).map(([k, v]) => {
- let promise = null
+ const resourceTransform = (resources) => {
+ return Object
+ .entries(resources)
+ .map(([k, v]) => {
if (typeof v === 'object') {
- promise = Promise.resolve(v)
+ return [k, () => Promise.resolve(v)]
} else if (typeof v === 'string') {
- promise = window.fetch(v, { cache })
- .then((data) => data.json())
- .catch((e) => {
- console.error(e)
- return null
- })
+ return [
+ k,
+ () => window
+ .fetch(v, { cache })
+ .then(data => data.text())
+ .then(text => parser(text))
+ .catch(e => {
+ console.error(e)
+ return null
+ })
+ ]
+ } else {
+ console.error(`Unknown resource format - ${k} is a ${typeof v}`)
+ return [k, null]
}
- return [k, promise]
})
- })
- .then((promises) => {
- return promises
- .reduce((acc, [k, v]) => {
- acc[k] = v
- return acc
- }, {})
- })
-}
-
-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 }
- }
-
- return { theme: data, source: theme.source }
- })
+ }
+
+ try {
+ const builtinData = await window.fetch(url, { cache })
+ const builtinResources = await builtinData.json()
+ builtin = resourceTransform(builtinResources)
+ } catch (e) {
+ builtin = []
+ console.warn(`Builtin resources at ${url} unavailable`)
+ }
+
+ try {
+ const customData = await window.fetch(customUrl, { cache })
+ const customResources = await customData.json()
+ custom = resourceTransform(customResources)
+ } catch (e) {
+ custom = []
+ console.warn(`Custom resources at ${customUrl} unavailable`)
+ }
+
+ const total = [...custom, ...builtin]
+ if (total.length === 0) {
+ return Promise.reject(new Error(`Resource at ${url} and ${customUrl} completely unavailable. Panicking`))
+ }
+ return Promise.resolve(Object.fromEntries(total))
}
diff --git a/src/services/theme_data/css_utils.js b/src/services/theme_data/css_utils.js
index 9bce4834..48935ea0 100644
--- a/src/services/theme_data/css_utils.js
+++ b/src/services/theme_data/css_utils.js
@@ -2,25 +2,6 @@ import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
-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) => {
@@ -84,6 +65,9 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
].join(';\n ')
}
case 'shadow': {
+ if (!rule.dynamicVars.shadow) {
+ return ''
+ }
return ' ' + [
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow),
@@ -98,7 +82,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
`
}
if (v === 'transparent') {
- if (rule.component === 'Root') return []
+ if (rule.component === 'Root') return null
return [
rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '',
' --background: ' + v
@@ -130,7 +114,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
}
default:
if (k.startsWith('--')) {
- const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme!
+ const [type, value] = v.split('|').map(x => x.trim())
switch (type) {
case 'color': {
const color = rule.dynamicVars[k]
@@ -143,21 +127,20 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
case 'generic':
return k + ': ' + value
default:
- return ''
+ return null
}
}
- return ''
+ return null
}
- }).filter(x => x).map(x => ' ' + x).join(';\n')
+ }).filter(x => x).map(x => ' ' + x + ';').join('\n')
return [
header,
- directives + ';',
+ directives,
(rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '',
- '',
virtualDirectives,
footer
- ].join('\n')
+ ].filter(x => x).join('\n')
}).filter(x => x)
export const getScopedVersion = (rules, newScope) => {
diff --git a/src/services/theme_data/iss_deserializer.js b/src/services/theme_data/iss_deserializer.js
index 909e9411..55a596a5 100644
--- a/src/services/theme_data/iss_deserializer.js
+++ b/src/services/theme_data/iss_deserializer.js
@@ -1,10 +1,11 @@
import { flattenDeep } from 'lodash'
-const parseShadow = string => {
- const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha']
+export const deserializeShadow = string => {
+ const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha', 'name']
const regexPrep = [
// inset keyword (optional)
- '^(?:(inset)\\s+)?',
+ '^',
+ '(?:(inset)\\s+)?',
// x
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
// y
@@ -14,19 +15,31 @@ const parseShadow = string => {
// spread (optional)
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
// either hex, variable or function
- '(#[0-9a-f]{6}|--[a-z\\-_]+|\\$[a-z\\-()_]+)',
+ '(#[0-9a-f]{6}|--[a-z0-9\\-_]+|\\$[a-z0-9\\-()_ ]+)',
// opacity (optional)
- '(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?$'
+ '(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?',
+ // name
+ '(?:\\s+#(\\w+)\\s*)?',
+ '$'
].join('')
const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string
const result = regex.exec(string)
if (result == null) {
- return string
+ if (string.startsWith('$') || string.startsWith('--')) {
+ return string
+ } else {
+ throw new Error(`Invalid shadow definition: '${string}'`)
+ }
} else {
const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha'])
- const { x, y, blur, spread, alpha, inset, color } = Object.fromEntries(modes.map((mode, i) => {
+ const { x, y, blur, spread, alpha, inset, color, name } = Object.fromEntries(modes.map((mode, i) => {
if (numeric.has(mode)) {
- return [mode, Number(result[i])]
+ const number = Number(result[i])
+ if (Number.isNaN(number)) {
+ if (mode === 'alpha') return [mode, 1]
+ return [mode, 0]
+ }
+ return [mode, number]
} else if (mode === 'inset') {
return [mode, !!result[i]]
} else {
@@ -34,7 +47,7 @@ const parseShadow = string => {
}
}).filter(([k, v]) => v !== false).slice(1))
- return { x, y, blur, spread, color, alpha, inset }
+ return { x, y, blur, spread, color, alpha, inset, name }
}
}
// this works nearly the same as HTML tree converter
@@ -136,12 +149,12 @@ export const deserialize = (input) => {
output.directives = Object.fromEntries(content.map(d => {
const [property, value] = d.split(':')
- let realValue = value.trim()
+ let realValue = (value || '').trim()
if (property === 'shadow') {
if (realValue === 'none') {
realValue = []
} else {
- realValue = value.split(',').map(v => parseShadow(v.trim()))
+ realValue = value.split(',').map(v => deserializeShadow(v.trim()))
}
} if (!Number.isNaN(Number(value))) {
realValue = Number(value)
diff --git a/src/services/theme_data/iss_serializer.js b/src/services/theme_data/iss_serializer.js
index 8b7cf5d8..9e553eab 100644
--- a/src/services/theme_data/iss_serializer.js
+++ b/src/services/theme_data/iss_serializer.js
@@ -1,8 +1,13 @@
import { unroll } from './iss_utils.js'
+import { deserializeShadow } from './iss_deserializer.js'
-const serializeShadow = s => {
+export const serializeShadow = (s, throwOnInvalid) => {
if (typeof s === 'object') {
- return `${s.inset ? 'inset ' : ''}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}`
+ const inset = s.inset ? 'inset ' : ''
+ const name = s.name ? ` #${s.name} ` : ''
+ const result = `${inset}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}${name}`
+ deserializeShadow(result) // Verify that output is valid and parseable
+ return result
} else {
return s
}
diff --git a/src/services/theme_data/iss_utils.js b/src/services/theme_data/iss_utils.js
index 75f8dd93..a0914ee2 100644
--- a/src/services/theme_data/iss_utils.js
+++ b/src/services/theme_data/iss_utils.js
@@ -56,43 +56,74 @@ export const getAllPossibleCombinations = (array) => {
*
* @returns {String} CSS selector (or path)
*/
-export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => {
+export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, liteMode, children) => {
+ const isParent = !!children
if (!rule && !isParent) return null
const component = components[rule.component]
- const { states = {}, variants = {}, selector, outOfTreeSelector } = component
+ const { states = {}, variants = {}, outOfTreeSelector } = component
- const applicableStates = ((rule.state || []).filter(x => x !== 'normal')).map(state => states[state])
+ const expand = (array = [], subArray = []) => {
+ if (array.length === 0) return subArray.map(x => [x])
+ if (subArray.length === 0) return array.map(x => [x])
+ return array.map(a => {
+ return subArray.map(b => [a, b])
+ }).flat()
+ }
+
+ let componentSelectors = Array.isArray(component.selector) ? component.selector : [component.selector]
+ if (ignoreOutOfTreeSelector || liteMode) componentSelectors = [componentSelectors[0]]
+ componentSelectors = componentSelectors.map(selector => {
+ if (selector === ':root') {
+ return ''
+ } else if (isParent) {
+ return selector
+ } else {
+ if (outOfTreeSelector && !ignoreOutOfTreeSelector) return outOfTreeSelector
+ return selector
+ }
+ })
const applicableVariantName = (rule.variant || 'normal')
- let applicableVariant = ''
+ let variantSelectors = null
if (applicableVariantName !== 'normal') {
- applicableVariant = variants[applicableVariantName]
+ variantSelectors = variants[applicableVariantName]
} else {
- applicableVariant = variants?.normal ?? ''
+ variantSelectors = variants?.normal ?? ''
}
+ variantSelectors = Array.isArray(variantSelectors) ? variantSelectors : [variantSelectors]
+ if (ignoreOutOfTreeSelector || liteMode) variantSelectors = [variantSelectors[0]]
- let realSelector
- if (selector === ':root') {
- realSelector = ''
- } else if (isParent) {
- realSelector = selector
- } else {
- if (outOfTreeSelector && !ignoreOutOfTreeSelector) realSelector = outOfTreeSelector
- else realSelector = selector
- }
+ const applicableStates = (rule.state || []).filter(x => x !== 'normal')
+ // const applicableStates = (rule.state || [])
+ const statesSelectors = applicableStates.map(state => {
+ const selector = states[state] || ''
+ let arraySelector = Array.isArray(selector) ? selector : [selector]
+ if (ignoreOutOfTreeSelector || liteMode) arraySelector = [arraySelector[0]]
+ arraySelector
+ .sort((a, b) => {
+ if (a.startsWith(':')) return 1
+ if (/^[a-z]/.exec(a)) return -1
+ else return 0
+ })
+ .join('')
+ return arraySelector
+ })
- const selectors = [realSelector, applicableVariant, ...applicableStates]
- .sort((a, b) => {
- if (a.startsWith(':')) return 1
- if (/^[a-z]/.exec(a)) return -1
- else return 0
- })
- .join('')
+ const statesSelectorsFlat = statesSelectors.reduce((acc, s) => {
+ return expand(acc, s).map(st => st.join(''))
+ }, [])
+
+ const componentVariant = expand(componentSelectors, variantSelectors).map(cv => cv.join(''))
+ const componentVariantStates = expand(componentVariant, statesSelectorsFlat).map(cvs => cvs.join(''))
+ const selectors = expand(componentVariantStates, children).map(cvsc => cvsc.join(' '))
+ /*
+ */
if (rule.parent) {
- return (genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, true) + ' ' + selectors).trim()
+ return genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, liteMode, selectors)
}
- return selectors.trim()
+
+ return selectors.join(', ').trim()
}
/**
diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js
index bcc0c961..9de1aca3 100644
--- a/src/services/theme_data/theme2_to_theme3.js
+++ b/src/services/theme_data/theme2_to_theme3.js
@@ -354,10 +354,6 @@ export const convertTheme2To3 = (data) => {
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'] })
}
diff --git a/src/services/theme_data/theme3_slot_functions.js b/src/services/theme_data/theme3_slot_functions.js
index 074a88f0..ae644763 100644
--- a/src/services/theme_data/theme3_slot_functions.js
+++ b/src/services/theme_data/theme3_slot_functions.js
@@ -3,7 +3,7 @@ import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/co
export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => {
const { funcName, argsString } = /\$(?
\w+)\((?[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups
- const args = argsString.split(/,/g).map(a => a.trim())
+ const args = argsString.split(/ /g).map(a => a.trim())
const func = functions[funcName]
if (args.length < func.argsNeeded) {
@@ -15,6 +15,11 @@ export const process = (text, functions, { findColor, findShadow }, { dynamicVar
export const colorFunctions = {
alpha: {
argsNeeded: 2,
+ documentation: 'Changes alpha value of the color only to be used for CSS variables',
+ args: [
+ 'color: source color used',
+ 'amount: alpha value'
+ ],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [color, amountArg] = args
@@ -23,8 +28,32 @@ export const colorFunctions = {
return { ...colorArg, a: amount }
}
},
+ brightness: {
+ argsNeeded: 2,
+ document: 'Changes brightness/lightness of color in HSL colorspace',
+ args: [
+ 'color: source color used',
+ 'amount: lightness value'
+ ],
+ exec: (args, { findColor }, { dynamicVars, staticVars }) => {
+ const [color, amountArg] = args
+
+ const colorArg = convert(findColor(color, { dynamicVars, staticVars })).hsl
+ colorArg.l += Number(amountArg)
+ return { ...convert(colorArg).rgb }
+ }
+ },
textColor: {
argsNeeded: 2,
+ documentation: 'Get text color with adequate contrast for given background and intended text color. Same function is used internally',
+ args: [
+ 'background: color of backdrop where text will be shown',
+ 'foreground: intended text color',
+ `[preserve]: (optional) intended color preservation:
+'preserve' - try to preserve the color
+'no-preserve' - if can't get adequate color - fall back to black or white
+'no-auto' - don't do anything (useless as a color function)`
+ ],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [backgroundArg, foregroundArg, preserve = 'preserve'] = args
@@ -36,6 +65,12 @@ export const colorFunctions = {
},
blend: {
argsNeeded: 3,
+ documentation: 'Alpha blending between two colors',
+ args: [
+ 'background: bottom layer color',
+ 'amount: opacity of top layer',
+ 'foreground: upper layer color'
+ ],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [backgroundArg, amountArg, foregroundArg] = args
@@ -48,6 +83,11 @@ export const colorFunctions = {
},
mod: {
argsNeeded: 2,
+ documentation: 'Old function that increases or decreases brightness depending if color is dark or light. Advised against using it as it might give unexpected results.',
+ args: [
+ 'color: source color',
+ 'amount: how much darken/brighten the color'
+ ],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [colorArg, amountArg] = args
@@ -65,6 +105,13 @@ export const colorFunctions = {
export const shadowFunctions = {
borderSide: {
argsNeeded: 3,
+ documentation: 'Simulate a border on a side with a shadow, best works on inset border',
+ args: [
+ 'color: border color',
+ 'side: string indicating on which side border should be, takes either one word or two words joined by dash (i.e. "left" or "bottom-right")',
+ '[alpha]: (Optional) border opacity, defaults to 1 (fully opaque)',
+ '[inset]: (Optional) whether border should be on the inside or outside, defaults to inside'
+ ],
exec: (args, { findColor }) => {
const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 3c2f8a63..9de47a7d 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -22,7 +22,7 @@ import {
normalizeCombination,
findRules
} from './iss_utils.js'
-import { parseCssShadow } from './css_utils.js'
+import { deserializeShadow } from './iss_deserializer.js'
// Ensuring the order of components
const components = {
@@ -37,18 +37,18 @@ const components = {
ChatMessage: null
}
-const findShadow = (shadows, { dynamicVars, staticVars }) => {
+export 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)
+ // modifiers are completely unsupported here
+ const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
- targetShadow = parseCssShadow(shadow)
+ targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
@@ -62,54 +62,63 @@ const findShadow = (shadows, { 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
+export const findColor = (color, { dynamicVars, staticVars }) => {
+ try {
+ if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color
+ let targetColor = null
+ if (color.startsWith('--')) {
+ // Modifier support is pretty much for v2 themes only
+ 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 {
- const virtualSlot = variableSlot.replace(/^parent/, '')
- targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb
+ switch (variableSlot) {
+ case 'inheritedBackground':
+ targetColor = convert(dynamicVars.inheritedBackground).rgb
+ break
+ case 'background':
+ targetColor = convert(dynamicVars.background).rgb
+ break
+ default:
+ targetColor = convert(staticVars[variableSlot]).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 (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
+ } catch (e) {
+ throw new Error(`Couldn't find color "${color}", variables are:
+Static:
+${JSON.stringify(staticVars, null, 2)}
+Dynamic:
+${JSON.stringify(dynamicVars, null, 2)}`)
}
-
- 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) => {
@@ -164,19 +173,19 @@ export const getEngineChecksum = () => engineChecksum
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
- * @param {string} rootComponentName - [UNTESTED] which component to start from, meant for previewing a
- * part of the theme (i.e. just the button) for themes 3 editor.
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
+ editMode = false,
onlyNormalState = false,
- rootComponentName = 'Root'
+ initialStaticVars = {}
}) => {
+ const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
- const staticVars = {}
+ const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
@@ -218,8 +227,8 @@ export const init = ({
bScore += b.component === 'Text' ? 1 : 0
// Debug
- a.specifityScore = aScore
- b.specifityScore = bScore
+ a._specificityScore = aScore
+ b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
@@ -228,211 +237,227 @@ export const init = ({
})
.map(({ data }) => data)
+ if (!ultimateBackgroundColor) {
+ console.warn('No ultimate background color provided, falling back to panel color')
+ const rootRule = ruleset.findLast((x) => (x.component === 'Root' && x.directives?.['--bg']))
+ ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
+ }
+
const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name))
+ const nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.name))
const processCombination = (combination) => {
- const selector = ruleToSelector(combination, true)
- const cssSelector = ruleToSelector(combination)
+ try {
+ 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 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 lowerLevelSelector = parentSelector
+ let lowerLevelBackground = computed[lowerLevelSelector]?.background
+ if (editMode && !lowerLevelBackground) {
+ // FIXME hack for editor until it supports handling component backgrounds
+ lowerLevelBackground = '#00FFFF'
+ }
+ 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(),
- ...sortBy(combination.state.filter(x => x !== 'normal')).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 dynamicVars = computed[selector] || {
+ lowerLevelBackground,
+ lowerLevelVirtualDirectives,
+ lowerLevelVirtualDirectivesRaw
}
- 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,
+ // 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
}
- return rule
+ 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(),
+ ...sortBy(combination.state.filter(x => x !== 'normal')).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, { dynamicVars, staticVars })
+ dynamicVars[k] = color
+ if (combination.component === rootComponentName) {
+ staticVars[k.substring(2)] = color
+ }
+ break
+ }
+ case 'shadow': {
+ const shadow = value.split(/,/g).map(s => s.trim()).filter(x => x)
+ dynamicVars[k] = shadow
+ if (combination.component === rootComponentName) {
+ staticVars[k.substring(2)] = shadow
+ }
+ break
+ }
+ case 'generic': {
+ dynamicVars[k] = value
+ if (combination.component === rootComponentName) {
+ staticVars[k.substring(2)] = value
+ }
+ break
+ }
+ }
+ })
+
+ const rule = {
+ dynamicVars,
+ selector: cssSelector,
+ ...combination,
+ directives: computedDirectives
+ }
+
+ return rule
+ }
+ } catch (e) {
+ const { component, variant, state } = combination
+ throw new Error(`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`)
}
}
@@ -443,11 +468,15 @@ export const init = ({
variants: originalVariants = {}
} = component
- const validInnerComponents = (
- liteMode
- ? (component.validInnerComponentsLite || component.validInnerComponents)
- : component.validInnerComponents
- ) || []
+ let validInnerComponents
+ if (editMode) {
+ const temp = (component.validInnerComponentsLite || component.validInnerComponents || [])
+ validInnerComponents = temp.filter(c => virtualComponents.has(c) && !nonEditableComponents.has(c))
+ } else if (liteMode) {
+ validInnerComponents = (component.validInnerComponentsLite || component.validInnerComponents || [])
+ } else {
+ validInnerComponents = component.validInnerComponents || []
+ }
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
@@ -489,7 +518,7 @@ export const init = ({
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
- if (combination.state.indexOf('hover') >= 0) {
+ if (!liteMode && combination.state.indexOf('hover') >= 0) {
combination.lazy = true
}
@@ -538,6 +567,7 @@ export const init = ({
lazy,
eager,
staticVars,
- engineChecksum
+ engineChecksum,
+ themeChecksum: sum([lazy, eager])
}
}
diff --git a/static/.gitignore b/static/.gitignore
new file mode 100644
index 00000000..3332292a
--- /dev/null
+++ b/static/.gitignore
@@ -0,0 +1 @@
+*.custom.*
diff --git a/static/config.json b/static/config.json
index fb39ff77..8a78284a 100644
--- a/static/config.json
+++ b/static/config.json
@@ -24,6 +24,8 @@
"showInstanceSpecificPanel": false,
"sidebarRight": false,
"subjectLineBehavior": "email",
- "theme": "pleroma-dark",
+ "theme": null,
+ "style": null,
+ "palette": null,
"webPushNotifications": false
}
diff --git a/static/palettes/index.json b/static/palettes/index.json
new file mode 100644
index 00000000..9484d0f4
--- /dev/null
+++ b/static/palettes/index.json
@@ -0,0 +1,32 @@
+{
+ "pleroma-dark": [ "Pleroma Dark", "#121a24", "#182230", "#b9b9ba", "#d8a070", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
+ "pleroma-light": [ "Pleroma Light", "#f2f4f6", "#dbe0e8", "#304055", "#f86f0f", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
+ "classic-dark": {
+ "name": "Classic Dark",
+ "bg": "#161c20",
+ "fg": "#282e32",
+ "text": "#b9b9b9",
+ "link": "#baaa9c",
+ "cRed": "#d31014",
+ "cGreen": "#0fa00f",
+ "cBlue": "#0095ff",
+ "cOrange": "#ffa500"
+ },
+ "bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"],
+ "pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"],
+ "tomorrow-night": {
+ "name": "Tomorrow Night",
+ "bg": "#1d1f21",
+ "fg": "#373b41",
+ "link": "#81a2be",
+ "text": "#c5c8c6",
+ "cRed": "#cc6666",
+ "cBlue": "#8abeb7",
+ "cGreen": "#b5bd68",
+ "cOrange": "#de935f",
+ "_cYellow": "#f0c674",
+ "_cPurple": "#b294bb"
+ },
+ "ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ],
+ "monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ]
+}
diff --git a/static/styles.json b/static/styles.json
index 23f57c65..2f836a47 100644
--- a/static/styles.json
+++ b/static/styles.json
@@ -1,12 +1,6 @@
{
"pleroma-dark": "/static/themes/pleroma-dark.json",
"pleroma-light": "/static/themes/pleroma-light.json",
- "pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"],
- "classic-dark": [ "Classic Dark", "#161c20", "#282e32", "#b9b9b9", "#baaa9c", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
- "bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"],
- "ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ],
- "monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ],
-
"redmond-xx": "/static/themes/redmond-xx.json",
"redmond-xx-se": "/static/themes/redmond-xx-se.json",
"redmond-xxi": "/static/themes/redmond-xxi.json",
diff --git a/static/styles/Breezy DX.piss b/static/styles/Breezy DX.piss
new file mode 100644
index 00000000..0203fec2
--- /dev/null
+++ b/static/styles/Breezy DX.piss
@@ -0,0 +1,80 @@
+@meta {
+ name: Breezy DX;
+ author: HJ;
+ license: WTFPL;
+ website: ebin.club;
+}
+
+@palette.Dark {
+ bg: #292C32;
+ fg: #292C32;
+ text: #ffffff;
+ link: #1CA4F3;
+ accent: #1CA4F3;
+ cRed: #f41a51;
+ cBlue: #1CA4F3;
+ cGreen: #1af46e;
+ cOrange: #f4af1a;
+}
+
+@palette.Light {
+ bg: #EFF0F2;
+ fg: #EFF0F2;
+ text: #1B1F22;
+ underlay: #5d6086;
+ accent: #1CA4F3;
+ cBlue: #1CA4F3;
+ cRed: #f41a51;
+ cGreen: #1af46e;
+ cOrange: #f4af1a;
+ border: #d8e6f9;
+ link: #1CA4F3;
+}
+
+Root {
+ --badgeNotification: color | --cRed;
+ --buttonDefaultHoverGlow: shadow | inset 0 0 0 1 --accent / 1;
+ --buttonDefaultFocusGlow: shadow | inset 0 0 0 1 --accent / 1;
+ --buttonDefaultShadow: shadow | inset 0 0 0 1 --text / 0.35, 0 5 5 -5 #000000 / 0.35;
+ --buttonDefaultBevel: shadow | inset 0 14 14 -14 #FFFFFF / 0.1;
+ --buttonPressedBevel: shadow | inset 0 -20 20 -20 #000000 / 0.05;
+ --defaultInputBevel: shadow | inset 0 0 0 1 --text / 0.35;
+ --defaultInputHoverGlow: shadow | 0 0 0 1 --accent / 1;
+ --defaultInputFocusGlow: shadow | 0 0 0 1 --link / 1;
+}
+
+Button:disabled {
+ shadow: --buttonDefaultBevel, --buttonDefaultShadow
+}
+
+Button:hover {
+ shadow: --buttonDefaultHoverGlow, --buttonDefaultBevel, --buttonDefaultShadow
+}
+
+Button:toggled {
+ background: $blend(--bg 0.3 --accent)
+}
+
+Button:pressed {
+ background: $blend(--bg 0.8 --accent)
+}
+
+Button:pressed:toggled {
+ background: $blend(--bg 0.2 --accent)
+}
+
+Button:toggled:hover {
+ background: $blend(--bg 0.3 --accent)
+}
+
+Input {
+ shadow: --defaultInputBevel
+}
+
+PanelHeader {
+ shadow: inset 0 30 30 -30 #ffffff / 0.25
+}
+
+Tab:hover {
+ shadow: --buttonDefaultHoverGlow, --buttonDefaultBevel, --buttonDefaultShadow
+}
diff --git a/static/styles/Redmond DX.piss b/static/styles/Redmond DX.piss
new file mode 100644
index 00000000..7777b7d3
--- /dev/null
+++ b/static/styles/Redmond DX.piss
@@ -0,0 +1,169 @@
+@meta {
+ name: Redmond DX;
+ author: HJ;
+ license: WTFPL;
+ website: ebin.club;
+}
+
+@palette.Modern {
+ bg: #D3CFC7;
+ fg: #092369;
+ text: #000000;
+ link: #0000FF;
+ accent: #A5C9F0;
+ cRed: #FF3000;
+ cBlue: #009EFF;
+ cGreen: #309E00;
+ cOrange: #FFCE00;
+}
+
+@palette.Classic {
+ bg: #BFBFBF;
+ fg: #000180;
+ text: #000000;
+ link: #0000FF;
+ accent: #A5C9F0;
+ cRed: #FF0000;
+ cBlue: #2E2ECE;
+ cGreen: #007E00;
+ cOrange: #CE8F5F;
+}
+
+@palette.Vapor {
+ bg: #F0ADCD;
+ fg: #bca4ee;
+ text: #602040;
+ link: #064745;
+ accent: #9DF7C8;
+ cRed: #86004a;
+ cBlue: #0e5663;
+ cGreen: #0a8b51;
+ cOrange: #787424;
+}
+
+Root {
+ --gradientColor: color | --accent;
+ --inputColor: color | #FFFFFF;
+ --bevelLight: color | $brightness(--bg 50);
+ --bevelDark: color | $brightness(--bg -20);
+ --bevelExtraDark: color | #404040;
+ --buttonDefaultBevel: shadow | $borderSide(--bevelExtraDark bottom-right 1 1), $borderSide(--bevelLight top-left 1 1), $borderSide(--bevelDark bottom-right 1 2);
+ --buttonPressedFocusedBevel: shadow | inset 0 0 0 1 #000000 / 1 #Outer , inset 0 0 0 2 --bevelExtraDark / 1 #inner;
+ --buttonPressedBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2);
+ --defaultInputBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2), $borderSide(--bg bottom-right 1 2);
+}
+
+Button:toggled {
+ background: --bg;
+ shadow: --buttonPressedBevel
+}
+
+Button:focused {
+ shadow: --buttonDefaultBevel, 0 0 0 1 #000000 / 1
+}
+
+Button:pressed {
+ shadow: --buttonPressedBevel
+}
+
+Button:hover {
+ shadow: --buttonDefaultBevel;
+ background: --bg
+}
+
+Button {
+ shadow: --buttonDefaultBevel;
+ background: --bg;
+ roundness: 0
+}
+
+Button:pressed:hover {
+ shadow: --buttonPressedBevel
+}
+
+Button:hover:pressed:focused {
+ shadow: --buttonPressedFocusedBevel
+}
+
+Button:pressed:focused {
+ shadow: --buttonPressedFocusedBevel
+}
+
+Button:toggled:pressed {
+ shadow: --buttonPressedFocusedBevel
+}
+
+Input {
+ background: $mod(--bg -80);
+ shadow: --defaultInputBevel;
+ roundness: 0
+}
+
+Input:focused {
+ shadow: inset 0 0 0 1 #000000 / 1, --defaultInputBevel
+}
+
+Input:focused:hover {
+ shadow: --defaultInputBevel
+}
+
+Input:focused:hover:disabled {
+ shadow: --defaultInputBevel
+}
+
+Input:hover {
+ shadow: --defaultInputBevel
+}
+
+Input:disabled {
+ shadow: --defaultInputBevel
+}
+
+Panel {
+ shadow: --buttonDefaultBevel;
+ roundness: 0
+}
+
+PanelHeader {
+ shadow: inset -1100 0 1000 -1000 --gradientColor / 1 #Gradient ;
+ background: --fg
+}
+
+Tab:hover {
+ background: --bg;
+ shadow: --buttonDefaultBevel
+}
+
+Tab:active {
+ background: --bg
+}
+
+Tab:active:hover {
+ background: --bg;
+ shadow: --defaultButtonBevel
+}
+
+Tab:active:hover:disabled {
+ background: --bg
+}
+
+Tab:hover:disabled {
+ background: --bg
+}
+
+Tab:disabled {
+ background: --bg
+}
+
+Tab {
+ background: --bg;
+ shadow: --buttonDefaultBevel
+}
+
+Tab:hover:active {
+ shadow: --buttonDefaultBevel
+}
+
+TopBar Link {
+ textColor: #ffffff
+}
diff --git a/static/styles/index.json b/static/styles/index.json
new file mode 100644
index 00000000..52365cd3
--- /dev/null
+++ b/static/styles/index.json
@@ -0,0 +1,4 @@
+{
+ "RedmondDX": "/static/styles/Redmond DX.piss",
+ "BreezyDX": "/static/styles/Breezy DX.piss"
+}
diff --git a/yarn.lock b/yarn.lock
index 231e35d9..955cd1a3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7201,6 +7201,11 @@ p-try@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
+pako@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
+ integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==
+
pako@~1.0.2:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"