Merge branch 'themes3-grand-finale-maybe' into 'develop'

Themes 3

See merge request pleroma/pleroma-fe!1951
This commit is contained in:
HJ 2024-12-18 12:19:11 +00:00
commit cbe9427123
76 changed files with 4827 additions and 1236 deletions

1
changelog.d/custom.add Normal file
View file

@ -0,0 +1 @@
Added support for fetching /{resource}.custom.ext to allow adding instance-specific themes without altering sourcetree

1
changelog.d/tabs.change Normal file
View file

@ -0,0 +1 @@
Tabs now have indentation for better visibility of which tab is currently active

1
changelog.d/themes3.add Normal file
View file

@ -0,0 +1 @@
UI for making v3 themes and palettes, support for bundling v3 themes

View file

@ -35,6 +35,7 @@
"hash-sum": "^2.0.0", "hash-sum": "^2.0.0",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"localforage": "1.10.0", "localforage": "1.10.0",
"pako": "^2.1.0",
"parse-link-header": "2.0.0", "parse-link-header": "2.0.0",
"phoenix": "1.7.7", "phoenix": "1.7.7",
"punycode.js": "2.3.0", "punycode.js": "2.3.0",

View file

@ -67,6 +67,9 @@ export default {
themeApplied () { themeApplied () {
return this.$store.state.interface.themeApplied return this.$store.state.interface.themeApplied
}, },
layoutModalClass () {
return '-' + this.layoutType
},
classes () { classes () {
return [ return [
{ {

View file

@ -70,7 +70,7 @@
<PostStatusModal /> <PostStatusModal />
<EditStatusModal v-if="editingAvailable" /> <EditStatusModal v-if="editingAvailable" />
<StatusHistoryModal v-if="editingAvailable" /> <StatusHistoryModal v-if="editingAvailable" />
<SettingsModal /> <SettingsModal :class="layoutModalClass"/>
<UpdateNotification /> <UpdateNotification />
<GlobalNoticeList /> <GlobalNoticeList />
</div> </div>

View file

@ -123,6 +123,8 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
} }
copyInstanceOption('theme') copyInstanceOption('theme')
copyInstanceOption('style')
copyInstanceOption('palette')
copyInstanceOption('nsfwCensorImage') copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background') copyInstanceOption('background')
copyInstanceOption('hidePostStats') copyInstanceOption('hidePostStats')
@ -351,7 +353,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
await setConfig({ store }) await setConfig({ store })
document.querySelector('#status').textContent = i18n.global.t('splash.theme') document.querySelector('#status').textContent = i18n.global.t('splash.theme')
try { try {
await store.dispatch('setTheme').catch((e) => { console.error('Error setting theme', e) }) await store.dispatch('applyTheme').catch((e) => { console.error('Error setting theme', e) })
} catch (e) { } catch (e) {
return Promise.reject(e) return Promise.reject(e)
} }
@ -391,6 +393,13 @@ const afterStoreSetup = async ({ store, i18n }) => {
app.use(store) app.use(store)
app.use(i18n) app.use(i18n)
// Little thing to get out of invalid theme state
window.resetThemes = () => {
store.dispatch('resetThemeV3')
store.dispatch('resetThemeV3Palette')
store.dispatch('resetThemeV2')
}
app.use(vClickOutside) app.use(vClickOutside)
app.use(VBodyScrollLock) app.use(VBodyScrollLock)
app.use(VueVirtualScroller) app.use(VueVirtualScroller)

View file

@ -14,6 +14,10 @@ export default {
warning: '.warning', warning: '.warning',
success: '.success' success: '.success'
}, },
editor: {
border: 1,
aspect: '3 / 1'
},
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'Attachment', name: 'Attachment',
selector: '.Attachment', selector: '.Attachment',
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Border', 'Border',
'ButtonUnstyled', 'ButtonUnstyled',

View file

@ -5,7 +5,7 @@ export default {
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
textColor: '$mod(--parent, 10)', textColor: '$mod(--parent 10)',
textAuto: 'no-auto' textAuto: 'no-auto'
} }
} }

View file

@ -9,9 +9,9 @@ export default {
// However, cascading still works, so resulting state will be result of merging of all relevant states/variants // However, cascading still works, so resulting state will be result of merging of all relevant states/variants
// normal: '' // normal state is implicitly added, it is always included // normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled', toggled: '.toggled',
pressed: ':active', focused: ':focus-visible',
pressed: ':focus:active',
hover: ':hover:not(:disabled)', hover: ':hover:not(:disabled)',
focused: ':focus-within',
disabled: ':disabled' disabled: ':disabled'
}, },
// Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it. // Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it.
@ -22,6 +22,9 @@ export default {
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants. // Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist. // This (currently) is further multipled by number of places where component can exist.
}, },
editor: {
aspect: '2 / 1'
},
// This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever). // This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever).
validInnerComponents: [ validInnerComponents: [
'Text', 'Text',
@ -32,10 +35,11 @@ export default {
{ {
component: 'Root', component: 'Root',
directives: { directives: {
'--defaultButtonHoverGlow': 'shadow | 0 0 4 --text', '--buttonDefaultHoverGlow': 'shadow | 0 0 4 --text / 0.5',
'--defaultButtonShadow': 'shadow | 0 0 2 #000000', '--buttonDefaultFocusGlow': 'shadow | 0 0 4 4 --link / 0.5',
'--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2), $borderSide(#000000, bottom, 0.2)', '--buttonDefaultShadow': 'shadow | 0 0 2 #000000',
'--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)' '--buttonDefaultBevel': 'shadow | $borderSide(#FFFFFF top 0.2 2), $borderSide(#000000 bottom 0.2 2)',
'--buttonPressedBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2 2), $borderSide(#000000 top 0.2 2)'
} }
}, },
{ {
@ -43,47 +47,60 @@ export default {
// like within it // like within it
directives: { directives: {
background: '--fg', background: '--fg',
shadow: ['--defaultButtonShadow', '--defaultButtonBevel'], shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3 roundness: 3
} }
}, },
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel']
}
},
{
state: ['focused'],
directives: {
shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel']
} }
}, },
{ {
state: ['pressed'], state: ['pressed'],
directives: { directives: {
shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
} }
}, },
{ {
state: ['hover', 'pressed'], state: ['pressed', 'hover'],
directives: { directives: {
shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow']
} }
}, },
{ {
state: ['toggled'], state: ['toggled'],
directives: { directives: {
background: '--inheritedBackground,-14.2', background: '--inheritedBackground,-14.2',
shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
} }
}, },
{ {
state: ['toggled', 'hover'], state: ['toggled', 'hover'],
directives: { directives: {
background: '--inheritedBackground,-14.2', background: '--inheritedBackground,-14.2',
shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'disabled'],
directives: {
background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--buttonPressedBevel']
} }
}, },
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground, 0.25, --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--defaultButtonBevel'] shadow: ['--buttonDefaultBevel']
} }
}, },
{ {

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'ButtonUnstyled', name: 'ButtonUnstyled',
selector: '.button-unstyled', selector: '.button-unstyled',
notEditable: true,
states: { states: {
toggled: '.toggled', toggled: '.toggled',
disabled: ':disabled', disabled: ':disabled',

View file

@ -5,6 +5,10 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.opt {
margin-right: 0.5em;
}
&-field.input { &-field.input {
display: inline-flex; display: inline-flex;
flex: 0 0 0; flex: 0 0 0;

View file

@ -11,11 +11,11 @@
{{ label }} {{ label }}
</label> </label>
<Checkbox <Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalTickbox" v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present" :model-value="present"
:disabled="disabled" :disabled="disabled"
class="opt" class="opt"
@update:modelValue="update(typeof modelValue === 'undefined' ? fallback : undefined)" @update:modelValue="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
/> />
<div <div
class="input color-input-field" class="input color-input-field"
@ -112,10 +112,16 @@ export default {
default: false default: false
}, },
// Show "optional" tickbox, for when value might become mandatory // Show "optional" tickbox, for when value might become mandatory
showOptionalTickbox: { showOptionalCheckbox: {
required: false, required: false,
type: Boolean, type: Boolean,
default: true default: true
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false
} }
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
@ -130,7 +136,7 @@ export default {
return this.modelValue === 'transparent' return this.modelValue === 'transparent'
}, },
computedColor () { computedColor () {
return this.modelValue && this.modelValue.startsWith('--') return this.modelValue && (this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
} }
}, },
methods: { methods: {

View file

@ -1,88 +1,190 @@
<template> <template>
<div
class="ComponentPreview"
:class="{ '-shadow-controls': shadowControl }"
>
<label
class="header"
v-show="shadowControl"
:class="{ faint: disabled }"
>
{{ $t('settings.style.shadows.offset') }}
</label>
<input
v-show="shadowControl"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-number y-shift-number"
type="number"
@input="e => updateProperty('y', e.target.value)"
>
<input
v-show="shadowControl"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-range y-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('y', e.target.value)"
>
<div <div
class="preview-window" class="ComponentPreview"
:class="{ '-light-grid': lightGrid }" :class="{ '-shadow-controls': shadowControl }"
> >
<div <!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
class="preview-block" <component
:style="previewStyle" :is="'style'"
v-html="previewCss"
/> />
<!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
<label
v-show="shadowControl"
role="heading"
class="header"
:class="{ faint: disabled }"
>
{{ $t('settings.style.shadows.offset') }}
</label>
<label
v-show="shadowControl && !hideControls"
class="x-shift-number"
>
{{ $t('settings.style.shadows.offset-x') }}
<input
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-number"
type="number"
@input="e => updateProperty('x', e.target.value)"
>
</label>
<label
class="y-shift-number"
v-show="shadowControl && !hideControls"
>
{{ $t('settings.style.shadows.offset-y') }}
<input
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-number"
type="number"
@input="e => updateProperty('y', e.target.value)"
>
</label>
<input
v-show="shadowControl && !hideControls"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-range x-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('x', e.target.value)"
>
<input
v-show="shadowControl && !hideControls"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-range y-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('y', e.target.value)"
>
<div
class="preview-window"
:class="{ '-light-grid': lightGrid }"
>
<div
class="preview-block"
:class="previewClass"
:style="style"
>
{{ $t('settings.style.themes3.editor.test_string') }}
</div>
<div v-if="invalid" class="invalid-container">
<div class="alert error invalid-label">
{{ $t('settings.style.themes3.editor.invalid') }}
</div>
</div>
</div>
<div class="assists">
<Checkbox
v-model="lightGrid"
name="lightGrid"
class="input-light-grid"
>
{{ $t('settings.style.shadows.light_grid') }}
</Checkbox>
<div class="style-control">
<label class="label">
{{ $t('settings.style.shadows.zoom') }}
</label>
<input
v-model="zoom"
class="input input-number y-shift-number"
type="number"
>
</div>
<ColorInput
v-if="!noColorControl"
class="input-color-input"
v-model="colorOverride"
fallback="#606060"
:label="$t('settings.style.shadows.color_override')"
/>
</div>
</div> </div>
<input
v-show="shadowControl"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-number x-shift-number"
type="number"
@input="e => updateProperty('x', e.target.value)"
>
<input
v-show="shadowControl"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-range x-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('x', e.target.value)"
>
<Checkbox
id="lightGrid"
v-model="lightGrid"
:disabled="shadow == null"
name="lightGrid"
class="input-light-grid"
>
{{ $t('settings.style.shadows.light_grid') }}
</Checkbox>
</div>
</template> </template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
export default {
components: {
Checkbox,
ColorInput
},
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'previewCss',
'disabled',
'invalid',
'noColorControl'
],
emits: ['update:shadow'],
data () {
return {
colorOverride: undefined,
lightGrid: false,
zoom: 100
}
},
computed: {
style () {
const result = [
this.previewStyle,
`zoom: ${this.zoom / 100}`
]
if (this.colorOverride) result.push(`--background: ${this.colorOverride}`)
return result
},
hideControls () {
return typeof this.shadow === 'string'
}
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value: Number(value) })
}
}
}
</script>
<style lang="scss"> <style lang="scss">
.ComponentPreview { .ComponentPreview {
display: grid; display: grid;
grid-template-columns: 3em 1fr 3em; grid-template-columns: 1em 1fr 1fr 1em;
grid-template-rows: 2em 1fr 2em; grid-template-rows: 2em 1fr 1fr 1fr 1em 2em max-content;
grid-template-areas: grid-template-areas:
". header y-num " "header header header header "
". preview y-slide" "preview preview preview y-slide"
"x-num x-slide . " "preview preview preview y-slide"
"options options options"; "preview preview preview y-slide"
"x-slide x-slide x-slide . "
"x-num x-num y-num y-num "
"assists assists assists assists";
grid-gap: 0.5em; grid-gap: 0.5em;
&:not(.-shadow-controls) {
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"assists assists assists assists";
grid-template-rows: 2em 1fr 1fr 1fr max-content;
}
.header { .header {
grid-area: header; grid-area: header;
justify-self: center; justify-self: center;
@ -90,8 +192,31 @@
line-height: 2; line-height: 2;
} }
.invalid-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
align-items: center;
justify-items: center;
background-color: rgba(100 0 0 / 50%);
.alert {
padding: 0.5em 1em;
}
}
.assists {
grid-area: assists;
display: grid;
grid-auto-flow: rows;
grid-auto-rows: 2em;
grid-gap: 0.5em;
}
.input-light-grid { .input-light-grid {
grid-area: options;
justify-self: center; justify-self: center;
} }
@ -101,6 +226,19 @@
.x-shift-number { .x-shift-number {
grid-area: x-num; grid-area: x-num;
justify-self: right;
}
.y-shift-number {
grid-area: y-num;
justify-self: left;
}
.x-shift-number,
.y-shift-number {
input {
max-width: 4em;
}
} }
.x-shift-slider { .x-shift-slider {
@ -110,10 +248,6 @@
min-width: 10em; min-width: 10em;
} }
.y-shift-number {
grid-area: y-num;
}
.y-shift-slider { .y-shift-slider {
grid-area: y-slide; grid-area: y-slide;
writing-mode: vertical-lr; writing-mode: vertical-lr;
@ -139,6 +273,7 @@
--__grid-color2-disabled: rgba(255 255 255 / 20%); --__grid-color2-disabled: rgba(255 255 255 / 20%);
} }
position: relative;
grid-area: preview; grid-area: preview;
aspect-ratio: 1; aspect-ratio: 1;
display: flex; display: flex;
@ -183,30 +318,3 @@
} }
} }
</style> </style>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'disabled'
],
data () {
return {
lightGrid: false
}
},
emits: ['update:shadow'],
components: {
Checkbox
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value })
}
}
}
</script>

View file

@ -3,39 +3,44 @@
v-if="contrast" v-if="contrast"
class="contrast-ratio" class="contrast-ratio"
> >
<span <span v-if="showRatio">
:title="hint" {{ contrast.text }}
</span>
<Tooltip
:text="hint"
class="rating" class="rating"
> >
<span v-if="contrast.aaa"> <span v-if="contrast.aaa">
<FAIcon icon="thumbs-up" /> <FAIcon icon="thumbs-up" :size="showRatio ? 'lg' : ''" />
</span> </span>
<span v-if="!contrast.aaa && contrast.aa"> <span v-if="!contrast.aaa && contrast.aa">
<FAIcon icon="adjust" /> <FAIcon icon="adjust" :size="showRatio ? 'lg' : ''" />
</span> </span>
<span v-if="!contrast.aaa && !contrast.aa"> <span v-if="!contrast.aaa && !contrast.aa">
<FAIcon icon="exclamation-triangle" /> <FAIcon icon="exclamation-triangle" :size="showRatio ? 'lg' : ''" />
</span> </span>
</span> </Tooltip>
<span <Tooltip
v-if="contrast && large" v-if="contrast && large"
:text="hint_18pt"
class="rating" class="rating"
:title="hint_18pt"
> >
<span v-if="contrast.laaa"> <span v-if="contrast.laaa">
<FAIcon icon="thumbs-up" /> <FAIcon icon="thumbs-up" :size="showRatio ? 'large' : ''" />
</span> </span>
<span v-if="!contrast.laaa && contrast.laa"> <span v-if="!contrast.laaa && contrast.laa">
<FAIcon icon="adjust" /> <FAIcon icon="adjust" :size="showRatio ? 'lg' : ''" />
</span> </span>
<span v-if="!contrast.laaa && !contrast.laa"> <span v-if="!contrast.laaa && !contrast.laa">
<FAIcon icon="exclamation-triangle" /> <FAIcon icon="exclamation-triangle" :size="showRatio ? 'lg' : ''" />
</span> </span>
</span> </Tooltip>
</span> </span>
</template> </template>
<script> <script>
import Tooltip from 'src/components/tooltip/tooltip.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faAdjust, faAdjust,
@ -62,8 +67,16 @@ export default {
required: false, required: false,
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
showRatio: {
required: false,
type: Boolean,
default: false
} }
}, },
components: {
Tooltip
},
computed: { computed: {
hint () { hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad') const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
@ -87,8 +100,7 @@ export default {
.contrast-ratio { .contrast-ratio {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: -4px; align-items: baseline;
margin-bottom: 5px;
.label { .label {
margin-right: 1em; margin-right: 1em;
@ -96,7 +108,6 @@ export default {
.rating { .rating {
display: inline-block; display: inline-block;
text-align: center;
margin-left: 0.5em; margin-left: 0.5em;
} }
} }

View file

@ -6,7 +6,7 @@ export default {
{ {
component: 'Icon', component: 'Icon',
directives: { directives: {
textColor: '$blend(--stack, 0.5, --parent--text)', textColor: '$blend(--stack 0.5 --parent--text)',
textAuto: 'no-auto' textAuto: 'no-auto'
} }
} }

View file

@ -1,12 +1,3 @@
const hoverGlow = {
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--text',
alpha: 1
}
export default { export default {
name: 'Input', name: 'Input',
selector: '.input', selector: '.input',
@ -27,7 +18,9 @@ export default {
{ {
component: 'Root', component: 'Root',
directives: { directives: {
'--defaultInputBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)' '--defaultInputBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2), $borderSide(#000000 top 0.2)',
'--defaultInputHoverGlow': 'shadow | 0 0 4 --text / 0.5',
'--defaultInputFocusGlow': 'shadow | 0 0 4 4 --link / 0.5'
} }
}, },
{ {
@ -54,7 +47,19 @@ export default {
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
shadow: [hoverGlow, '--defaultInputBevel'] shadow: ['--defaultInputHoverGlow', '--defaultInputBevel']
}
},
{
state: ['focused'],
directives: {
shadow: ['--defaultInputFocusGlow', '--defaultInputBevel']
}
},
{
state: ['focused', 'hover'],
directives: {
shadow: ['--defaultInputFocusGlow', '--defaultInputHoverGlow', '--defaultInputBevel']
} }
}, },
{ {

View file

@ -24,21 +24,21 @@ export default {
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
background: '$mod(--bg, 5)', background: '$mod(--bg 5)',
opacity: 1 opacity: 1
} }
}, },
{ {
state: ['active'], state: ['active'],
directives: { directives: {
background: '$mod(--bg, 10)', background: '$mod(--bg 10)',
opacity: 1 opacity: 1
} }
}, },
{ {
state: ['active', 'hover'], state: ['active', 'hover'],
directives: { directives: {
background: '$mod(--bg, 15)', background: '$mod(--bg 15)',
opacity: 1 opacity: 1
} }
}, },

View file

@ -1,7 +1,8 @@
export default { export default {
name: 'Modals', name: 'Modals',
selector: '.modal-view', selector: ['.modal-view', '#modal', '.shout-panel'],
lazy: true, lazy: true,
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Panel' 'Panel'
], ],

View file

@ -8,7 +8,7 @@
class="label" class="label"
:class="{ faint: !present || disabled }" :class="{ faint: !present || disabled }"
> >
{{ $t('settings.style.common.opacity') }} {{ label }}
</label> </label>
<Checkbox <Checkbox
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined'"
@ -39,7 +39,7 @@ export default {
Checkbox Checkbox
}, },
props: [ props: [
'name', 'modelValue', 'fallback', 'disabled' 'name', 'label', 'modelValue', 'fallback', 'disabled'
], ],
emits: ['update:modelValue'], emits: ['update:modelValue'],
computed: { computed: {

View file

@ -0,0 +1,192 @@
<template>
<div
class="PaletteEditor"
:class="{ '-compact': compact, '-apply': apply }"
>
<ColorInput
v-for="key in paletteKeys"
:key="key"
:model-value="props.modelValue[key]"
:fallback="fallback(key)"
:label="$t('settings.style.themes3.palette.' + key)"
@update:modelValue="value => updatePalette(key, value)"
/>
<button
class="btn button-default palette-import-button"
@click="importPalette"
>
<FAIcon icon="file-import" />
{{ $t('settings.style.themes3.palette.import') }}
</button>
<button
class="btn button-default palette-export-button"
@click="exportPalette"
>
<FAIcon icon="file-export" />
{{ $t('settings.style.themes3.palette.export') }}
</button>
<button
v-if="apply"
class="btn button-default palette-apply-button"
@click="applyPalette"
>
{{ $t('settings.style.themes3.palette.apply') }}
</button>
</div>
</template>
<script setup>
import ColorInput from 'src/components/color_input/color_input.vue'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFileImport,
faFileExport
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFileImport,
faFileExport
)
const paletteKeys = [
'bg',
'fg',
'text',
'link',
'accent',
'cRed',
'cBlue',
'cGreen',
'cOrange',
'wallpaper'
]
const props = defineProps(['modelValue', 'compact', 'apply'])
const emit = defineEmits(['update:modelValue', 'applyPalette'])
const getExportedObject = () => paletteKeys.reduce((acc, key) => {
const value = props.modelValue[key]
if (value == null) {
return acc
} else {
return { ...acc, [key]: props.modelValue[key] }
}
}, {})
const paletteExporter = newExporter({
filename: 'pleroma_palette',
extension: 'json',
getExportedObject
})
const paletteImporter = newImporter({
accept: '.json',
onImport (parsed, filename) {
emit('update:modelValue', parsed)
}
})
const exportPalette = () => {
paletteExporter.exportData()
}
const importPalette = () => {
paletteImporter.importData()
}
const applyPalette = (data) => {
emit('applyPalette', getExportedObject())
}
const fallback = (key) => {
if (key === 'accent') {
return props.modelValue.link
}
if (key === 'link') {
return props.modelValue.accent
}
if (key.startsWith('extra')) {
return '#FF00FF'
}
if (key.startsWith('wallpaper')) {
return '#008080'
}
}
const updatePalette = (paletteKey, value) => {
emit('update:modelValue', {
...props.modelValue,
[paletteKey]: value
})
}
</script>
<style lang="scss">
.PaletteEditor {
display: grid;
justify-content: space-around;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(5, 1fr) auto;
grid-gap: 0.5em;
align-items: baseline;
.palette-import-button {
grid-column: 1 / span 2;
}
.palette-export-button {
grid-column: 3 / span 2;
}
.palette-apply-button {
grid-column: 1 / span 2;
}
.color-input.style-control {
margin: 0;
}
&.-compact {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(5, 1fr) auto;
.palette-import-button {
grid-column: 1;
}
.palette-export-button {
grid-column: 2;
}
&.-apply {
grid-template-rows: repeat(5, 1fr) auto auto;
.palette-apply-button {
grid-column: 1 / span 2;
}
}
.-mobile & {
grid-template-columns: 1fr;
grid-template-rows: repeat(10, 1fr) auto;
.palette-import-button {
grid-column: 1;
}
.palette-export-button {
grid-column: 1;
}
&.-apply {
.palette-apply-button {
grid-column: 1;
}
}
}
}
}
</style>

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'RichContent', name: 'RichContent',
selector: '.RichContent', selector: '.RichContent',
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Text', 'Text',
'FunText', 'FunText',

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'Root', name: 'Root',
selector: ':root', selector: ':root',
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Underlay', 'Underlay',
'Modals', 'Modals',
@ -42,7 +43,7 @@ export default {
// Selection colors // Selection colors
'--selectionBackground': 'color | --accent', '--selectionBackground': 'color | --accent',
'--selectionText': 'color | $textColor(--accent, --text, no-preserve)' '--selectionText': 'color | $textColor(--accent --text no-preserve)'
} }
} }
] ]

View file

@ -0,0 +1,51 @@
<template>
<div
class="roundness-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
:model-value="present"
:disabled="disabled"
class="opt"
@update:modelValue="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="999"
min="0"
step="1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from '../checkbox/checkbox.vue'
export default {
components: {
Checkbox
},
props: [
'name', 'label', 'modelValue', 'fallback', 'disabled'
],
emits: ['update:modelValue'],
computed: {
present () {
return typeof this.modelValue !== 'undefined'
}
}
}
</script>

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'Scrollbar', name: 'Scrollbar',
selector: '::-webkit-scrollbar', selector: ['::-webkit-scrollbar-button', '::-webkit-scrollbar-thumb', '::-webkit-resizer'],
notEditable: true, // for now
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

View file

@ -31,6 +31,7 @@ const hoverGlow = {
export default { export default {
name: 'ScrollbarElement', name: 'ScrollbarElement',
selector: '::-webkit-scrollbar-button', selector: '::-webkit-scrollbar-button',
notEditable: true, // for now
states: { states: {
pressed: ':active', pressed: ':active',
hover: ':hover:not(:disabled)', hover: ':hover:not(:disabled)',
@ -82,7 +83,7 @@ export default {
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground, 0.25, --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: [...buttonInsetFakeBorders] shadow: [...buttonInsetFakeBorders]
} }
}, },

View file

@ -49,6 +49,7 @@ label.Select {
option { option {
background-color: transparent; background-color: transparent;
&:checked,
&.-active { &.-active {
color: var(--selectionText); color: var(--selectionText);
background-color: var(--selectionBackground); background-color: var(--selectionBackground);

View file

@ -0,0 +1,136 @@
<template>
<div
class="SelectMotion btn-group"
>
<button
class="btn button-default"
:disabled="disabled"
@click="add"
>
<FAIcon
fixed-width
icon="plus"
/>
</button>
<button
class="btn button-default"
:disabled="disabled || !moveUpValid"
:class="{ disabled: disabled || !moveUpValid }"
@click="moveUp"
>
<FAIcon
fixed-width
icon="chevron-up"
/>
</button>
<button
class="btn button-default"
:disabled="disabled || !moveDnValid"
:class="{ disabled: disabled || !moveDnValid }"
@click="moveDn"
>
<FAIcon
fixed-width
icon="chevron-down"
/>
</button>
<button
class="btn button-default"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
@click="del"
>
<FAIcon
fixed-width
icon="times"
/>
</button>
</div>
</template>
<script setup>
import { computed, defineEmits, defineProps, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: Array,
required: true
},
selectedId: {
type: Number,
required: true
},
disabled: {
type: Boolean,
default: false
},
getAddValue: {
type: Function,
required: true
}
})
const emit = defineEmits(['update:modelValue', 'update:selectedId'])
const moveUpValid = computed(() => {
return props.selectedId > 0
})
const present = computed(() => props.modelValue[props.selectedId] != null)
const moveUp = async () => {
const newModel = [...props.modelValue]
const movable = newModel.splice(props.selectedId, 1)[0]
newModel.splice(props.selectedId - 1, 0, movable)
emit('update:modelValue', newModel)
await nextTick()
emit('update:selectedId', props.selectedId - 1)
}
const moveDnValid = computed(() => {
return props.selectedId < props.modelValue.length - 1
})
const moveDn = async () => {
const newModel = [...props.modelValue]
const movable = newModel.splice(props.selectedId.value, 1)[0]
newModel.splice(props.selectedId + 1, 0, movable)
emit('update:modelValue', newModel)
await nextTick()
emit('update:selectedId', props.selectedId + 1)
}
const add = async () => {
const newModel = [...props.modelValue, props.getAddValue()]
emit('update:modelValue', newModel)
await nextTick()
emit('update:selectedId', Math.max(newModel.length - 1, 0))
}
const del = async () => {
const newModel = [...props.modelValue]
newModel.splice(props.selectedId, 1)
emit('update:modelValue', newModel)
await nextTick()
emit('update:selectedId', newModel.length === 0 ? undefined : Math.max(props.selectedId - 1, 0))
}
</script>
<style lang="scss">
.SelectMotion {
flex: 0 0 auto;
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
margin-top: 0.25em;
.button-default {
margin: 0;
padding: 0;
}
}
</style>

View file

@ -10,9 +10,13 @@ export default {
ProfileSettingIndicator ProfileSettingIndicator
}, },
props: { props: {
modelValue: {
type: String,
default: null
},
path: { path: {
type: [String, Array], type: [String, Array],
required: true required: false
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
@ -68,7 +72,7 @@ export default {
} }
}, },
created () { created () {
if (this.realDraftMode && this.realSource !== 'admin') { if (this.realDraftMode && (this.realSource !== 'admin' || this.path == null)) {
this.draft = this.state this.draft = this.state
} }
}, },
@ -76,14 +80,14 @@ export default {
draft: { draft: {
// TODO allow passing shared draft object? // TODO allow passing shared draft object?
get () { get () {
if (this.realSource === 'admin') { if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath) return get(this.$store.state.adminSettings.draft, this.canonPath)
} else { } else {
return this.localDraft return this.localDraft
} }
}, },
set (value) { set (value) {
if (this.realSource === 'admin') { if (this.realSource === 'admin' || this.path == null) {
this.$store.commit('updateAdminDraft', { path: this.canonPath, value }) this.$store.commit('updateAdminDraft', { path: this.canonPath, value })
} else { } else {
this.localDraft = value this.localDraft = value
@ -91,6 +95,9 @@ export default {
} }
}, },
state () { state () {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath) const value = get(this.configSource, this.canonPath)
if (value === undefined) { if (value === undefined) {
return this.defaultState return this.defaultState
@ -145,6 +152,9 @@ export default {
return this.backendDescription?.suggestions return this.backendDescription?.suggestions
}, },
shouldBeDisabled () { shouldBeDisabled () {
if (this.path == null) {
return this.disabled
}
const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null
return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false) return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false)
}, },
@ -159,6 +169,9 @@ export default {
} }
}, },
configSink () { configSink () {
if (this.path == null) {
return (k, v) => this.$emit('update:modelValue', v)
}
switch (this.realSource) { switch (this.realSource) {
case 'profile': case 'profile':
return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v }) return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v })
@ -184,6 +197,7 @@ export default {
return this.realSource === 'profile' return this.realSource === 'profile'
}, },
isChanged () { isChanged () {
if (this.path == null) return false
switch (this.realSource) { switch (this.realSource) {
case 'profile': case 'profile':
case 'admin': case 'admin':
@ -193,9 +207,11 @@ export default {
} }
}, },
canonPath () { canonPath () {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.') return Array.isArray(this.path) ? this.path : this.path.split('.')
}, },
isDirty () { isDirty () {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) { if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values return false // should not show draft buttons for "grouped" values
} else { } else {

View file

@ -5,6 +5,7 @@
> >
<label <label
:for="path" :for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }" :class="{ 'faint': shouldBeDisabled }"
> >
<template v-if="backendDescriptionLabel"> <template v-if="backendDescriptionLabel">
@ -15,6 +16,7 @@
</template> </template>
<slot v-else /> <slot v-else />
</label> </label>
{{ ' ' }}
<input <input
:id="path" :id="path"
class="input string-input" class="input string-input"

View file

@ -10,31 +10,33 @@
<slot /> <slot />
</label> </label>
{{ ' ' }} {{ ' ' }}
<input <span class="no-break">
:id="path" <input
class="input number-input" :id="path"
type="number" class="input number-input"
:step="step" type="number"
:disabled="disabled" :step="step"
:min="min || 0" :disabled="disabled"
:value="stateValue" :min="min || 0"
@change="updateValue" :value="stateValue"
> @change="updateValue"
<Select
:id="path"
:model-value="stateUnit"
:disabled="disabled"
class="unit-input unstyled"
@change="updateUnit"
>
<option
v-for="option in units"
:key="option"
:value="option"
> >
{{ getUnitString(option) }} <Select
</option> :id="path"
</Select> :model-value="stateUnit"
:disabled="disabled"
class="unit-input unstyled"
@change="updateUnit"
>
<option
v-for="option in units"
:key="option"
:value="option"
>
{{ getUnitString(option) }}
</option>
</Select>
</span>
{{ ' ' }} {{ ' ' }}
<ModifiedIndicator <ModifiedIndicator
:changed="isChanged" :changed="isChanged"
@ -47,6 +49,10 @@
<style lang="scss"> <style lang="scss">
.UnitSetting { .UnitSetting {
.no-break {
display: inline-block;
}
.number-input { .number-input {
max-width: 6.5em; max-width: 6.5em;
text-align: right; text-align: right;

View file

@ -10,6 +10,10 @@
list-style-type: none; list-style-type: none;
padding-left: 2em; padding-left: 2em;
.btn:not(.dropdown-button) {
padding: 0 2em;
}
li { li {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
@ -54,10 +58,6 @@
.btn { .btn {
min-height: 2em; min-height: 2em;
} }
.btn:not(.dropdown-button) {
padding: 0 2em;
}
} }
} }
@ -76,6 +76,23 @@
} }
} }
&.-mobile {
.setting-list,
.option-list {
padding-left: 0.25em;
> li {
margin: 1em 0;
line-height: 1.5em;
vertical-align: center;
}
&.two-column {
column-count: 1;
}
}
}
&.peek { &.peek {
.settings-modal-panel { .settings-modal-panel {
/* Explanation: /* Explanation:

View file

@ -17,10 +17,13 @@
} }
.select-multiple { .select-multiple {
margin-top: 0.5em;
display: flex; display: flex;
flex-direction: column;
.option-list { .option-list {
margin: 0; margin: 0;
margin-top: 0.5em;
padding-left: 0.5em; padding-left: 0.5em;
} }
} }

View file

@ -10,6 +10,7 @@ import GeneralTab from './tabs/general_tab.vue'
import AppearanceTab from './tabs/appearance_tab.vue' import AppearanceTab from './tabs/appearance_tab.vue'
import VersionTab from './tabs/version_tab.vue' import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_tab.vue' import ThemeTab from './tabs/theme_tab/theme_tab.vue'
import StyleTab from './tabs/style_tab/style_tab.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -17,6 +18,7 @@ import {
faUser, faUser,
faFilter, faFilter,
faPaintBrush, faPaintBrush,
faPalette,
faBell, faBell,
faDownload, faDownload,
faEyeSlash, faEyeSlash,
@ -29,6 +31,7 @@ library.add(
faUser, faUser,
faFilter, faFilter,
faPaintBrush, faPaintBrush,
faPalette,
faBell, faBell,
faDownload, faDownload,
faEyeSlash, faEyeSlash,
@ -48,6 +51,7 @@ const SettingsModalContent = {
ProfileTab, ProfileTab,
GeneralTab, GeneralTab,
AppearanceTab, AppearanceTab,
StyleTab,
VersionTab, VersionTab,
ThemeTab ThemeTab
}, },
@ -60,6 +64,12 @@ const SettingsModalContent = {
}, },
bodyLock () { bodyLock () {
return this.$store.state.interface.settingsModalState === 'visible' return this.$store.state.interface.settingsModalState === 'visible'
},
expertLevel () {
return this.$store.state.config.expertLevel
},
isMobileLayout () {
return this.$store.state.interface.layoutType === 'mobile'
} }
}, },
methods: { methods: {

View file

@ -1,6 +1,21 @@
.settings_tab-switcher { .settings_tab-switcher {
height: 100%; height: 100%;
h1 {
margin-bottom: 0.5em;
margin-top: 0.5em;
}
h4 {
margin-bottom: 0;
margin-top: 0.25em;
}
h5 {
margin-bottom: 0;
margin-top: 0.25em;
}
.setting-item { .setting-item {
border-bottom: 2px solid var(--border); border-bottom: 2px solid var(--border);
margin: 1em 1em 1.4em; margin: 1em 1em 1.4em;
@ -8,7 +23,6 @@
> div, > div,
> label { > label {
display: block;
margin-bottom: 0.5em; margin-bottom: 0.5em;
&:last-child { &:last-child {
@ -17,10 +31,13 @@
} }
.select-multiple { .select-multiple {
margin-top: 1em;
display: flex; display: flex;
flex-direction: column;
.option-list { .option-list {
margin: 0; margin: 0;
margin-top: 0.5em;
padding-left: 0.5em; padding-left: 0.5em;
} }
} }

View file

@ -21,7 +21,16 @@
<AppearanceTab /> <AppearanceTab />
</div> </div>
<div <div
:label="$t('settings.theme')" v-if="expertLevel > 0 && !isMobileLayout"
:label="$t('settings.style.themes3.editor.title')"
icon="palette"
data-tab-name="style"
>
<StyleTab />
</div>
<div
v-if="expertLevel > 0 && !isMobileLayout"
:label="$t('settings.theme_old')"
icon="paint-brush" icon="paint-brush"
data-tab-name="theme" data-tab-name="theme"
> >

View file

@ -3,20 +3,20 @@ import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue' import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue' import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import FontControl from 'src/components/font_control/font_control.vue' import FontControl from 'src/components/font_control/font_control.vue'
import { normalizeThemeData } from 'src/modules/interface' import { normalizeThemeData } from 'src/modules/interface'
import { import { newImporter } from 'src/services/export_import/export_import.js'
getThemes
} from 'src/services/style_setter/style_setter.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js' import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js' import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { import {
getCssRules, getCssRules,
getScopedVersion getScopedVersion
} from 'src/services/theme_data/css_utils.js' } from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
@ -27,6 +27,10 @@ import {
import Preview from './theme_tab/theme_preview.vue' import Preview from './theme_tab/theme_preview.vue'
// helper for debugging
// eslint-disable-next-line no-unused-vars
const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
library.add( library.add(
faGlobe faGlobe
) )
@ -34,7 +38,28 @@ library.add(
const AppearanceTab = { const AppearanceTab = {
data () { data () {
return { return {
availableStyles: [], availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .piss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange'
],
userPalette: {},
intersectionObserver: null, intersectionObserver: null,
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({ thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode, key: mode,
@ -61,33 +86,69 @@ const AppearanceTab = {
UnitSetting, UnitSetting,
ProfileSettingIndicator, ProfileSettingIndicator,
FontControl, FontControl,
Preview Preview,
PaletteEditor
}, },
mounted () { mounted () {
getThemes() this.$store.dispatch('getThemeData')
.then((promises) => {
return Promise.all( const updateIndex = (resource) => {
Object.entries(promises) const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
.map(([k, v]) => v.then(res => [k, res])) const currentIndex = this.$store.state.instance[`${resource}sIndex`]
)
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = this.$store.dispatch(`fetch${capitalizedResource}sIndex`)
}
return promise.then(index => {
return Object
.entries(index)
.map(([k, func]) => [k, func()])
}) })
.then(themes => themes.reduce((acc, [k, v]) => { }
if (v) {
return [ updateIndex('style').then(styles => {
...acc, styles.forEach(([key, stylePromise]) => stylePromise.then(data => {
{ const meta = data.find(x => x.component === '@meta')
name: v.name || v[0], this.availableThemesV3.push({ key, data, name: meta.directives.name, version: 'v3' })
key: k, }))
data: v })
}
] updateIndex('theme').then(themes => {
themes.forEach(([key, themePromise]) => themePromise.then(data => {
this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' })
}))
})
this.userPalette = this.$store.state.interface.paletteDataUsed || {}
updateIndex('palette').then(bundledPalettes => {
bundledPalettes.forEach(([key, palettePromise]) => palettePromise.then(v => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = v
palette = { key, name, bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
} else { } else {
return acc palette = { key, ...v }
} }
}, [])) if (!palette.key.startsWith('style.')) {
.then((themesComplete) => { this.bundledPalettes.push(palette)
this.availableStyles = themesComplete }
}) }))
})
if (window.IntersectionObserver) { if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => { this.intersectionObserver = new IntersectionObserver((entries, observer) => {
@ -111,7 +172,65 @@ const AppearanceTab = {
}) })
}) })
}, },
watch: {
paletteDataUsed () {
this.userPalette = this.paletteDataUsed || {}
}
},
computed: { computed: {
paletteDataUsed () {
return this.$store.state.interface.paletteDataUsed
},
availableStyles () {
return [
...this.availableThemesV3,
...this.availableThemesV2
]
},
availablePalettes () {
return [
...this.bundledPalettes,
...this.stylePalettes
]
},
stylePalettes () {
const ruleset = this.$store.state.interface.styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find(x => x.component === '@meta')
const result = ruleset.filter(x => x.component.startsWith('@palette'))
.map(x => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
}
return Object.fromEntries(Object.entries(result).filter(([k, v]) => v))
})
return result
},
noIntersectionObserver () { noIntersectionObserver () {
return !window.IntersectionObserver return !window.IntersectionObserver
}, },
@ -144,15 +263,22 @@ const AppearanceTab = {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
} }
}, },
customThemeVersion () {
const { themeVersion } = this.$store.state.interface
return themeVersion
},
isCustomThemeUsed () { isCustomThemeUsed () {
const { theme } = this.mergedConfig const { customTheme, customThemeSource } = this.mergedConfig
return theme === 'custom' || theme === null return customTheme != null || customThemeSource != null
},
isCustomStyleUsed (name) {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
}, },
...SharedComputedObject() ...SharedComputedObject()
}, },
methods: { methods: {
updateFont (key, value) { updateFont (key, value) {
console.log(key, value)
this.$store.dispatch('setOption', { this.$store.dispatch('setOption', {
name: 'theme3hacks', name: 'theme3hacks',
value: { value: {
@ -164,25 +290,120 @@ const AppearanceTab = {
} }
}) })
}, },
importFile () {
this.fileImporter.importData()
},
importParser (file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.piss')) {
return deserialize(file)
}
},
onImport (parsed, filename) {
if (filename.endsWith('.json')) {
this.$store.dispatch('setThemeCustom', parsed.source || parsed.theme)
} else if (filename.endsWith('.piss')) {
this.$store.dispatch('setStyleCustom', parsed)
}
},
onImportFailure (result) {
console.error('Failure importing theme:', result)
this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.piss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find(x => x.component === '@meta') == null) return false
return true
}
},
isThemeActive (key) { isThemeActive (key) {
const { theme } = this.mergedConfig return key === (this.mergedConfig.theme || this.$store.state.instance.theme)
return key === theme },
isStyleActive (key) {
return key === (this.mergedConfig.style || this.$store.state.instance.style)
},
isPaletteActive (key) {
return key === (this.mergedConfig.palette || this.$store.state.instance.palette)
},
setStyle (name) {
this.$store.dispatch('setStyle', name)
}, },
setTheme (name) { setTheme (name) {
this.$store.dispatch('setTheme', { themeName: name, saveData: true, recompile: true }) this.$store.dispatch('setTheme', name)
}, },
previewTheme (key, input) { setPalette (name, data) {
const style = normalizeThemeData(input) this.$store.dispatch('setPalette', name)
const x = 2 this.userPalette = data
if (x === 1) return },
const theme2 = convertTheme2To3(style) setPaletteCustom (data) {
const theme3 = init({ this.$store.dispatch('setPaletteCustom', data)
inputRuleset: theme2, this.userPalette = data
ultimateBackgroundColor: '#000000', },
liteMode: true, resetTheming (name) {
debug: true, this.$store.dispatch('setStyle', 'stock')
onlyNormalState: true },
}) previewTheme (key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
} else if (version === 'v3') {
const palette = input.find(x => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object
.entries(directives)
.filter(([k, v]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v])
)
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(x => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
return getScopedVersion( return getScopedVersion(
getCssRules(theme3.eager), getCssRules(theme3.eager),

View file

@ -0,0 +1,120 @@
.appearance-tab {
.palette,
.theme-notice {
padding: 0.5em;
margin: 1em;
}
.setting-item {
padding-bottom: 0;
&.heading {
display: grid;
align-items: baseline;
grid-template-columns: 1fr auto auto auto;
grid-gap: 0.5em;
h2 {
flex: 1 0 auto;
}
}
}
.palettes {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 0.5em;
h4,
.unsupported-theme-v2,
.userPalette {
grid-column: 1 / span 2;
}
}
.palette-entry {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.5em;
.palette-label label {
text-align: center;
}
.palette-square {
flex: 0 0 auto;
display: inline-block;
min-width: 1em;
min-height: 1em;
}
}
.column-settings {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
.column-settings .size-label {
display: block;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
.modal-view.-mobile & {
.palette-entry {
flex-wrap: wrap;
justify-content: center;
}
.palette-label {
line-height: 1.5em;
margin-top: 0.5em;
width: 100%;
}
.palette-preview {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: 1em 1em;
margin-bottom: 0.5em;
}
}
.theme-list {
list-style: none;
display: flex;
flex-wrap: wrap;
margin: -0.5em 0;
height: 25em;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
padding: 0;
margin-bottom: 1em;
.theme-preview {
font-size: 1rem; // fix for firefox
width: 19rem;
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5em;
&.placeholder {
opacity: 0.2;
}
.theme-preview-container {
pointer-events: none;
zoom: 0.5;
border: none;
border-radius: var(--roundness);
text-align: left;
}
}
}
}

View file

@ -1,44 +1,161 @@
<template> <template>
<div class="appearance-tab" :label="$t('settings.general')"> <div
class="appearance-tab"
:label="$t('settings.general')"
>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.theme') }}</h2> <h2>{{ $t('settings.theme') }}</h2>
<ul <ul
class="theme-list"
ref="themeList" ref="themeList"
class="theme-list"
> >
<button <button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview" class="button-default theme-preview"
> data-theme-key="stock"
<preview /> :class="{ toggled: isStyleActive('stock') }"
<h4 class="theme-name">{{ $t('settings.style.custom_theme_used') }}</h4> @click="resetTheming"
</button>
<button
v-for="style in availableStyles"
:data-theme-key="style.key"
:key="style.key"
class="button-default theme-preview"
:class="{ toggled: isThemeActive(style.key) }"
@click="setTheme(style.key)"
> >
<!-- eslint-disable vue/no-v-text-v-html-on-component --> <!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component <component
:is="'style'" :is="'style'"
v-if="style.ready || noIntersectionObserver" v-html="previewTheme('stock', 'v3')"
v-html="previewTheme(style.key, style.data)"
/> />
<!-- eslint-enable vue/no-v-text-v-html-on-component --> <!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :class="{ placeholder: ready }" :id="'theme-preview-' + style.key"/> <preview id="theme-preview-stock" />
<h4 class="theme-name">{{ style.name }}</h4> <h4 class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
</h4>
</button>
<button
v-if="isCustomStyleUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
{{ $t('settings.style.custom_style_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button
v-for="style in availableStyles"
:key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview"
:class="{ toggled: isStyleActive(style.key) }"
@click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)"
>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<div v-if="style.ready || noIntersectionObserver">
<component
:is="'style'"
v-html="previewTheme(style.key, style.version, style.data)"
/>
</div>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :id="'theme-preview-' + style.key" />
<h4 class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
</h4>
</button> </button>
</ul> </ul>
</div> <div class="import-file-container">
<div class="alert neutral theme-notice"> <button
{{ $t("settings.style.appearance_tab_note") }} class="btn button-default"
@click="importFile"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.style.themes3.palette.label') }}</h2>
<div class="palettes">
<template v-if="customThemeVersion === 'v3'">
<h4>{{ $t('settings.style.themes3.palette.bundled') }}</h4>
<button
v-for="p in bundledPalettes"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key) }"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
<h4 v-if="stylePalettes?.length > 0">
{{ $t('settings.style.themes3.palette.style') }}
</h4>
<button
v-for="p in stylePalettes || []"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key) }"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name ?? $t('settings.style.themes3.palette.user') }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
<h4 v-if="expertLevel > 0">
{{ $t('settings.style.themes3.palette.user') }}
</h4>
<PaletteEditor
v-if="expertLevel > 0"
class="userPalette"
v-model="userPalette"
:compact="true"
:apply="true"
@applyPalette="data => setPaletteCustom(data)"
/>
</template>
<template v-else-if="customThemeVersion === 'v2'">
<div class="alert neutral theme-notice unsupported-theme-v2">
{{ $t('settings.style.themes3.palette.v2_unsupported') }}
</div>
</template>
</div>
</div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2> <h2>{{ $t('settings.scale_and_layout') }}</h2>
<div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }}
</div>
<ul class="setting-list"> <ul class="setting-list">
<li> <li>
<UnitSetting <UnitSetting
@ -60,7 +177,7 @@
<code>px</code> <code>px</code>
<code>rem</code> <code>rem</code>
</i18n-t> </i18n-t>
<br/> <br>
<i18n-t <i18n-t
scope="global" scope="global"
keypath="settings.text_size_tip2" keypath="settings.text_size_tip2"
@ -256,58 +373,4 @@
<script src="./appearance_tab.js"></script> <script src="./appearance_tab.js"></script>
<style lang="scss"> <style lang="scss" src="./appearance_tab.scss"></style>
.appearance-tab {
.theme-notice {
padding: 0.5em;
margin: 1em;
}
.column-settings {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
.column-settings .size-label {
display: block;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
.theme-list {
list-style: none;
display: flex;
flex-wrap: wrap;
margin: -0.5em 0;
height: 25em;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
padding: 0;
.theme-preview {
font-size: 1rem; // fix for firefox
width: 19rem;
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5em;
&.placeholder {
opacity: 0.2;
}
.theme-preview-container {
pointer-events: none;
zoom: 0.5;
border: none;
border-radius: var(--roundness);
text-align: left;
}
}
}
}
</style>

View file

@ -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 <ContractRatio />
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
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,383 @@
<script src="./style_tab.js">
</script>
<template>
<div class="StyleTab">
<div class="setting-item heading">
<h2> {{ $t('settings.style.themes3.editor.title') }} </h2>
<div class="meta-preview">
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="overallPreviewCssRules"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<Preview id="edited-style-preview" />
<teleport
v-if="isActive"
to="#unscrolled-content"
>
<div class="style-actions-container">
<div class="style-actions">
<button
class="btn button-default button-new"
@click="clearStyle"
>
<FAIcon icon="arrows-rotate" />
{{ $t('settings.style.themes3.editor.reset_style') }}
</button>
<button
class="btn button-default button-load"
@click="importStyle"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
<button
class="btn button-default button-save"
@click="exportStyle"
>
<FAIcon icon="floppy-disk" />
{{ $t('settings.style.themes3.editor.save_style') }}
</button>
<button
class="btn button-default button-apply"
@click="applyStyle"
>
<FAIcon icon="check" />
{{ $t('settings.style.themes3.editor.apply_preview') }}
</button>
</div>
</div>
</teleport>
<ul class="setting-list style-metadata">
<li>
<StringSetting class="meta-field" v-model="name">
{{ $t('settings.style.themes3.editor.style_name') }}
</StringSetting>
</li>
<li>
<StringSetting class="meta-field" v-model="author">
{{ $t('settings.style.themes3.editor.style_author') }}
</StringSetting>
</li>
<li>
<StringSetting class="meta-field" v-model="license">
{{ $t('settings.style.themes3.editor.style_license') }}
</StringSetting>
</li>
<li>
<StringSetting class="meta-field" v-model="website">
{{ $t('settings.style.themes3.editor.style_website') }}
</StringSetting>
</li>
</ul>
</div>
</div>
<tab-switcher>
<div
key="component"
class="setting-item component-editor"
:label="$t('settings.style.themes3.editor.component_tab')"
>
<div class="component-selector">
<label for="component-selector">
{{ $t('settings.style.themes3.editor.component_selector') }}
{{ ' ' }}
</label>
<Select
id="component-selector"
v-model="selectedComponentKey"
>
<option
v-for="key in componentKeys"
:key="'component-' + key"
:value="key"
>
{{ componentsMap.get(key).name }}
</option>
</Select>
</div>
<div
v-if="selectedComponentVariants.length > 1"
class="variant-selector"
>
<label for="variant-selector">
{{ $t('settings.style.themes3.editor.variant_selector') }}
</label>
<Select
v-model="selectedVariant"
>
<option
v-for="variant in selectedComponentVariants"
:key="'component-variant-' + variant"
:value="variant"
>
{{ variant }}
</option>
</Select>
</div>
<div
v-if="selectedComponentStates.length > 0"
class="state-selector"
>
<label>
{{ $t('settings.style.themes3.editor.states_selector') }}
</label>
<ul
class="state-selector-list"
>
<li
v-for="state in selectedComponentStates"
:key="'component-state-' + state"
>
<Checkbox
:value="selectedState.has(state)"
@update:modelValue="(v) => updateSelectedStates(state, v)"
>
{{ state }}
</Checkbox>
</li>
</ul>
</div>
<div class="preview-container">
<!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="previewCss"
/>
<!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
<ComponentPreview
class="component-preview"
:show-text="componentHas('Text')"
:shadow-control="isShadowTabOpen"
:preview-class="previewClass"
:preview-style="editorHintStyle"
:preview-css="previewCss"
:disabled="!editedSubShadow && typeof editedShadow !== 'string'"
:shadow="editedSubShadow"
:no-color-control="true"
@update:shadow="({ axis, value }) => updateSubShadow(axis, value)"
/>
</div>
<tab-switcher
ref="tabSwitcher"
class="component-settings"
:on-switch="onTabSwitch"
>
<div
key="main"
class="editor-tab"
:label="$t('settings.style.themes3.editor.main_tab')"
>
<ColorInput
v-model="editedBackgroundColor"
:fallback="computeColor(editedBackgroundColor) ?? previewColors.background"
:disabled="!isBackgroundColorPresent"
:label="$t('settings.style.themes3.editor.background')"
:hide-optional-checkbox="true"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isBackgroundColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Text')"
v-model="editedTextColor"
:fallback="computeColor(editedTextColor) ?? previewColors.text"
:label="$t('settings.style.themes3.editor.text_color')"
:disabled="!isTextColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextColorPresent" />
</Tooltip>
<div
v-if="componentHas('Text')"
class="style-control suboption"
>
<label
for="textAuto"
class="label"
:class="{ faint: !isTextAutoPresent }"
>
{{ $t('settings.style.themes3.editor.text_auto.label') }}
</label>
<Select
id="textAuto"
v-model="editedTextAuto"
:disabled="!isTextAutoPresent"
>
<option value="no-preserve">
{{ $t('settings.style.themes3.editor.text_auto.no-preserve') }}
</option>
<option value="no-auto">
{{ $t('settings.style.themes3.editor.text_auto.no-auto') }}
</option>
<option value="preserve">
{{ $t('settings.style.themes3.editor.text_auto.preserve') }}
</option>
</Select>
</div>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextAutoPresent" />
</Tooltip>
<div
class="style-control suboption"
v-if="componentHas('Text')"
>
<label class="label">
{{$t('settings.style.themes3.editor.contrast') }}
</label>
<ContrastRatio
:show-ratio="true"
:contrast="contrast"
/>
</div>
<div v-if="componentHas('Text')">
</div>
<ColorInput
v-if="componentHas('Link')"
v-model="editedLinkColor"
:fallback="computeColor(editedLinkColor) ?? previewColors.link"
:label="$t('settings.style.themes3.editor.link_color')"
:disabled="!isLinkColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Link')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isLinkColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Icon')"
v-model="editedIconColor"
:fallback="computeColor(editedIconColor) ?? previewColors.icon"
:label="$t('settings.style.themes3.editor.icon_color')"
:disabled="!isIconColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Icon')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isIconColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Border')"
v-model="editedBorderColor"
:fallback="computeColor(editedBorderColor) ?? previewColors.border"
:label="$t('settings.style.themes3.editor.border_color')"
:disabled="!isBorderColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Border')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isBorderColorPresent" />
</Tooltip>
<OpacityInput
v-model="editedOpacity"
:disabled="!isOpacityPresent"
:label="$t('settings.style.themes3.editor.opacity')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isOpacityPresent" />
</Tooltip>
<RoundnessInput
v-model="editedRoundness"
:disabled="!isRoundnessPresent"
:label="$t('settings.style.themes3.editor.roundness')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isRoundnessPresent" />
</Tooltip>
</div>
<div
key="shadow"
class="editor-tab shadow-tab"
:label="$t('settings.style.themes3.editor.shadows_tab')"
>
<Checkbox
v-model="isShadowPresent"
class="style-control"
>
{{ $t('settings.style.themes3.editor.include_in_rule') }}
</checkbox>
<ShadowControl
v-model="editedShadow"
:disabled="!isShadowPresent"
:no-preview="true"
:compact="true"
:static-vars="staticVars"
@subShadowSelected="onSubShadow"
/>
</div>
</tab-switcher>
</div>
<div
key="palette"
:label="$t('settings.style.themes3.editor.palette_tab')"
class="setting-item list-editor palette-editor"
>
<label
class="list-select-label"
for="palette-selector"
>
{{ $t('settings.style.themes3.palette.label') }}
{{ ' ' }}
</label>
<Select
id="palette-selector"
v-model="selectedPaletteId"
class="list-select"
size="4"
>
<option
v-for="(p, index) in palettes"
:key="p.name"
:value="index"
>
{{ p.name }}
</option>
</Select>
<SelectMotion
class="list-select-movement"
:modelValue="palettes"
@update:modelValue="onPalettesUpdate"
:selected-id="selectedPaletteId"
:get-add-value="getNewPalette"
@update:selectedId="e => selectedPaletteId = e"
/>
<div class="list-edit-area">
<StringSetting
class="palette-name-input"
v-model="selectedPalette.name"
>
{{ $t('settings.style.themes3.palette.name_label') }}
</StringSetting>
<PaletteEditor
class="palette-editor-single"
v-model="selectedPalette"
/>
</div>
</div>
<VirtualDirectivesTab
key="variables"
:label="$t('settings.style.themes3.editor.variables_tab')"
:model-value="virtualDirectives"
@update:modelValue="updateVirtualDirectives"
:normalize-shadows="normalizeShadows"
/>
</tab-switcher>
</div>
</template>
<style src="./style_tab.scss" lang="scss"></style>

View file

@ -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
}
}

View file

@ -0,0 +1,83 @@
<script src="./virtual_directives_tab.js"></script>
<template>
<div class="setting-item list-editor variables-editor">
<label
class="list-select-label"
for="variables-selector"
>
{{ $t('settings.style.themes3.editor.variables.label') }}
{{ ' ' }}
</label>
<Select
id="variables-selector"
v-model="selectedVirtualDirectiveId"
class="list-select"
size="20"
>
<option
v-for="(p, index) in modelValue"
:key="p.name"
:value="index"
>
{{ p.name }}
</option>
</Select>
<SelectMotion
class="list-select-movement"
:model-value="modelValue"
@update:modelValue="e => emit('update:modelValue', e)"
:selected-id="selectedVirtualDirectiveId"
@update:selectedId="e => selectedVirtualDirectiveId = e"
:get-add-value="getNewVirtualDirective"
/>
<div class="list-edit-area">
<div class="variable-selector">
<label
class="variable-name-label"
for="variables-selector"
>
{{ $t('settings.style.themes3.editor.variables.name_label') }}
{{ ' ' }}
</label>
<input
class="input"
v-model="selectedVirtualDirective.name"
>
<label
class="variable-type-label"
for="variables-selector"
>
{{ $t('settings.style.themes3.editor.variables.type_label') }}
{{ ' ' }}
</label>
<Select
v-model="selectedVirtualDirectiveValType"
>
<option value='shadow'>
{{ $t('settings.style.themes3.editor.variables.type_shadow') }}
</option>
<option value='color'>
{{ $t('settings.style.themes3.editor.variables.type_color') }}
</option>
<option value='generic'>
{{ $t('settings.style.themes3.editor.variables.type_generic') }}
</option>
</Select>
</div>
<ShadowControl
v-if="selectedVirtualDirectiveValType === 'shadow'"
v-model="draftVirtualDirective"
:static-vars="staticVars"
:compact="true"
/>
<ColorInput
v-if="selectedVirtualDirectiveValType === 'color'"
v-model="draftVirtualDirective"
:fallback="computeColor(draftVirtualDirective)"
:label="$t('settings.style.themes3.editor.variables.virtual_color')"
:hide-optional-checkbox="true"
/>
</div>
</div>
</template>

View file

@ -4,9 +4,6 @@ import {
getContrastRatioLayers, getContrastRatioLayers,
relativeLuminance relativeLuminance
} from 'src/services/color_convert/color_convert.js' } from 'src/services/color_convert/color_convert.js'
import {
getThemes
} from 'src/services/style_setter/style_setter.js'
import { import {
newImporter, newImporter,
newExporter newExporter
@ -123,31 +120,24 @@ export default {
} }
}, },
created () { created () {
const self = this const currentIndex = this.$store.state.instance.themesIndex
getThemes() let promise
.then((promises) => { if (currentIndex) {
return Promise.all( promise = Promise.resolve(currentIndex)
Object.entries(promises) } else {
.map(([k, v]) => v.then(res => [k, res])) promise = this.$store.dispatch('fetchThemesIndex')
) }
})
.then(themes => themes.reduce((acc, [k, v]) => { promise.then(themesIndex => {
if (v) { Object
return { .values(themesIndex)
...acc, .forEach(themeFunc => {
[k]: v themeFunc().then(themeData => this.availableStyles.push(themeData))
} })
} else { })
return acc
}
}, {}))
.then((themesComplete) => {
self.availableStyles = themesComplete
})
}, },
mounted () { mounted () {
this.loadThemeFromLocalStorage()
if (typeof this.shadowSelected === 'undefined') { if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0] this.shadowSelected = this.shadowsAvailable[0]
} }
@ -305,6 +295,9 @@ export default {
return {} return {}
} }
}, },
themeDataUsed () {
return this.$store.state.interface.themeDataUsed
},
shadowsAvailable () { shadowsAvailable () {
return Object.keys(DEFAULT_SHADOWS).sort() return Object.keys(DEFAULT_SHADOWS).sort()
}, },
@ -412,9 +405,6 @@ export default {
forceUseSource = false forceUseSource = false
) { ) {
this.dismissWarning() this.dismissWarning()
if (!source && !theme) {
throw new Error('Can\'t load theme: empty')
}
const version = (origin === 'localStorage' && !theme.colors) const version = (origin === 'localStorage' && !theme.colors)
? 'l1' ? 'l1'
: fileVersion : fileVersion
@ -490,22 +480,11 @@ export default {
this.dismissWarning() this.dismissWarning()
}, },
loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) { loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) {
const { const theme = this.themeDataUsed?.source
customTheme: theme, if (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 {
this.loadTheme( this.loadTheme(
{ {
theme, theme
source: forceSnapshot ? theme : source
}, },
'localStorage', 'localStorage',
confirmLoadSource confirmLoadSource
@ -724,6 +703,9 @@ export default {
} }
}, },
watch: { watch: {
themeDataUsed () {
this.loadThemeFromLocalStorage()
},
currentRadii () { currentRadii () {
try { try {
this.previewTheme.radii = generateRadii({ radii: this.currentRadii }).theme.radii this.previewTheme.radii = generateRadii({ radii: this.currentRadii }).theme.radii

View file

@ -45,12 +45,16 @@
flex: 0; flex: 0;
&[type="number"] { &[type="number"] {
min-width: 5em; min-width: 9em;
&.-small {
min-width: 5em;
}
} }
&[type="range"] { &[type="range"] {
flex: 1; flex: 1;
min-width: 2em; min-width: 9em;
align-self: center; align-self: center;
margin: 0 0.5em; margin: 0 0.5em;
} }

View file

@ -187,14 +187,14 @@
name="accentColor" name="accentColor"
:fallback="previewTheme.colors?.link" :fallback="previewTheme.colors?.link"
:label="$t('settings.accent')" :label="$t('settings.accent')"
:show-optional-tickbox="typeof linkColorLocal !== 'undefined'" :show-optional-checkbox="typeof linkColorLocal !== 'undefined'"
/> />
<ColorInput <ColorInput
v-model="linkColorLocal" v-model="linkColorLocal"
name="linkColor" name="linkColor"
:fallback="previewTheme.colors?.accent" :fallback="previewTheme.colors?.accent"
:label="$t('settings.links')" :label="$t('settings.links')"
:show-optional-tickbox="typeof accentColorLocal !== 'undefined'" :show-optional-checkbox="typeof accentColorLocal !== 'undefined'"
/> />
<ContrastRatio :contrast="previewContrast.bgLink" /> <ContrastRatio :contrast="previewContrast.bgLink" />
</div> </div>
@ -957,6 +957,8 @@
v-model="currentShadow" v-model="currentShadow"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'" :separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
:fallback="currentShadowFallback" :fallback="currentShadowFallback"
:static-vars="previewTheme.colors"
:compact="true"
/> />
</div> </div>
<div <div

View file

@ -1,12 +1,17 @@
import ColorInput from 'src/components/color_input/color_input.vue' import ColorInput from 'src/components/color_input/color_input.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue' import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import Select from 'src/components/select/select.vue' 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 Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue' import Popover from 'src/components/popover/popover.vue'
import ComponentPreview from 'src/components/component_preview/component_preview.vue' import ComponentPreview from 'src/components/component_preview/component_preview.vue'
import { getCssShadow, getCssShadowFilter } from '../../services/theme_data/theme_data.service.js' import { rgb2hex } from 'src/services/color_convert/color_convert.js'
import { serializeShadow } from 'src/services/theme_data/iss_serializer.js'
import { deserializeShadow } from 'src/services/theme_data/iss_deserializer.js'
import { getCssShadow, getCssShadowFilter } from 'src/services/theme_data/css_utils.js'
import { findShadow, findColor } from 'src/services/theme_data/theme_data_3.service.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { throttle } from 'lodash' import { throttle, flattenDeep } from 'lodash'
import { import {
faTimes, faTimes,
faChevronDown, faChevronDown,
@ -21,50 +26,83 @@ library.add(
faPlus faPlus
) )
const toModel = (object = {}) => ({ const toModel = (input) => {
x: 0, if (typeof input === 'object') {
y: 0, return {
blur: 0, x: 0,
spread: 0, y: 0,
inset: false, blur: 0,
color: '#000000', spread: 0,
alpha: 1, inset: false,
...object color: '#000000',
}) alpha: 1,
...input
}
} else if (typeof input === 'string') {
return input
}
}
export default { export default {
props: [ props: [
'modelValue', 'fallback', 'separateInset', 'noPreview', 'disabled' 'modelValue',
'fallback',
'separateInset',
'noPreview',
'disabled',
'staticVars',
'compact'
], ],
emits: ['update:modelValue', 'subShadowSelected'], emits: ['update:modelValue', 'subShadowSelected'],
data () { data () {
return { return {
selectedId: 0, selectedId: 0,
// TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason) invalid: false
cValue: (this.modelValue ?? this.fallback ?? []).map(toModel)
} }
}, },
components: { components: {
ColorInput, ColorInput,
OpacityInput, OpacityInput,
Select, Select,
SelectMotion,
Checkbox, Checkbox,
Popover, Popover,
ComponentPreview ComponentPreview
}, },
beforeUpdate () {
this.cValue = (this.modelValue ?? this.fallback ?? []).map(toModel)
},
computed: { computed: {
selected () { cValue: {
const selected = this.cValue[this.selectedId] get () {
if (selected) { return (this.modelValue ?? this.fallback ?? []).map(toModel)
return { ...selected } },
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 () { present () {
return this.selected != null && !this.usingFallback return this.selected != null && this.modelValue != null
}, },
shadowsAreNull () { shadowsAreNull () {
return this.modelValue == null return this.modelValue == null
@ -72,24 +110,43 @@ export default {
currentFallback () { currentFallback () {
return this.fallback?.[this.selectedId] return this.fallback?.[this.selectedId]
}, },
moveUpValid () { getColorFallback () {
return this.selectedId > 0 if (this.staticVars && this.selected?.color) {
}, try {
moveDnValid () { const computedColor = findColor(this.selected.color, { dynamicVars: {}, staticVars: this.staticVars }, true)
return this.selectedId < this.cValue.length - 1 if (computedColor) return rgb2hex(computedColor)
}, return null
usingFallback () { } catch (e) {
return this.modelValue == null console.warn(e)
return null
}
} else {
return this.currentFallback?.color
}
}, },
style () { style () {
if (this.separateInset) { try {
return { let result
filter: getCssShadowFilter(this.cValue), const serialized = this.cValue.map(x => serializeShadow(x)).join(',')
boxShadow: getCssShadow(this.cValue, true) 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)
}
} }
} this.invalid = false
return { return result
boxShadow: getCssShadow(this.cValue) } catch (e) {
console.error('Invalid shadow', e)
this.invalid = true
} }
} }
}, },
@ -99,34 +156,25 @@ export default {
} }
}, },
methods: { 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) { updateProperty: throttle(function (prop, value) {
this.cValue[this.selectedId][prop] = value this.cValue[this.selectedId][prop] = value
if (prop === 'inset' && value === false && this.separateInset) { if (prop === 'inset' && value === false && this.separateInset) {
this.cValue[this.selectedId].spread = 0 this.cValue[this.selectedId].spread = 0
} }
this.$emit('update:modelValue', this.cValue) this.$emit('update:modelValue', this.cValue)
}, 100), }, 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)
}
} }
} }

View file

@ -1,11 +1,29 @@
.settings-modal .settings-modal-panel .shadow-control { .ShadowControl {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 10em 1fr 1fr;
grid-template-rows: 1fr;
grid-template-areas: "selector preview tweak";
grid-gap: 0.5em;
justify-content: stretch; 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 { .shadow-switcher {
grid-area: selector;
order: 1; order: 1;
flex: 1 0 6em; flex: 1 0 6em;
min-width: 6em; min-width: 6em;
@ -16,27 +34,18 @@
.shadow-list { .shadow-list {
flex: 1 0 auto; 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 { .shadow-tweak {
grid-area: tweak;
order: 3; order: 3;
flex: 2 0 10em; flex: 2 0 10em;
min-width: 10em; min-width: 10em;
margin-left: 0.125em; margin-left: 0.125em;
margin-right: 0.125em; margin-right: 0.125em;
display: grid;
grid-template-rows: auto 1fr;
grid-gap: 0.25em;
/* hack */ /* hack */
.input-boolean { .input-boolean {
@ -52,6 +61,11 @@
flex: 1 0 5em; flex: 1 0 5em;
} }
.shadow-expression {
width: 100%;
height: 100%;
}
.id-control { .id-control {
align-items: stretch; align-items: stretch;
@ -69,6 +83,10 @@
} }
&.-no-preview { &.-no-preview {
grid-template-columns: 10em 1fr;
grid-template-rows: 1fr;
grid-template-areas: "selector tweak";
.shadow-tweak { .shadow-tweak {
order: 0; order: 0;
flex: 2 0 8em; flex: 2 0 8em;
@ -91,15 +109,14 @@
} }
.shadow-preview { .shadow-preview {
order: 2; grid-area: preview;
flex: 3 3 15em; min-width: 25em;
min-width: 10em;
margin-left: 0.125em; margin-left: 0.125em;
align-self: start; align-self: start;
justify-self: center;
} }
} }
.inset-tooltip { .inset-tooltip {
padding: 0.5em;
max-width: 30em; max-width: 30em;
} }

View file

@ -1,10 +1,11 @@
<template> <template>
<div <div
class="label shadow-control" class="ShadowControl label shadow-control"
:class="{ disabled: disabled || !present, '-no-preview': noPreview }" :class="{ disabled: disabled || !present, '-no-preview': noPreview, '-compact': compact }"
> >
<ComponentPreview <ComponentPreview
v-if="!noPreview" v-if="!noPreview"
:invalid="invalid"
class="shadow-preview" class="shadow-preview"
:shadow-control="true" :shadow-control="true"
:shadow="selected" :shadow="selected"
@ -17,8 +18,8 @@
id="shadow-list" id="shadow-list"
v-model="selectedId" v-model="selectedId"
class="shadow-list" class="shadow-list"
size="10" size="4"
:disabled="shadowsAreNull" :disabled="disabled || shadowsAreNull"
> >
<option <option
v-for="(shadow, index) in cValue" v-for="(shadow, index) in cValue"
@ -26,227 +27,208 @@
:value="index" :value="index"
:class="{ '-active': index === Number(selectedId) }" :class="{ '-active': index === Number(selectedId) }"
> >
{{ shadow?.name ?? $t('settings.style.shadows.shadow_id', { value: index }) }} {{ getSubshadowLabel(shadow, index) }}
</option> </option>
</Select> </Select>
<div <SelectMotion
class="id-control btn-group arrange-buttons" v-model="cValue"
> :selected-id="selectedId"
<button :get-add-value="getNewSubshadow"
class="btn button-default" :disabled="disabled"
:disabled="disabled || shadowsAreNull" @update:selectedId="onSelectChange"
@click="add" />
>
<FAIcon
fixed-width
icon="plus"
/>
</button>
<button
class="btn button-default"
:disabled="disabled || !moveUpValid"
:class="{ disabled: disabled || !moveUpValid }"
@click="moveUp"
>
<FAIcon
fixed-width
icon="chevron-up"
/>
</button>
<button
class="btn button-default"
:disabled="disabled || !moveDnValid"
:class="{ disabled: disabled || !moveDnValid }"
@click="moveDn"
>
<FAIcon
fixed-width
icon="chevron-down"
/>
</button>
<button
class="btn button-default"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
@click="del"
>
<FAIcon
fixed-width
icon="times"
/>
</button>
</div>
</div> </div>
<div class="shadow-tweak"> <div class="shadow-tweak">
<div <Select
:class="{ disabled: disabled || !present }" v-model="selectedType"
class="name-control style-control" :disabled="disabled || !present"
> >
<label <option value="object">
for="name" {{ $t('settings.style.shadows.raw') }}
class="label" </option>
:class="{ faint: disabled || !present }" <option value="string">
> {{ $t('settings.style.shadows.expression') }}
{{ $t('settings.style.shadows.name') }} </option>
</label> </Select>
<input <template v-if="selectedType === 'string'">
id="name" <textarea
:value="selected?.name" v-model="selected"
:disabled="disabled || !present" class="input shadow-expression"
:disabled="disabled || shadowsAreNull"
:class="{disabled: disabled || shadowsAreNull}"
/>
</template>
<template v-else-if="selectedType === 'object'">
<div
:class="{ disabled: disabled || !present }" :class="{ disabled: disabled || !present }"
name="name" class="name-control style-control"
class="input input-string"
@input="e => updateProperty('name', e.target.value)"
> >
</div> <label
<div for="name"
:disabled="disabled || !present" class="label"
class="inset-control style-control" :class="{ faint: disabled || !present }"
>
<Checkbox
id="inset"
:value="selected?.inset"
:disabled="disabled || !present"
name="inset"
class="input-inset input-boolean"
@input="e => updateProperty('inset', e.target.checked)"
>
<template #before>
{{ $t('settings.style.shadows.inset') }}
</template>
</Checkbox>
</div>
<div
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
class="blur-control style-control"
>
<label
for="blur"
class="label"
:class="{ faint: disabled || !present }"
>
{{ $t('settings.style.shadows.blur') }}
</label>
<input
id="blur"
:value="selected?.blur"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
name="blur"
class="input input-range"
type="range"
max="20"
min="0"
@input="e => updateProperty('blur', e.target.value)"
>
<input
:value="selected?.blur"
class="input input-number -small"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
type="number"
min="0"
@input="e => updateProperty('blur', e.target.value)"
>
</div>
<div
class="spread-control style-control"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
>
<label
for="spread"
class="label"
:class="{ faint: disabled || !present || (separateInset && !selected?.inset) }"
>
{{ $t('settings.style.shadows.spread') }}
</label>
<input
id="spread"
:value="selected?.spread"
:disabled="disabled || !present || (separateInset && !selected?.inset)"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
name="spread"
class="input input-range"
type="range"
max="20"
min="-20"
@input="e => updateProperty('spread', e.target.value)"
>
<input
:value="selected?.spread"
class="input input-number -small"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
:disabled="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
type="number"
@input="e => updateProperty('spread', e.target.value)"
>
</div>
<ColorInput
:model-value="selected?.color"
:disabled="disabled || !present"
:label="$t('settings.style.common.color')"
:fallback="currentFallback?.color"
:show-optional-tickbox="false"
name="shadow"
@update:modelValue="e => updateProperty('color', e)"
/>
<OpacityInput
:model-value="selected?.alpha"
:disabled="disabled || !present"
@update:modelValue="e => updateProperty('alpha', e)"
/>
<i18n-t
scope="global"
keypath="settings.style.shadows.hintV3"
:class="{ faint: disabled || !present }"
tag="p"
>
<code>--variable,mod</code>
</i18n-t>
<Popover
v-if="separateInset"
trigger="hover"
>
<template #trigger>
<div
class="inset-alert alert warning"
> >
<FAIcon icon="exclamation-triangle" /> {{ $t('settings.style.shadows.name') }}
&nbsp; </label>
{{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }} <input
</div> id="name"
</template> :value="selected?.name"
<template #content> :disabled="disabled || !present"
<div class="inset-tooltip"> :class="{ disabled: disabled || !present }"
<i18n-t name="name"
scope="global" class="input input-string"
keypath="settings.style.shadows.filter_hint.always_drop_shadow" @input="e => updateProperty('name', e.target.value)"
tag="p" >
</div>
<div
:disabled="disabled || !present"
class="inset-control style-control"
>
<Checkbox
id="inset"
:value="selected?.inset"
:disabled="disabled || !present"
name="inset"
class="input-inset input-boolean"
@input="e => updateProperty('inset', e.target.checked)"
>
<template #before>
{{ $t('settings.style.shadows.inset') }}
</template>
</Checkbox>
</div>
<div
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
class="blur-control style-control"
>
<label
for="blur"
class="label"
:class="{ faint: disabled || !present }"
>
{{ $t('settings.style.shadows.blur') }}
</label>
<input
id="blur"
:value="selected?.blur"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
name="blur"
class="input input-range"
type="range"
max="20"
min="0"
@input="e => updateProperty('blur', e.target.value)"
>
<input
:value="selected?.blur"
class="input input-number -small"
:disabled="disabled || !present"
:class="{ disabled: disabled || !present }"
type="number"
min="0"
@input="e => updateProperty('blur', e.target.value)"
>
</div>
<div
class="spread-control style-control"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
>
<label
for="spread"
class="label"
:class="{ faint: disabled || !present || (separateInset && !selected?.inset) }"
>
{{ $t('settings.style.shadows.spread') }}
</label>
<input
id="spread"
:value="selected?.spread"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
:disabled="disabled || !present || (separateInset && !selected?.inset)"
name="spread"
class="input input-range"
type="range"
max="20"
min="-20"
@input="e => updateProperty('spread', e.target.value)"
>
<input
:value="selected?.spread"
class="input input-number -small"
:class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
:disabled="disabled || !present || (separateInset && !selected?.inset)"
type="number"
@input="e => updateProperty('spread', e.target.value)"
>
</div>
<ColorInput
:model-value="selected?.color"
:disabled="disabled || !present"
:label="$t('settings.style.common.color')"
:fallback="getColorFallback"
:show-optional-checkbox="false"
name="shadow"
@update:modelValue="e => updateProperty('color', e)"
/>
<OpacityInput
:model-value="selected?.alpha"
:disabled="disabled || !present"
@update:modelValue="e => updateProperty('alpha', e)"
/>
<i18n-t
scope="global"
keypath="settings.style.shadows.hintV3"
:class="{ faint: disabled || !present }"
tag="p"
>
<code>--variable,mod</code>
</i18n-t>
<Popover
v-if="separateInset"
trigger="hover"
>
<template #trigger>
<div
class="inset-alert alert warning"
> >
<code>filter: drop-shadow()</code> <FAIcon icon="exclamation-triangle" />
</i18n-t> &nbsp;
<p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p> {{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }}
<i18n-t </div>
scope="global" </template>
keypath="settings.style.shadows.filter_hint.drop_shadow_syntax" <template #content>
tag="p" <div class="inset-tooltip tooltip">
> <i18n-t
<code>drop-shadow</code> scope="global"
<code>spread-radius</code> keypath="settings.style.shadows.filter_hint.always_drop_shadow"
<code>inset</code> tag="p"
</i18n-t> >
<i18n-t <code>filter: drop-shadow()</code>
scope="global" </i18n-t>
keypath="settings.style.shadows.filter_hint.inset_classic" <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
tag="p" <i18n-t
> scope="global"
<code>box-shadow</code> keypath="settings.style.shadows.filter_hint.drop_shadow_syntax"
</i18n-t> tag="p"
<p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p> >
</div> <code>drop-shadow</code>
</template> <code>spread-radius</code>
</Popover> <code>inset</code>
</i18n-t>
<i18n-t
scope="global"
keypath="settings.style.shadows.filter_hint.inset_classic"
tag="p"
>
<code>box-shadow</code>
</i18n-t>
<p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
</div>
</template>
</Popover>
</template>
</div> </div>
</div> </div>
</template> </template>

View file

@ -14,14 +14,14 @@ export default {
{ {
directives: { directives: {
background: '--fg', background: '--fg',
shadow: ['--defaultButtonShadow', '--defaultButtonBevel'], shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3 roundness: 3
} }
}, },
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel']
} }
}, },
{ {
@ -33,14 +33,14 @@ export default {
{ {
state: ['hover', 'active'], state: ['hover', 'active'],
directives: { directives: {
shadow: ['--defaultButtonShadow', '--defaultButtonBevel'] shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel']
} }
}, },
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground, 0.25, --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--defaultButtonBevel'] shadow: ['--buttonDefaultBevel']
} }
}, },
{ {

View file

@ -119,7 +119,7 @@
.tab { .tab {
flex: 1; flex: 1;
box-sizing: content-box; box-sizing: content-box;
min-width: 10em; max-width: 9em;
min-width: 1px; min-width: 1px;
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
@ -128,12 +128,22 @@
margin-right: -200px; margin-right: -200px;
margin-left: 1em; margin-left: 1em;
&:not(.active) {
margin-top: 0;
margin-left: 1.5em;
}
@media all and (max-width: 800px) { @media all and (max-width: 800px) {
padding-left: 0.25em; padding-left: 0.25em;
padding-right: calc(0.25em + 200px); padding-right: calc(0.25em + 200px);
margin-right: calc(0.25em - 200px); margin-right: calc(0.25em - 200px);
margin-left: 0.25em; margin-left: 0.25em;
&:not(.active) {
margin-top: 0;
margin-left: 0.5em;
}
.text { .text {
display: none; display: none;
} }
@ -181,6 +191,7 @@
&:not(.active) { &:not(.active) {
z-index: 4; z-index: 4;
margin-top: 0.25em;
&:hover { &:hover {
z-index: 6; z-index: 6;

View file

@ -0,0 +1,24 @@
<template>
<Popover trigger="hover">
<template #trigger>
<slot />
</template>
<template #content>
<div class="tooltip">
{{ props.text }}
</div>
</template>
</Popover>
</template>
<script setup>
import Popover from 'src/components/popover/popover.vue'
const props = defineProps(['text'])
</script>
<style lang="scss">
.tooltip {
margin: 0.5em 1em;
}
</style>

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'UserCard', name: 'UserCard',
selector: '.user-card', selector: '.user-card',
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Text', 'Text',
'Link', 'Link',
@ -25,7 +26,7 @@ export default {
color: '#000000', color: '#000000',
alpha: 0.6 alpha: 0.6
}], }],
'--profileTint': 'color | $alpha(--background, 0.5)' '--profileTint': 'color | $alpha(--background 0.5)'
} }
}, },
{ {

View file

@ -701,6 +701,7 @@
"use_websockets": "Use websockets (Realtime updates)", "use_websockets": "Use websockets (Realtime updates)",
"text": "Text", "text": "Text",
"theme": "Theme", "theme": "Theme",
"theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.", "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_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.", "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", "more_settings": "More settings",
"style": { "style": {
"custom_theme_used": "(Custom theme)", "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.", "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", "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", "update_preview": "Update preview",
"themes3": { "themes3": {
"define": "Override", "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": { "hacks": {
"underlay_overrides": "Change underlay", "underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default", "underlay_override_mode_none": "Theme default",
@ -877,11 +948,18 @@
"override": "Override", "override": "Override",
"shadow_id": "Shadow #{value}", "shadow_id": "Shadow #{value}",
"offset": "Shadow offset", "offset": "Shadow offset",
"zoom": "Zoom",
"offset-x": "x:",
"offset-y": "y:",
"light_grid": "Use light checkerboard", "light_grid": "Use light checkerboard",
"color_override": "Use different color",
"name": "Name", "name": "Name",
"blur": "Blur", "blur": "Blur",
"spread": "Spread", "spread": "Spread",
"inset": "Inset", "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.", "hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": { "filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.", "always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",

View file

@ -47,6 +47,10 @@ export const defaultState = {
customThemeSource: undefined, // "source", stores original theme data customThemeSource: undefined, // "source", stores original theme data
// V3 // 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 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 forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
theme3hacks: { // Hacks, user overrides that are independent of theme used theme3hacks: { // Hacks, user overrides that are independent of theme used

View file

@ -42,6 +42,9 @@ const defaultState = {
registrationOpen: true, registrationOpen: true,
server: 'http://localhost:4040/', server: 'http://localhost:4040/',
textlimit: 5000, textlimit: 5000,
themesIndex: undefined,
stylesIndex: undefined,
palettesIndex: undefined,
themeData: undefined, // used for theme editor v2 themeData: undefined, // used for theme editor v2
vapidPublicKey: undefined, vapidPublicKey: undefined,
@ -96,6 +99,8 @@ const defaultState = {
sidebarRight: false, sidebarRight: false,
subjectLineBehavior: 'email', subjectLineBehavior: 'email',
theme: 'pleroma-dark', theme: 'pleroma-dark',
palette: null,
style: null,
emojiReactionsScale: 0.5, emojiReactionsScale: 0.5,
textSize: '14px', textSize: '14px',
emojiSize: '2.2rem', emojiSize: '2.2rem',

View file

@ -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 { CURRENT_VERSION, generatePreset } from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.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 = { const defaultState = {
localFonts: null, localFonts: null,
themeApplied: false, 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 temporaryChangesTimeoutId: null, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {}, // used for applying temporary options temporaryChangesConfirm: () => {}, // used for applying temporary options
temporaryChangesRevert: () => {}, // used for reverting temporary options temporaryChangesRevert: () => {}, // used for reverting temporary options
@ -212,142 +225,450 @@ const interfaceMod = {
setLastTimeline ({ commit }, value) { setLastTimeline ({ commit }, value) {
commit('setLastTimeline', 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 { const {
theme: instanceThemeName style: instanceStyleName,
palette: instancePaletteName
} = rootState.instance
let {
theme: instanceThemeV2Name,
themesIndex,
stylesIndex,
palettesIndex
} = rootState.instance } = rootState.instance
const { const {
theme: userThemeName, style: userStyleName,
customTheme: userThemeSnapshot, styleCustomData: userStyleCustomData,
customThemeSource: userThemeSource, 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, forceThemeRecompilation,
themeDebug, themeDebug,
theme3hacks theme3hacks
} = rootState.config } = 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 // If we're not not forced to recompile try using
// cache (tryLoadCache return true if load successful) // cache (tryLoadCache return true if load successful)
if (!forceRecompile && !themeDebug && tryLoadCache()) {
commit('setThemeApplied') const forceRecompile = forceThemeRecompilation || recompile
return if (!forceRecompile && !themeDebug && await tryLoadCache()) {
return commit('setThemeApplied')
} }
await dispatch('getThemeData')
promise const paletteIss = (() => {
.then(realThemeData => { if (!state.paletteDataUsed) return null
const theme2ruleset = convertTheme2To3(realThemeData) const result = {
component: 'Root',
directives: {}
}
if (saveData) { Object
commit('setOption', { name: 'theme', value: themeName || actualThemeName }) .entries(state.paletteDataUsed)
commit('setOption', { name: 'customTheme', value: realThemeData }) .filter(([k]) => k !== 'name')
commit('setOption', { name: 'customThemeSource', value: realThemeData }) .forEach(([k, v]) => {
} let issRootDirectiveName
const hacks = [] switch (k) {
case 'background':
Object.entries(theme3hacks).forEach(([key, value]) => { issRootDirectiveName = 'bg'
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 break
} case 'foreground':
case 'underlay': { issRootDirectiveName = 'fg'
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 break
} default:
issRootDirectiveName = k
} }
result.directives['--' + issRootDirectiveName] = 'color | ' + v
}) })
return result
})()
const ruleset = [ const theme2ruleset = state.themeDataUsed && convertTheme2To3(normalizeThemeData(state.themeDataUsed))
...theme2ruleset, const hacks = []
...hacks
]
applyTheme( Object.entries(theme3hacks).forEach(([key, value]) => {
ruleset, switch (key) {
() => commit('setThemeApplied'), case 'fonts': {
themeDebug 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 default interfaceMod
export const normalizeThemeData = (input) => { 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 let themeData, themeSource
if (input.themeFileVerison === 1) { if (input.themeFileVerison === 1) {
@ -381,7 +689,10 @@ export const normalizeThemeData = (input) => {
// We got passed a full theme file // We got passed a full theme file
themeData = input.theme themeData = input.theme
themeSource = input.source 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 // We got passed a source/snapshot
themeData = input themeData = input
themeSource = input themeSource = input

View file

@ -2,15 +2,23 @@ import utf8 from 'utf8'
export const newExporter = ({ export const newExporter = ({
filename = 'data', filename = 'data',
mime = 'application/json',
extension = '.json',
getExportedObject getExportedObject
}) => ({ }) => ({
exportData () { 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 // Create an invisible link with a data url and simulate a click
const e = document.createElement('a') const e = document.createElement('a')
e.setAttribute('download', `${filename}.json`) const realFilename = typeof filename === 'function' ? filename() : filename
e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified)) e.setAttribute('download', `${realFilename}.${extension}`)
e.setAttribute('href', `data:${mime};base64, ${window.btoa(stringified)}`)
e.style.display = 'none' e.style.display = 'none'
document.body.appendChild(e) document.body.appendChild(e)
@ -20,6 +28,8 @@ export const newExporter = ({
}) })
export const newImporter = ({ export const newImporter = ({
accept = '.json',
parser = (string) => JSON.parse(string),
onImport, onImport,
onImportFailure, onImportFailure,
validator = () => true validator = () => true
@ -27,18 +37,19 @@ export const newImporter = ({
importData () { importData () {
const filePicker = document.createElement('input') const filePicker = document.createElement('input')
filePicker.setAttribute('type', 'file') filePicker.setAttribute('type', 'file')
filePicker.setAttribute('accept', '.json') filePicker.setAttribute('accept', accept)
filePicker.addEventListener('change', event => { filePicker.addEventListener('change', event => {
if (event.target.files[0]) { if (event.target.files[0]) {
const filename = event.target.files[0].name
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({ target }) => { reader.onload = ({ target }) => {
try { try {
const parsed = JSON.parse(target.result) const parsed = parser(target.result, filename)
const validationResult = validator(parsed) const validationResult = validator(parsed, filename)
if (validationResult === true) { if (validationResult === true) {
onImport(parsed) onImport(parsed, filename)
} else { } else {
onImportFailure({ validationResult }) onImportFailure({ validationResult })
} }

View file

@ -1,8 +1,9 @@
import { hex2rgb } from '../color_convert/color_convert.js'
import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js' import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
import { getCssRules } from '../theme_data/css_utils.js' import { getCssRules } from '../theme_data/css_utils.js'
import { defaultState } from '../../modules/config.js' import { defaultState } from '../../modules/config.js'
import { chunk } from 'lodash' import { chunk } from 'lodash'
import pako from 'pako'
import localforage from 'localforage'
// On platforms where this is not supported, it will return undefined // On platforms where this is not supported, it will return undefined
// Otherwise it will return an array // Otherwise it will return an array
@ -52,29 +53,12 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
const themes3 = init({ const themes3 = init({
inputRuleset, 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 debug
}) })
getCssRules(themes3.eager, debug).forEach(rule => { getCssRules(themes3.eager, debug).forEach(rule => {
// Hacks to support multiple selectors on same component // Hacks to support multiple selectors on same component
if (rule.match(/::-webkit-scrollbar-button/)) { onNewRule(rule, false)
const parts = rule.split(/[{}]/g)
const newRule = [
parts[0],
', ',
parts[0].replace(/button/, 'thumb'),
', ',
parts[0].replace(/scrollbar-button/, 'resizer'),
' {',
parts[1],
'}'
].join('')
onNewRule(newRule, false)
} else {
onNewRule(rule, false)
}
}) })
onEagerFinished() onEagerFinished()
@ -88,22 +72,7 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
const chunk = chunks[counter] const chunk = chunks[counter]
Promise.all(chunk.map(x => x())).then(result => { Promise.all(chunk.map(x => x())).then(result => {
getCssRules(result.filter(x => x), debug).forEach(rule => { getCssRules(result.filter(x => x), debug).forEach(rule => {
if (rule.match(/\.modal-view/)) { onNewRule(rule, true)
const parts = rule.split(/[{}]/g)
const newRule = [
parts[0],
', ',
parts[0].replace(/\.modal-view/, '#modal'),
', ',
parts[0].replace(/\.modal-view/, '.shout-panel'),
' {',
parts[1],
'}'
].join('')
onNewRule(newRule, true)
} else {
onNewRule(rule, true)
}
}) })
// const t1 = performance.now() // const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms') // console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
@ -120,12 +89,15 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
return { lazyProcessFunc: processChunk } return { lazyProcessFunc: processChunk }
} }
export const tryLoadCache = () => { export const tryLoadCache = async () => {
const json = localStorage.getItem('pleroma-fe-theme-cache') console.info('Trying to load compiled theme data from cache')
if (!json) return null const data = await localforage.getItem('pleromafe-theme-cache')
if (!data) return null
let cache let cache
try { 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) { } catch (e) {
console.error('Failed to decode theme cache:', e) console.error('Failed to decode theme cache:', e)
return false return false
@ -150,16 +122,28 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID) const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
const lazyStyles = createStyleSheet(LAZY_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( const { lazyProcessFunc } = generateTheme(
input, input,
{ {
onNewRule (rule, isLazy) { onNewRule (rule, isLazy) {
if (isLazy) { if (isLazy) {
lazyStyles.sheet.insertRule(rule, 'index-max') insertRule(lazyStyles, rule)
lazyStyles.rules.push(rule)
} else { } else {
eagerStyles.sheet.insertRule(rule, 'index-max') insertRule(eagerStyles, rule)
eagerStyles.rules.push(rule)
} }
}, },
onEagerFinished () { onEagerFinished () {
@ -169,16 +153,10 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
adoptStyleSheets([eagerStyles, lazyStyles]) adoptStyleSheets([eagerStyles, lazyStyles])
const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] } const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] }
onFinish(cache) onFinish(cache)
try { const compress = (js) => {
localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache)) return pako.deflate(JSON.stringify(js))
} 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)
}
} }
localforage.setItem('pleromafe-theme-cache', compress(cache))
} }
}, },
debug debug
@ -252,64 +230,66 @@ export const applyConfig = (input, i18n) => {
styleSheet.toString() styleSheet.toString()
styleSheet.insertRule(`:root { ${rules} }`, 'index-max') 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')) { if (Object.prototype.hasOwnProperty.call(config, 'forcedRoundness')) {
styleSheet.insertRule(` * { styleSheet.insertRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important; --roundness: var(--forcedRoundness) !important;
}`, 'index-max') }`, 'index-max')
} }
} }
export const getThemes = () => { export const getResourcesIndex = async (url, parser = JSON.parse) => {
const cache = 'no-store' const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
return window.fetch('/static/styles.json', { cache }) const resourceTransform = (resources) => {
.then((data) => data.json()) return Object
.then((themes) => { .entries(resources)
return Object.entries(themes).map(([k, v]) => { .map(([k, v]) => {
let promise = null
if (typeof v === 'object') { if (typeof v === 'object') {
promise = Promise.resolve(v) return [k, () => Promise.resolve(v)]
} else if (typeof v === 'string') { } else if (typeof v === 'string') {
promise = window.fetch(v, { cache }) return [
.then((data) => data.json()) k,
.catch((e) => { () => window
console.error(e) .fetch(v, { cache })
return null .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 try {
.reduce((acc, [k, v]) => { const builtinData = await window.fetch(url, { cache })
acc[k] = v const builtinResources = await builtinData.json()
return acc builtin = resourceTransform(builtinResources)
}, {}) } catch (e) {
}) builtin = []
} console.warn(`Builtin resources at ${url} unavailable`)
}
export const getPreset = (val) => {
return getThemes() try {
.then((themes) => themes[val] ? themes[val] : themes['pleroma-dark']) const customData = await window.fetch(customUrl, { cache })
.then((theme) => { const customResources = await customData.json()
const isV1 = Array.isArray(theme) custom = resourceTransform(customResources)
const data = isV1 ? {} : theme.theme } catch (e) {
custom = []
if (isV1) { console.warn(`Custom resources at ${customUrl} unavailable`)
const bg = hex2rgb(theme[1]) }
const fg = hex2rgb(theme[2])
const text = hex2rgb(theme[3]) const total = [...custom, ...builtin]
const link = hex2rgb(theme[4]) if (total.length === 0) {
return Promise.reject(new Error(`Resource at ${url} and ${customUrl} completely unavailable. Panicking`))
const cRed = hex2rgb(theme[5] || '#FF0000') }
const cGreen = hex2rgb(theme[6] || '#00FF00') return Promise.resolve(Object.fromEntries(total))
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 }
})
} }

View file

@ -2,25 +2,6 @@ import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js' 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 getCssColorString = (color, alpha = 1) => rgba2css({ ...convert(color).rgb, a: alpha })
export const getCssShadow = (input, usesDropShadow) => { export const getCssShadow = (input, usesDropShadow) => {
@ -84,6 +65,9 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
].join(';\n ') ].join(';\n ')
} }
case 'shadow': { case 'shadow': {
if (!rule.dynamicVars.shadow) {
return ''
}
return ' ' + [ return ' ' + [
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow), '--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow), '--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow),
@ -98,7 +82,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
` `
} }
if (v === 'transparent') { if (v === 'transparent') {
if (rule.component === 'Root') return [] if (rule.component === 'Root') return null
return [ return [
rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '', rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '',
' --background: ' + v ' --background: ' + v
@ -130,7 +114,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
} }
default: default:
if (k.startsWith('--')) { 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) { switch (type) {
case 'color': { case 'color': {
const color = rule.dynamicVars[k] const color = rule.dynamicVars[k]
@ -143,21 +127,20 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
case 'generic': case 'generic':
return k + ': ' + value return k + ': ' + value
default: default:
return '' return null
} }
} }
return '' return null
} }
}).filter(x => x).map(x => ' ' + x).join(';\n') }).filter(x => x).map(x => ' ' + x + ';').join('\n')
return [ return [
header, header,
directives + ';', directives,
(rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '', (rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '',
'',
virtualDirectives, virtualDirectives,
footer footer
].join('\n') ].filter(x => x).join('\n')
}).filter(x => x) }).filter(x => x)
export const getScopedVersion = (rules, newScope) => { export const getScopedVersion = (rules, newScope) => {

View file

@ -1,10 +1,11 @@
import { flattenDeep } from 'lodash' import { flattenDeep } from 'lodash'
const parseShadow = string => { export const deserializeShadow = string => {
const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha'] const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha', 'name']
const regexPrep = [ const regexPrep = [
// inset keyword (optional) // inset keyword (optional)
'^(?:(inset)\\s+)?', '^',
'(?:(inset)\\s+)?',
// x // x
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)', '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
// y // y
@ -14,19 +15,31 @@ const parseShadow = string => {
// spread (optional) // spread (optional)
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?', '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
// either hex, variable or function // 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) // opacity (optional)
'(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?$' '(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?',
// name
'(?:\\s+#(\\w+)\\s*)?',
'$'
].join('') ].join('')
const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string
const result = regex.exec(string) const result = regex.exec(string)
if (result == null) { if (result == null) {
return string if (string.startsWith('$') || string.startsWith('--')) {
return string
} else {
throw new Error(`Invalid shadow definition: '${string}'`)
}
} else { } else {
const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha']) 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)) { 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') { } else if (mode === 'inset') {
return [mode, !!result[i]] return [mode, !!result[i]]
} else { } else {
@ -34,7 +47,7 @@ const parseShadow = string => {
} }
}).filter(([k, v]) => v !== false).slice(1)) }).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 // this works nearly the same as HTML tree converter
@ -136,12 +149,12 @@ export const deserialize = (input) => {
output.directives = Object.fromEntries(content.map(d => { output.directives = Object.fromEntries(content.map(d => {
const [property, value] = d.split(':') const [property, value] = d.split(':')
let realValue = value.trim() let realValue = (value || '').trim()
if (property === 'shadow') { if (property === 'shadow') {
if (realValue === 'none') { if (realValue === 'none') {
realValue = [] realValue = []
} else { } else {
realValue = value.split(',').map(v => parseShadow(v.trim())) realValue = value.split(',').map(v => deserializeShadow(v.trim()))
} }
} if (!Number.isNaN(Number(value))) { } if (!Number.isNaN(Number(value))) {
realValue = Number(value) realValue = Number(value)

View file

@ -1,8 +1,13 @@
import { unroll } from './iss_utils.js' import { unroll } from './iss_utils.js'
import { deserializeShadow } from './iss_deserializer.js'
const serializeShadow = s => { export const serializeShadow = (s, throwOnInvalid) => {
if (typeof s === 'object') { 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 { } else {
return s return s
} }

View file

@ -56,43 +56,74 @@ export const getAllPossibleCombinations = (array) => {
* *
* @returns {String} CSS selector (or path) * @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 if (!rule && !isParent) return null
const component = components[rule.component] 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') const applicableVariantName = (rule.variant || 'normal')
let applicableVariant = '' let variantSelectors = null
if (applicableVariantName !== 'normal') { if (applicableVariantName !== 'normal') {
applicableVariant = variants[applicableVariantName] variantSelectors = variants[applicableVariantName]
} else { } else {
applicableVariant = variants?.normal ?? '' variantSelectors = variants?.normal ?? ''
} }
variantSelectors = Array.isArray(variantSelectors) ? variantSelectors : [variantSelectors]
if (ignoreOutOfTreeSelector || liteMode) variantSelectors = [variantSelectors[0]]
let realSelector const applicableStates = (rule.state || []).filter(x => x !== 'normal')
if (selector === ':root') { // const applicableStates = (rule.state || [])
realSelector = '' const statesSelectors = applicableStates.map(state => {
} else if (isParent) { const selector = states[state] || ''
realSelector = selector let arraySelector = Array.isArray(selector) ? selector : [selector]
} else { if (ignoreOutOfTreeSelector || liteMode) arraySelector = [arraySelector[0]]
if (outOfTreeSelector && !ignoreOutOfTreeSelector) realSelector = outOfTreeSelector arraySelector
else realSelector = selector .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] const statesSelectorsFlat = statesSelectors.reduce((acc, s) => {
.sort((a, b) => { return expand(acc, s).map(st => st.join(''))
if (a.startsWith(':')) return 1 }, [])
if (/^[a-z]/.exec(a)) return -1
else return 0 const componentVariant = expand(componentSelectors, variantSelectors).map(cv => cv.join(''))
}) const componentVariantStates = expand(componentVariant, statesSelectorsFlat).map(cvs => cvs.join(''))
.join('') const selectors = expand(componentVariantStates, children).map(cvsc => cvsc.join(' '))
/*
*/
if (rule.parent) { 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()
} }
/** /**

View file

@ -354,10 +354,6 @@ export const convertTheme2To3 = (data) => {
newRules.push({ ...rule, state: ['toggled'] }) newRules.push({ ...rule, state: ['toggled'] })
newRules.push({ ...rule, state: ['toggled', 'focus'] }) newRules.push({ ...rule, state: ['toggled', 'focus'] })
newRules.push({ ...rule, state: ['pressed', '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: ['toggled', 'focus', 'hover'] })
newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] }) newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] })
} }

View file

@ -3,7 +3,7 @@ import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/co
export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => { export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => {
const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#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] const func = functions[funcName]
if (args.length < func.argsNeeded) { if (args.length < func.argsNeeded) {
@ -15,6 +15,11 @@ export const process = (text, functions, { findColor, findShadow }, { dynamicVar
export const colorFunctions = { export const colorFunctions = {
alpha: { alpha: {
argsNeeded: 2, 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 }) => { exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [color, amountArg] = args const [color, amountArg] = args
@ -23,8 +28,32 @@ export const colorFunctions = {
return { ...colorArg, a: amount } 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: { textColor: {
argsNeeded: 2, 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 }) => { exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [backgroundArg, foregroundArg, preserve = 'preserve'] = args const [backgroundArg, foregroundArg, preserve = 'preserve'] = args
@ -36,6 +65,12 @@ export const colorFunctions = {
}, },
blend: { blend: {
argsNeeded: 3, 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 }) => { exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [backgroundArg, amountArg, foregroundArg] = args const [backgroundArg, amountArg, foregroundArg] = args
@ -48,6 +83,11 @@ export const colorFunctions = {
}, },
mod: { mod: {
argsNeeded: 2, 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 }) => { exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [colorArg, amountArg] = args const [colorArg, amountArg] = args
@ -65,6 +105,13 @@ export const colorFunctions = {
export const shadowFunctions = { export const shadowFunctions = {
borderSide: { borderSide: {
argsNeeded: 3, 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 }) => { exec: (args, { findColor }) => {
const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args

View file

@ -22,7 +22,7 @@ import {
normalizeCombination, normalizeCombination,
findRules findRules
} from './iss_utils.js' } from './iss_utils.js'
import { parseCssShadow } from './css_utils.js' import { deserializeShadow } from './iss_deserializer.js'
// Ensuring the order of components // Ensuring the order of components
const components = { const components = {
@ -37,18 +37,18 @@ const components = {
ChatMessage: null ChatMessage: null
} }
const findShadow = (shadows, { dynamicVars, staticVars }) => { export const findShadow = (shadows, { dynamicVars, staticVars }) => {
return (shadows || []).map(shadow => { return (shadows || []).map(shadow => {
let targetShadow let targetShadow
if (typeof shadow === 'string') { if (typeof shadow === 'string') {
if (shadow.startsWith('$')) { if (shadow.startsWith('$')) {
targetShadow = process(shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars }) targetShadow = process(shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars })
} else if (shadow.startsWith('--')) { } else if (shadow.startsWith('--')) {
const [variable] = shadow.split(/,/g).map(str => str.trim()) // discarding modifier since it's not supported // modifiers are completely unsupported here
const variableSlot = variable.substring(2) const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars }) return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else { } else {
targetShadow = parseCssShadow(shadow) targetShadow = deserializeShadow(shadow)
} }
} else { } else {
targetShadow = shadow targetShadow = shadow
@ -62,54 +62,63 @@ const findShadow = (shadows, { dynamicVars, staticVars }) => {
}) })
} }
const findColor = (color, { dynamicVars, staticVars }) => { export const findColor = (color, { dynamicVars, staticVars }) => {
if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color try {
let targetColor = null if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color
if (color.startsWith('--')) { let targetColor = null
const [variable, modifier] = color.split(/,/g).map(str => str.trim()) if (color.startsWith('--')) {
const variableSlot = variable.substring(2) // Modifier support is pretty much for v2 themes only
if (variableSlot === 'stack') { const [variable, modifier] = color.split(/,/g).map(str => str.trim())
const { r, g, b } = dynamicVars.stacked const variableSlot = variable.substring(2)
targetColor = { r, g, b } if (variableSlot === 'stack') {
} else if (variableSlot.startsWith('parent')) { const { r, g, b } = dynamicVars.stacked
if (variableSlot === 'parent') {
const { r, g, b } = dynamicVars.lowerLevelBackground
targetColor = { r, g, b } 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 { } else {
const virtualSlot = variableSlot.replace(/^parent/, '') switch (variableSlot) {
targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb 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) { if (modifier) {
case 'inheritedBackground': const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor
targetColor = convert(dynamicVars.inheritedBackground).rgb const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5
break const mod = isLightOnDark ? 1 : -1
case 'background': targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
targetColor = convert(dynamicVars.background).rgb
break
default:
targetColor = convert(staticVars[variableSlot]).rgb
} }
} }
if (modifier) { if (color.startsWith('$')) {
const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor try {
const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5 targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars })
const mod = isLightOnDark ? 1 : -1 } catch (e) {
targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb 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) => { 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 * @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 * previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time * 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 = ({ export const init = ({
inputRuleset, inputRuleset,
ultimateBackgroundColor, ultimateBackgroundColor,
debug = false, debug = false,
liteMode = false, liteMode = false,
editMode = false,
onlyNormalState = false, onlyNormalState = false,
rootComponentName = 'Root' initialStaticVars = {}
}) => { }) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!') if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = {} const staticVars = { ...initialStaticVars }
const stacked = {} const stacked = {}
const computed = {} const computed = {}
@ -218,8 +227,8 @@ export const init = ({
bScore += b.component === 'Text' ? 1 : 0 bScore += b.component === 'Text' ? 1 : 0
// Debug // Debug
a.specifityScore = aScore a._specificityScore = aScore
b.specifityScore = bScore b._specificityScore = bScore
if (aScore === bScore) { if (aScore === bScore) {
return ai - bi return ai - bi
@ -228,211 +237,227 @@ export const init = ({
}) })
.map(({ data }) => data) .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 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 processCombination = (combination) => {
const selector = ruleToSelector(combination, true) try {
const cssSelector = ruleToSelector(combination) const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(combination)
const parentSelector = selector.split(/ /g).slice(0, -1).join(' ') const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0] const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector const lowerLevelSelector = parentSelector
const lowerLevelBackground = computed[lowerLevelSelector]?.background let lowerLevelBackground = computed[lowerLevelSelector]?.background
const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives if (editMode && !lowerLevelBackground) {
const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw // 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] || { const dynamicVars = computed[selector] || {
lowerLevelBackground, lowerLevelBackground,
lowerLevelVirtualDirectives, lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw 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 newTextRule = { // Inheriting all of the applicable rules
...computedRule, const existingRules = ruleset.filter(findRules(combination))
directives: { const computedDirectives =
...computedRule.directives, existingRules
textColor: inheritedTextColor, .map(r => r.directives)
textAuto: inheritedTextAuto ?? 'preserve', .reduce((acc, directives) => ({ ...acc, ...directives }), {})
textOpacity: inheritedTextOpacity, const computedRule = {
textOpacityMode: inheritedTextOpacityMode
}
}
dynamicVars.inheritedBackground = lowerLevelBackground
dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
const intendedTextColor = convert(findColor(inheritedTextColor, { dynamicVars, staticVars })).rgb
const textColor = newTextRule.directives.textAuto === 'no-auto'
? intendedTextColor
: getTextColor(
convert(stacked[lowerLevelSelector]).rgb,
intendedTextColor,
newTextRule.directives.textAuto === 'preserve'
)
const virtualDirectives = computed[lowerLevelSelector].virtualDirectives || {}
const virtualDirectivesRaw = computed[lowerLevelSelector].virtualDirectivesRaw || {}
// Storing color data in lower layer to use as custom css properties
virtualDirectives[virtualName] = getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
virtualDirectivesRaw[virtualName] = textColor
computed[lowerLevelSelector].virtualDirectives = virtualDirectives
computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
return {
dynamicVars,
selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
...combination,
directives: {},
virtualDirectives: {
[virtualName]: getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
},
virtualDirectivesRaw: {
[virtualName]: textColor
}
}
} else {
computed[selector] = computed[selector] || {}
// TODO: DEFAULT TEXT COLOR
const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
if (computedDirectives.background) {
let inheritRule = null
const variantRules = ruleset.filter(
findRules({
component: combination.component,
variant: combination.variant,
parent: combination.parent
})
)
const lastVariantRule = variantRules[variantRules.length - 1]
if (lastVariantRule) {
inheritRule = lastVariantRule
} else {
const normalRules = ruleset.filter(findRules({
component: combination.component,
parent: combination.parent
}))
const lastNormalRule = normalRules[normalRules.length - 1]
inheritRule = lastNormalRule
}
const inheritSelector = ruleToSelector({ ...inheritRule, parent: combination.parent }, true)
const inheritedBackground = computed[inheritSelector].background
dynamicVars.inheritedBackground = inheritedBackground
const rgb = convert(findColor(computedDirectives.background, { dynamicVars, staticVars })).rgb
if (!stacked[selector]) {
let blend
const alpha = computedDirectives.opacity ?? 1
if (alpha >= 1) {
blend = rgb
} else if (alpha <= 0) {
blend = lowerLevelStackedBackground
} else {
blend = alphaBlend(rgb, computedDirectives.opacity, lowerLevelStackedBackground)
}
stacked[selector] = blend
computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1 }
}
}
if (computedDirectives.shadow) {
dynamicVars.shadow = flattenDeep(findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars }))
}
if (!stacked[selector]) {
computedDirectives.background = 'transparent'
computedDirectives.opacity = 0
stacked[selector] = lowerLevelStackedBackground
computed[selector].background = { ...lowerLevelStackedBackground, a: 0 }
}
dynamicVars.stacked = stacked[selector]
dynamicVars.background = computed[selector].background
const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--'))
dynamicSlots.forEach(([k, v]) => {
const [type, ...value] = v.split('|').map(x => x.trim()) // woah, Extreme!
switch (type) {
case 'color': {
const color = findColor(value[0], { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === 'Root') {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value
dynamicVars[k] = shadow
if (combination.component === 'Root') {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === 'Root') {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
...combination, ...combination,
directives: computedDirectives 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 = {} variants: originalVariants = {}
} = component } = component
const validInnerComponents = ( let validInnerComponents
liteMode if (editMode) {
? (component.validInnerComponentsLite || component.validInnerComponents) const temp = (component.validInnerComponentsLite || component.validInnerComponents || [])
: 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" // Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates } const states = { normal: '', ...originalStates }
@ -489,7 +518,7 @@ export const init = ({
combination.component = component.name combination.component = component.name
combination.lazy = component.lazy || parent?.lazy combination.lazy = component.lazy || parent?.lazy
combination.parent = parent combination.parent = parent
if (combination.state.indexOf('hover') >= 0) { if (!liteMode && combination.state.indexOf('hover') >= 0) {
combination.lazy = true combination.lazy = true
} }
@ -538,6 +567,7 @@ export const init = ({
lazy, lazy,
eager, eager,
staticVars, staticVars,
engineChecksum engineChecksum,
themeChecksum: sum([lazy, eager])
} }
} }

1
static/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.custom.*

View file

@ -24,6 +24,8 @@
"showInstanceSpecificPanel": false, "showInstanceSpecificPanel": false,
"sidebarRight": false, "sidebarRight": false,
"subjectLineBehavior": "email", "subjectLineBehavior": "email",
"theme": "pleroma-dark", "theme": null,
"style": null,
"palette": null,
"webPushNotifications": false "webPushNotifications": false
} }

View file

@ -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" ]
}

View file

@ -1,12 +1,6 @@
{ {
"pleroma-dark": "/static/themes/pleroma-dark.json", "pleroma-dark": "/static/themes/pleroma-dark.json",
"pleroma-light": "/static/themes/pleroma-light.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": "/static/themes/redmond-xx.json",
"redmond-xx-se": "/static/themes/redmond-xx-se.json", "redmond-xx-se": "/static/themes/redmond-xx-se.json",
"redmond-xxi": "/static/themes/redmond-xxi.json", "redmond-xxi": "/static/themes/redmond-xxi.json",

View file

@ -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
}

View file

@ -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
}

4
static/styles/index.json Normal file
View file

@ -0,0 +1,4 @@
{
"RedmondDX": "/static/styles/Redmond DX.piss",
"BreezyDX": "/static/styles/Breezy DX.piss"
}

View file

@ -7201,6 +7201,11 @@ p-try@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" 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: pako@~1.0.2:
version "1.0.11" version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"