Merge branch 'appearance-tab' into 'develop'

Themes 3: Intermission: Appearance Tab and fixes

See merge request pleroma/pleroma-fe!1920
merge-requests/1931/head
HJ 2 months ago
commit 0c9893c8a0

@ -0,0 +1 @@
Reorganized Settings modal to move out visual stuff into Appearance tab

@ -0,0 +1 @@
Ability to change size of emoji

@ -0,0 +1 @@
Bug with firefox and redmond themes

@ -0,0 +1 @@
Theme selector with visual previews of the theme

@ -0,0 +1 @@
Ability to resize UI (and certain components) scale independent of browser/text scale

@ -0,0 +1 @@
Ability to override certain aspects of UI style independent of theme used (UI roundness, fonts, underlay)

@ -3,9 +3,10 @@
@import "./panel";
:root {
--font-size: 14px;
--fontSize: 14px;
--status-margin: 0.75em;
--navbar-height: 3.5rem;
--navbar-height: var(--navbarSize, 3.5rem);
--panel-header-height: var(--panelHeaderSize, 3.2rem);
--post-line-height: 1.4;
// Z-Index stuff
--ZI_media_modal: 9000;
@ -20,7 +21,10 @@
}
html {
font-size: var(--font-size);
font-size: var(--textSize);
--navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px);
// overflow-x: clip causes my browser's tab to crash with SIGILL lul
}
@ -156,6 +160,7 @@ nav {
box-shadow: var(--shadow);
box-sizing: border-box;
height: var(--navbar-height);
font-size: calc(var(--navbar-height) / 3.5);
position: fixed;
}
@ -207,7 +212,7 @@ nav {
.app-layout {
--miniColumn: 25rem;
--maxiColumn: 45rem;
--columnGap: 1em;
--columnGap: 1rem;
--effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
--effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
--effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
@ -371,7 +376,6 @@ nav {
user-select: none;
color: var(--text);
border: none;
border-radius: var(--roundness);
cursor: pointer;
background-color: var(--background);
box-shadow: var(--shadow);
@ -507,7 +511,6 @@ textarea {
--_padding: 0.5em;
border: none;
border-radius: var(--roundness);
background-color: var(--background);
color: var(--text);
box-shadow: var(--shadow);
@ -613,6 +616,17 @@ textarea {
}
}
.input,
.button-default {
--_roundness-left: var(--roundness);
--_roundness-right: var(--roundness);
border-top-left-radius: var(--_roundness-left);
border-bottom-left-radius: var(--_roundness-left);
border-top-right-radius: var(--_roundness-right);
border-bottom-right-radius: var(--_roundness-right);
}
// Textareas should have stock line-height + vertical padding instead of huge line-height
textarea.input {
padding: var(--_padding);
@ -658,22 +672,23 @@ option {
display: inline-flex;
vertical-align: middle;
button,
.button-dropdown {
> *,
> * .button-default {
--_roundness-left: 0;
--_roundness-right: 0;
position: relative;
flex: 1 1 auto;
}
&:not(:last-child),
&:not(:last-child) .button-default {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
> *:first-child,
> *:first-child .button-default {
--_roundness-left: var(--roundness);
}
&:not(:first-child),
&:not(:first-child) .button-default {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
> *:last-child,
> *:last-child .button-default {
--_roundness-right: var(--roundness);
}
}

@ -13,8 +13,7 @@ import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme, applyConfig, tryLoadCache } from '../services/style_setter/style_setter.js'
import { applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
@ -160,8 +159,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
return store.dispatch('setTheme', config.theme)
}
const getTOS = async ({ store }) => {
@ -352,27 +349,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store })
const { customTheme, customThemeSource, forceThemeRecompilation } = store.state.config
const { theme } = store.state.instance
const customThemePresent = customThemeSource || customTheme
if (!forceThemeRecompilation && tryLoadCache()) {
store.commit('setThemeApplied')
} else {
if (customThemePresent) {
if (customThemeSource && customThemeSource.themeEngineVersion === CURRENT_VERSION) {
applyTheme(customThemeSource)
} else {
applyTheme(customTheme)
}
store.commit('setThemeApplied')
} else if (theme) {
// do nothing, it will load asynchronously
} else {
console.error('Failed to load any theme!')
}
}
await store.dispatch('setTheme')
applyConfig(store.state.config)

@ -120,6 +120,7 @@ const EmojiPicker = {
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0
}
},
@ -130,6 +131,23 @@ const EmojiPicker = {
Popover
},
methods: {
updateEmojiSize () {
const css = window.getComputedStyle(this.$refs.popover.$el)
const emojiSize = css.getPropertyValue('--emojiSize')
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '')
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
const fontSize = css.getPropertyValue('font-size').replace(/[^0-9,.]+/, '')
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSize
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + (2 * 0.2 * fontSize)
this.emojiSize = fullEmojiSize
},
showPicker () {
this.$refs.popover.showPopover()
this.onShowing()
@ -224,6 +242,7 @@ const EmojiPicker = {
},
onShowing () {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
@ -266,16 +285,20 @@ const EmojiPicker = {
},
computed: {
minItemSize () {
return this.emojiHeight
return this.emojiSize
},
emojiHeight () {
return 32 + 4
// used to watch it
fontSize () {
this.$nextTick(() => {
this.updateEmojiSize()
})
return this.$store.getters.mergedConfig.fontSize
},
emojiWidth () {
return 32 + 4
emojiHeight () {
return this.emojiSize
},
itemPerRow () {
return this.width ? Math.floor(this.width / this.emojiWidth - 1) : 6
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup

@ -1,9 +1,6 @@
$emoji-picker-header-height: 36px;
$emoji-picker-header-picture-width: 32px;
$emoji-picker-header-picture-height: 32px;
$emoji-picker-emoji-size: 32px;
.emoji-picker {
--__emoji-picker-header: 2.2em;
width: 25em;
max-width: calc(100vw - 20px); // popover gives 10px margin from window edge
display: flex;
@ -13,24 +10,26 @@ $emoji-picker-emoji-size: 32px;
display: inline-flex;
justify-content: center;
align-items: center;
width: $emoji-picker-header-picture-width;
max-width: $emoji-picker-header-picture-width;
height: $emoji-picker-header-picture-height;
max-height: $emoji-picker-header-picture-height;
width: var(--__emoji-picker-header);
max-width: var(--__emoji-picker-header);
height: var(--__emoji-picker-header);
max-height: var(--__emoji-picker-header);
.still-image {
max-width: 100%;
max-height: 100%;
height: 100%;
width: 100%;
width: var(--__emoji-picker-header);
max-width: var(--__emoji-picker-header);
height: var(--__emoji-picker-header);
max-height: var(--__emoji-picker-header);
object-fit: contain;
--_still_image-label-scale: 0.5;
}
}
.keep-open,
.too-many-emoji,
.hide-custom-emoji {
padding: 7px;
padding: 0.5em;
line-height: normal;
}
@ -44,13 +43,13 @@ $emoji-picker-emoji-size: 32px;
}
.keep-open-label {
padding: 0 7px;
padding: 0 0.5em;
display: flex;
}
.heading {
display: flex;
padding: 10px 7px 5px;
padding: 0.7em 0.5em 0;
}
.content {
@ -65,13 +64,14 @@ $emoji-picker-emoji-size: 32px;
display: flex;
flex-flow: row nowrap;
overflow-x: auto;
overflow-y: hidden;
}
.additional-tabs {
display: flex;
border-left: 1px solid;
border-left-color: var(--border);
padding-left: 7px;
padding-left: 0.5em;
flex: 0 0 auto;
}
@ -80,25 +80,29 @@ $emoji-picker-emoji-size: 32px;
flex-basis: auto;
display: flex;
align-content: center;
scrollbar-width: thin;
&-item {
padding: 0 7px;
padding: 0 0.5em;
cursor: pointer;
font-size: 1.85em;
width: $emoji-picker-header-picture-width;
max-width: $emoji-picker-header-picture-width;
height: $emoji-picker-header-picture-height;
max-height: $emoji-picker-header-picture-height;
width: var(--__emoji-picker-header);
max-width: var(--__emoji-picker-header);
height: var(--__emoji-picker-header);
max-height: var(--__emoji-picker-header);
display: flex;
align-items: center;
.svg-inline--fa {
font-size: 1.85em;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.toggled {
border-bottom: 4px solid;
border-bottom: 0.2em solid;
}
}
}
@ -125,7 +129,7 @@ $emoji-picker-emoji-size: 32px;
.emoji {
&-search {
padding: 5px;
padding: 0.3em;
flex: 0 0 auto;
input {
@ -139,6 +143,7 @@ $emoji-picker-emoji-size: 32px;
flex: 1 1 1px;
position: relative;
overflow: auto;
scrollbar-gutter: stable both-edges;
user-select: none;
mask:
linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
@ -165,13 +170,13 @@ $emoji-picker-emoji-size: 32px;
display: flex;
align-items: center;
flex-wrap: wrap;
padding-left: 5px;
justify-content: left;
&-title {
font-size: 0.85em;
width: 100%;
margin: 0;
padding-left: 0.3em;
&.disabled {
display: none;
@ -180,24 +185,28 @@ $emoji-picker-emoji-size: 32px;
}
&-item {
width: $emoji-picker-emoji-size;
height: $emoji-picker-emoji-size;
width: var(--emoji-size);
height: var(--emoji-size);
box-sizing: border-box;
display: flex;
line-height: $emoji-picker-emoji-size;
line-height: var(--emoji-size);
align-items: center;
justify-content: center;
margin: 4px;
margin: 0.2em;
cursor: pointer;
.emoji-picker-emoji.-custom {
object-fit: contain;
max-width: 100%;
max-height: 100%;
width: var(--emoji-size);
max-width: var(--emoji-size);
height: var(--emoji-size);
max-height: var(--emoji-size);
--_still_image-label-scale: 0.5;
}
.emoji-picker-emoji.-unicode {
font-size: 24px;
font-size: 1.6em;
overflow: hidden;
}
}

@ -79,7 +79,7 @@
margin-top: 0.25em;
flex-wrap: wrap;
--emoji-size: calc(1.25em * var(--emojiReactionsScale, 1));
--emoji-size: calc(var(--emojiSize, 1.25em) * var(--emojiReactionsScale, 1));
.emoji-reaction-container {
display: flex;

@ -1,63 +1,59 @@
import { set } from 'lodash'
import Select from '../select/select.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faKeyboard,
faFont
} from '@fortawesome/free-solid-svg-icons'
library.add(
faExclamationTriangle,
faKeyboard,
faFont
)
export default {
components: {
Select
Select,
Checkbox,
Popover
},
props: [
'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
],
mounted () {
this.$store.dispatch('queryLocalFonts')
},
emits: ['update:modelValue'],
data () {
return {
lValue: this.modelValue,
manualEntry: false,
availableOptions: [
this.noInherit ? '' : 'inherit',
'custom',
...(this.options || []),
'serif',
'sans-serif',
'monospace',
'sans-serif'
...(this.options || [])
].filter(_ => _)
}
},
beforeUpdate () {
this.lValue = this.modelValue
methods: {
toggleManualEntry () {
this.manualEntry = !this.manualEntry
}
},
computed: {
present () {
return typeof this.lValue !== 'undefined'
},
dValue () {
return this.lValue || this.fallback || {}
},
family: {
get () {
return this.dValue.family
},
set (v) {
set(this.lValue, 'family', v)
this.$emit('update:modelValue', this.lValue)
}
return typeof this.modelValue !== 'undefined'
},
isCustom () {
return this.preset === 'custom'
localFontsList () {
return this.$store.state.interface.localFonts
},
preset: {
get () {
if (this.family === 'serif' ||
this.family === 'sans-serif' ||
this.family === 'monospace' ||
this.family === 'inherit') {
return this.family
} else {
return 'custom'
}
},
set (v) {
this.family = v === 'custom' ? '' : v
}
localFontsSize () {
return this.$store.state.interface.localFonts?.length
}
}
}

@ -1,6 +1,6 @@
<template>
<div
class="font-control style-control"
class="font-control"
:class="{ custom: isCustom }"
>
<label
@ -10,43 +10,121 @@
>
{{ label }}
</label>
<input
{{ ' ' }}
<Checkbox
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
:aria-labelledby="name + '-label'"
class="input -checkbox opt exlcude-disabled visible-for-screenreader-only"
type="checkbox"
:checked="present"
:modelValue="present"
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
:aria-hidden="true"
/>
{{ ' ' }}
<Select
:id="name + '-font-switcher'"
v-model="preset"
:disabled="!present"
class="font-switcher"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
{{ $t('settings.style.themes3.define') }}
</Checkbox>
<p v-if="modelValue?.family">
<label
v-if="manualEntry"
:id="name + '-label'"
:for="preset === 'custom' ? name : name + '-font-switcher'"
class="label"
>
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option>
</Select>
<input
v-if="isCustom"
:id="name"
v-model="family"
class="input custom-font"
type="text"
>
<i18n-t
keypath="settings.style.themes3.font.entry"
tag="span"
>
<template #fontFamily>
<code>font-family</code>
</template>
</i18n-t>
</label>
<label
v-else
:id="name + '-label'"
:for="preset === 'custom' ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.select') }}
</label>
{{ ' ' }}
<span
v-if="manualEntry"
class="btn-group"
>
<button
class="btn button-default"
@click="toggleManualEntry"
:title="$t('settings.style.themes3.font.lookup_local_fonts')"
>
<FAIcon
fixed-width
icon="font"
/>
</button>
<input
:id="name"
:model-value="modelValue.family"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', { ...(modelValue || {}), family: $event.target.value })"
>
</span>
<span
v-else
class="btn-group"
>
<button
class="btn button-default"
@click="toggleManualEntry"
:title="$t('settings.style.themes3.font.enter_manually')"
>
<FAIcon
fixed-width
icon="keyboard"
/>
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue?.family"
class="custom-font"
@update:modelValue="v => $emit('update:modelValue', { ...(modelValue || {}), family: v })"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
:style="{ fontFamily: option === 'inherit' ? null : option }"
>
{{ $t('settings.style.themes3.font.builtin.' + option) }}
</option>
</optgroup>
<optgroup
v-if="localFontsSize > 0"
:label="$t('settings.style.themes3.font.group-local')"
>
<option
v-for="option in localFontsList"
:key="option"
:value="option"
:style="{ fontFamily: option }"
>
{{ option }}
</option>
</optgroup>
<optgroup
v-else
:label="$t('settings.style.themes3.font.group-local')"
>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable1') }}
</option>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable2') }}
</option>
</optgroup>
</Select>
</span>
</p>
</div>
</template>
@ -54,21 +132,15 @@
<style lang="scss">
.font-control {
input.custom-font {
min-width: 10em;
.custom-font {
min-width: 20em;
max-width: 20em;
}
}
&.custom {
/* TODO Should make proper joiners... */
.font-switcher {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.custom-font {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>

@ -129,7 +129,7 @@
.mobile-nav {
display: grid;
line-height: var(--navbar-height);
grid-template-rows: 50px;
grid-template-rows: var(--navbar-height);
grid-template-columns: 2fr auto;
width: 100%;
box-sizing: border-box;
@ -190,8 +190,8 @@
justify-content: space-between;
z-index: calc(var(--ZI_navbar) + 100);
width: 100%;
height: 50px;
line-height: 50px;
height: 3.5em;
line-height: 3.5em;
position: absolute;
box-shadow: var(--shadow);
@ -214,7 +214,7 @@
}
.mobile-notifications {
margin-top: 50px;
margin-top: 3.5em;
width: 100vw;
height: calc(100vh - var(--navbar-height));
overflow-x: hidden;

@ -49,6 +49,7 @@
}
&.toggled {
margin-bottom: -4px;
border-bottom: 4px solid;
}
}

@ -11,7 +11,8 @@ export default {
'RichContent',
'Input',
'Avatar',
'Attachment'
'Attachment',
'PollGraph'
],
defaultRules: []
}

@ -20,6 +20,16 @@ export default {
'Tab',
'ListItem'
],
validInnerComponentsLite: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'Input',
'PanelHeader',
'Alert'
],
defaultRules: [
{
directives: {

@ -12,6 +12,11 @@ export default {
'Alert',
'Button' // mobile post button
],
validInnerComponentsLite: [
'Underlay',
'Scrollbar',
'ScrollbarElement'
],
defaultRules: [
{
directives: {

@ -15,6 +15,7 @@
</template>
<slot v-else />
</label>
{{ ' ' }}
<input
:id="path"
class="input number-input"

@ -48,6 +48,10 @@ export default {
draftMode: {
type: Boolean,
default: undefined
},
timedApplyMode: {
type: Boolean,
default: false
}
},
inject: {
@ -161,7 +165,11 @@ export default {
case 'admin':
return (k, v) => this.$store.dispatch('pushAdminSetting', { path: k, value: v })
default:
return (k, v) => this.$store.dispatch('setOption', { name: k, value: v })
if (this.timedApplyMode) {
return (k, v) => this.$store.dispatch('setOptionTemporarily', { name: k, value: v })
} else {
return (k, v) => this.$store.dispatch('setOption', { name: k, value: v })
}
}
},
defaultState () {

@ -21,15 +21,23 @@ export default {
unitSet: {
type: String,
default: 'none'
},
step: {
type: Number,
default: 1
},
resetDefault: {
type: Object,
default: null
}
},
computed: {
...Setting.computed,
stateUnit () {
return this.state.replace(/\d+/, '')
return typeof this.state === 'string' ? this.state.replace(/[0-9,.]+/, '') : ''
},
stateValue () {
return this.state.replace(/\D+/, '')
return typeof this.state === 'string' ? this.state.replace(/[^0-9,.]+/, '') : ''
}
},
methods: {
@ -39,10 +47,18 @@ export default {
return this.$t(['settings', 'units', this.unitSet, value].join('.'))
},
updateValue (e) {
this.configSink(this.path, parseInt(e.target.value) + this.stateUnit)
this.configSink(this.path, parseFloat(e.target.value) + this.stateUnit)
},
updateUnit (e) {
this.configSink(this.path, this.stateValue + e.target.value)
let value = this.stateValue
const newUnit = e.target.value
if (this.resetDefault) {
const replaceValue = this.resetDefault[newUnit]
if (replaceValue != null) {
value = replaceValue
}
}
this.configSink(this.path, value + newUnit)
}
}
}

@ -9,11 +9,12 @@
>
<slot />
</label>
{{ ' ' }}
<input
:id="path"
class="input number-input"
type="number"
step="1"
:step="step"
:disabled="disabled"
:min="min || 0"
:value="stateValue"

@ -4,6 +4,7 @@ import AsyncComponentError from 'src/components/async_component_error/async_comp
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import Popover from '../popover/popover.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { cloneDeep, isEqual } from 'lodash'
import {
@ -53,6 +54,7 @@ const SettingsModal = {
Modal,
Popover,
Checkbox,
ConfirmModal,
SettingsModalUserContent: getResettableAsyncComponent(
() => import('./settings_modal_user_content.vue'),
{
@ -165,6 +167,7 @@ const SettingsModal = {
},
computed: {
currentSaveStateNotice () {
console.log(this.$store.state.interface.settings.currentSaveStateNotice)
return this.$store.state.interface.settings.currentSaveStateNotice
},
modalActivated () {

@ -147,6 +147,18 @@
</span>
</div>
</div>
<teleport to="#modal">
<ConfirmModal
v-if="$store.state.interface.temporaryChangesTimeoutId"
:title="$t('settings.confirm_new_setting')"
:cancel-text="$t('settings.revert')"
:confirm-text="$t('settings.confirm')"
@cancelled="$store.state.interface.temporaryChangesRevert"
@accepted="$store.state.interface.temporaryChangesConfirm"
>
{{ $t('settings.confirm_new_question') }}
</ConfirmModal>
</teleport>
</Modal>
</template>

@ -7,6 +7,7 @@ import FilteringTab from './tabs/filtering_tab.vue'
import SecurityTab from './tabs/security_tab/security_tab.vue'
import ProfileTab from './tabs/profile_tab.vue'
import GeneralTab from './tabs/general_tab.vue'
import AppearanceTab from './tabs/appearance_tab.vue'
import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_tab.vue'
@ -19,7 +20,8 @@ import {
faBell,
faDownload,
faEyeSlash,
faInfo
faInfo,
faWindowRestore
} from '@fortawesome/free-solid-svg-icons'
library.add(
@ -30,7 +32,8 @@ library.add(
faBell,
faDownload,
faEyeSlash,
faInfo
faInfo,
faWindowRestore
)
const SettingsModalContent = {
@ -44,6 +47,7 @@ const SettingsModalContent = {
SecurityTab,
ProfileTab,
GeneralTab,
AppearanceTab,
VersionTab,
ThemeTab
},

@ -13,6 +13,20 @@
>
<GeneralTab />
</div>
<div
:label="$t('settings.appearance')"
icon="window-restore"
data-tab-name="appearance"
>
<AppearanceTab />
</div>
<div
:label="$t('settings.theme')"
icon="paint-brush"
data-tab-name="theme"
>
<ThemeTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.profile_tab')"
@ -21,6 +35,14 @@
>
<ProfileTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.notifications')"
icon="bell"
data-tab-name="notifications"
>
<NotificationsTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.security_tab')"
@ -36,20 +58,14 @@
>
<FilteringTab />
</div>
<div
:label="$t('settings.theme')"
icon="paint-brush"
data-tab-name="theme"
>
<ThemeTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.notifications')"
icon="bell"
data-tab-name="notifications"
:label="$t('settings.mutes_and_blocks')"
:fullHeight="true"
icon="eye-slash"
data-tab-name="mutesAndBlocks"
>
<NotificationsTab />
<MutesAndBlocksTab />
</div>
<div
v-if="isLoggedIn"
@ -59,15 +75,6 @@
>
<DataImportExportTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.mutes_and_blocks')"
:fullHeight="true"
icon="eye-slash"
data-tab-name="mutesAndBlocks"
>
<MutesAndBlocksTab />
</div>
<div
:label="$t('settings.version.title')"
icon="info"

@ -0,0 +1,195 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import { normalizeThemeData } from 'src/modules/interface'
import {
getThemes
} from 'src/services/style_setter/style_setter.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 {
getCssRules,
getScopedVersion
} from 'src/services/theme_data/css_utils.js'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
} from '@fortawesome/free-solid-svg-icons'
import Preview from './theme_tab/preview.vue'
library.add(
faGlobe
)
const AppearanceTab = {
data () {
return {
availableStyles: [],
intersectionObserver: null,
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.third_column_mode_${mode}`)
})),
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map((mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(`settings.style.themes3.hacks.forced_roundness_mode_${mode}`)
})),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode, i) => ({
key: mode,
value: mode,
label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`)
}))
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
ProfileSettingIndicator,
FontControl,
Preview
},
mounted () {
getThemes()
.then((promises) => {
return Promise.all(
Object.entries(promises)
.map(([k, v]) => v.then(res => [k, res]))
)
})
.then(themes => themes.reduce((acc, [k, v]) => {
if (v) {
return [
...acc,
{
name: v.name || v[0],
key: k,
data: v
}
]
} else {
return acc
}
}, []))
.then((themesComplete) => {
this.availableStyles = themesComplete
})
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(x => x.key === target.dataset.themeKey)
this.$nextTick(() => {
if (theme) theme.ready = true
})
observer.unobserve(target)
})
}, {
root: this.$refs.themeList
})
}
},
updated () {
this.$nextTick(() => {
this.$refs.themeList.querySelectorAll('.theme-preview').forEach(node => {
this.intersectionObserver.observe(node)
})
})
},
computed: {
noIntersectionObserver () {
return !window.IntersectionObserver
},
horizontalUnits () {
return defaultHorizontalUnits
},
fontsOverride () {
return this.$store.getters.mergedConfig.fontsOverride
},
columns () {
const mode = this.$store.getters.mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
language: {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
isCustomThemeUsed () {
const { theme } = this.mergedConfig
return theme === 'custom' || theme === null
},
...SharedComputedObject()
},
methods: {
updateFont (key, value) {
console.log(key, value)
this.$store.dispatch('setOption', {
name: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value
}
}
})
},
isThemeActive (key) {
const { theme } = this.mergedConfig
return key === theme
},
setTheme (name) {
this.$store.dispatch('setTheme', { themeName: name, saveData: true, recompile: true })
},
previewTheme (key, input) {
const style = normalizeThemeData(input)
const x = 2
if (x === 1) return
const theme2 = convertTheme2To3(style)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
return getScopedVersion(
getCssRules(theme3.eager),
'#theme-preview-' + key
).join('\n')
}
}
}
export default AppearanceTab

@ -0,0 +1,313 @@
<template>
<div class="appearance-tab" :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.theme') }}</h2>
<ul
class="theme-list"
ref="themeList"
>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview"
>
<preview />
<h4 class="theme-name">{{ $t('settings.style.custom_theme_used') }}</h4>
</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 -->
<component
:is="'style'"
v-if="style.ready || noIntersectionObserver"
v-html="previewTheme(style.key, style.data)"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :class="{ placeholder: ready }" :id="'theme-preview-' + style.key"/>
<h4 class="theme-name">{{ style.name }}</h4>
</button>
</ul>
</div>
<div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }}
</div>
<div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2>
<ul class="setting-list">
<li>
<UnitSetting
path="textSize"
step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 14, 'rem': 1 }"
timed-apply-mode
>
{{ $t('settings.text_size') }}
</UnitSetting>
<div>
<small>
<i18n-t
scope="global"
keypath="settings.text_size_tip"
tag="span"
>
<code>px</code>
<code>rem</code>
</i18n-t>
<br/>
<i18n-t
scope="global"
keypath="settings.text_size_tip2"
tag="span"
>
<code>14px</code>
</i18n-t>
</small>
</div>
</li>
<li>
<h3>{{ $t('settings.style.interface_font_user_override') }}</h3>
<ul class="setting-list">
<li>
<FontControl
:model-value="mergedConfig.theme3hacks.fonts.interface"
name="ui"
:label="$t('settings.style.fonts.components.interface')"
:fallback="{ family: 'sans-serif' }"
no-inherit="1"
@update:modelValue="v => updateFont('interface', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.input"
name="input"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.input')"
@update:modelValue="v => updateFont('input', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.post"
name="post"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.post')"
@update:modelValue="v => updateFont('post', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.monospace"
name="postCode"
:fallback="{ family: 'monospace' }"
:label="$t('settings.style.fonts.components.monospace')"
@update:modelValue="v => updateFont('monospace', v)"
/>
</li>
</ul>
</li>
<li>
<UnitSetting
path="emojiSize"
step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 32, 'rem': 2.2 }"
>
{{ $t('settings.emoji_size') }}
</UnitSetting>
<ul
class="setting-list suboptions"
>
<li>
<FloatSetting
v-if="user"
path="emojiReactionsScale"
expert="1"
>
{{ $t('settings.emoji_reactions_scale') }}
</FloatSetting>
</li>
</ul>
</li>
<li>
<UnitSetting
path="navbarSize"
step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 55, 'rem': 3.5 }"
>
{{ $t('settings.navbar_size') }}
</UnitSetting>
</li>
<h3>{{ $t('settings.columns') }}</h3>
<li>
<UnitSetting
path="panelHeaderSize"
step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 52, 'rem': 3.2 }"
timed-apply-mode
>
{{ $t('settings.panel_header_size') }}
</UnitSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="navbarColumnStretch">
{{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li v-if="expertLevel > 0">
{{ $t('settings.column_sizes') }}
<div class="column-settings">
<UnitSetting
v-for="column in columns"
:key="column"
:path="column + 'ColumnWidth'"
:units="horizontalUnits"
expert="1"
>
{{ $t('settings.column_sizes_' + column) }}
</UnitSetting>
</div>
</li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.visual_tweaks') }}</h2>
<ul class="setting-list">
<li>
<ChoiceSetting
id="forcedRoundness"
path="forcedRoundness"
:options="forcedRoundnessOptions"
>
{{ $t('settings.style.themes3.hacks.force_interface_roundness') }}
</ChoiceSetting>
</li>
<li>
<ChoiceSetting
id="underlayOverride"
path="theme3hacks.underlay"
:options="underlayOverrideModes"
>
{{ $t('settings.style.themes3.hacks.underlay_overrides') }}
</ChoiceSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="forceThemeRecompilation"
:expert="1"
>
{{ $t('settings.force_theme_recompilation_debug') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="themeDebug"
:expert="1"
>
{{ $t('settings.theme_debug') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./appearance_tab.js"></script>
<style lang="scss">
.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;
}
.preview-container {
pointer-events: none;
zoom: 0.5;
border: none;
border-radius: var(--roundness);
text-align: left;
}
}
}
}
</style>

@ -3,7 +3,7 @@ import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
@ -40,11 +40,6 @@ const GeneralTab = {
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`)
})),
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.third_column_mode_${mode}`)
})),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({
key: mode,
value: mode,
@ -70,9 +65,6 @@ const GeneralTab = {
ProfileSettingIndicator
},
computed: {
horizontalUnits () {
return defaultHorizontalUnits
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
@ -83,29 +75,6 @@ const GeneralTab = {
label: this.$t(`post_status.content_type["${format}"]`)
}))
},
columns () {
const mode = this.$store.getters.mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
language: {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
...SharedComputedObject()
},
methods: {

@ -15,11 +15,6 @@
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }}
@ -98,53 +93,6 @@
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
<li>
<h3>{{ $t('settings.columns') }}</h3>
</li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="navbarColumnStretch">
{{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li v-if="expertLevel > 0">
{{ $t('settings.column_sizes') }}
<div class="column-settings">
<UnitSetting
v-for="column in columns"
:key="column"
:path="column + 'ColumnWidth'"
:units="horizontalUnits"
expert="1"
>
{{ $t('settings.column_sizes_' + column) }}
</UnitSetting>
</div>
</li>
<li class="select-multiple">
<span class="label">{{ $t('settings.confirm_dialogs') }}</span>
<ul class="option-list">
@ -200,14 +148,6 @@
<div class="setting-item">
<h2>{{ $t('settings.post_look_feel') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting
path="forceThemeRecompilation"
:expert="1"
>
{{ $t('settings.force_theme_recompilation_debug') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="conversationDisplay"
@ -277,15 +217,6 @@
{{ $t('settings.no_rich_text_description') }}
</BooleanSetting>
</li>
<li>
<FloatSetting
v-if="user"
path="emojiReactionsScale"
expert="1"
>
{{ $t('settings.emoji_reactions_scale') }}
</FloatSetting>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<BooleanSetting
@ -528,17 +459,3 @@
</template>
<script src="./general_tab.js"></script>
<style lang="scss">
.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;
}
</style>

@ -99,15 +99,9 @@
>
<div class="actions">
<span class="checkbox">
<input
id="preview_checkbox"
checked="very yes"
type="checkbox"
class="input"
>
<label for="preview_checkbox">{{ $t('settings.style.preview.checkbox') }}</label>
</span>
<Checkbox>
{{ $t('settings.style.preview.checkbox') }}
</Checkbox>
<button class="btn button-default">
{{ $t('settings.style.preview.button') }}
</button>
@ -118,6 +112,7 @@
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@ -133,12 +128,116 @@ library.add(
faReply
)
export default {}
export default {
components: {
Checkbox
}
}
</script>
<style lang="scss">
.preview-container {
position: relative;
border-top: 1px dashed;
border-bottom: 1px dashed;
border-color: var(--border);
margin: 1em 0;
padding: 1em;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-size: cover;
background-position: 50% 50%;
.theme-preview-content {
padding: 20px;
}
.dummy {
.post {
font-family: var(--postFont);
display: flex;
.content {
flex: 1;
h4 {
margin-bottom: 0.25em;
}
.icons {
margin-top: 0.5em;
display: flex;
i {
margin-right: 1em;
}
}
}
}
.after-post {
margin-top: 1em;
display: flex;
align-items: center;
}
.avatar,
.avatar-alt {
background:
linear-gradient(
135deg,
#b8e1fc 0%,
#a9d2f3 10%,
#90bae4 25%,
#90bcea 37%,
#90bff0 50%,
#6ba8e5 51%,
#a2daf5 83%,
#bdf3fd 100%
);
color: black;
font-family: sans-serif;
text-align: center;
margin-right: 1em;
}
.avatar-alt {
flex: 0 auto;
margin-left: 28px;
font-size: 12px;
min-width: 20px;
min-height: 20px;
line-height: 20px;
}
.avatar {
flex: 0 auto;
width: 48px;
height: 48px;
font-size: 14px;
line-height: 48px;
}
.actions {
display: flex;
align-items: baseline;
.checkbox {
margin-right: 1em;
flex: 1;
}
}
.separator {
margin: 1em;
border-bottom: 1px solid;
border-color: var(--border);
}
.btn {
min-width: 3em;
}
}
}
.underlay-preview {
@ -148,4 +247,4 @@ export default {}
left: 10px;
right: 10px;
}
</style>
</style>

@ -1,7 +1,8 @@
import {
rgb2hex,
hex2rgb,
getContrastRatioLayers
getContrastRatioLayers,
relativeLuminance
} from 'src/services/color_convert/color_convert.js'
import {
getThemes
@ -23,10 +24,17 @@ import {
generateShadows,
generateRadii,
generateFonts,
composePreset,
shadows2to3,
colors2to3
} from 'src/services/theme_data/theme_data.service.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 {
getCssRules,
getScopedVersion
} from 'src/services/theme_data/css_utils.js'
import ColorInput from 'src/components/color_input/color_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
@ -62,6 +70,7 @@ const colorConvert = (color) => {
export default {
data () {
return {
themeV3Preview: [],
themeImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
@ -78,10 +87,7 @@ export default {
tempImportFile: undefined,
engineVersion: 0,
previewShadows: {},
previewColors: {},
previewRadii: {},
previewFonts: {},
previewTheme: {},
shadowsInvalid: true,
colorsInvalid: true,
@ -232,13 +238,6 @@ export default {
chatMessage: this.chatMessageRadiusLocal
}
},
preview () {
return composePreset(this.previewColors, this.previewRadii, this.previewShadows, this.previewFonts)
},
previewTheme () {
if (!this.preview.theme.colors) return { colors: {}, opacity: {}, radii: {}, shadows: {}, fonts: {} }
return this.preview.theme
},
// This needs optimization maybe
previewContrast () {
try {
@ -306,14 +305,6 @@ export default {
return {}
}
},
previewRules () {
if (!this.preview.rules) return ''
return [
...Object.values(this.preview.rules),
'color: var(--text)',
'font-family: var(--interfaceFont, sans-serif)'
].join(';')
},
shadowsAvailable () {
return Object.keys(DEFAULT_SHADOWS).sort()
},
@ -511,17 +502,14 @@ export default {
}
},
setCustomTheme () {
this.$store.dispatch('setOption', {
name: 'customTheme',
value: {
this.$store.dispatch('setThemeV2', {
customTheme: {
ignore: true,
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
}
})
this.$store.dispatch('setOption', {
name: 'customThemeSource',
value: {
},
customThemeSource: {
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
@ -532,16 +520,24 @@ export default {
}
})
},
updatePreviewColorsAndShadows () {
this.previewColors = generateColors({
updatePreviewColors () {
const result = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors
})
this.previewShadows = generateShadows(
{ shadows: this.shadowsLocal, opacity: this.previewTheme.opacity, themeEngineVersion: this.engineVersion },
this.previewColors.theme.colors,
this.previewColors.mod
)
this.previewTheme.colors = result.theme.colors
this.previewTheme.opacity = result.theme.opacity
},
updatePreviewShadows () {
this.previewTheme.shadows = generateShadows(
{
shadows: this.shadowsLocal,
opacity: this.previewTheme.opacity,
themeEngineVersion: this.engineVersion
},
this.previewTheme.colors,
relativeLuminance(this.previewTheme.colors.bg) < 0.5 ? 1 : -1
).theme.shadows
},
importTheme () { this.themeImporter.importData() },
exportTheme () { this.themeExporter.exportData() },
@ -610,7 +606,7 @@ export default {
normalizeLocalState (theme, version = 0, source, forceSource = false) {
let input
if (typeof source !== 'undefined') {
if (forceSource || source.themeEngineVersion === CURRENT_VERSION) {
if (forceSource || source?.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
@ -692,6 +688,8 @@ export default {
} else {
this.shadowsLocal = shadows
}
this.updatePreviewColors()
this.updatePreviewShadows()
this.shadowSelected = this.shadowsAvailable[0]
}
@ -699,12 +697,25 @@ export default {
this.clearFonts()
this.fontsLocal = fonts
}
},
updateTheme3Preview () {
const theme2 = convertTheme2To3(this.previewTheme)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true
})
this.themeV3Preview = getScopedVersion(
getCssRules(theme3.eager),
'#theme-preview'
).join('\n')
}
},
watch: {
currentRadii () {
try {
this.previewRadii = generateRadii({ radii: this.currentRadii })
this.previewTheme.radii = generateRadii({ radii: this.currentRadii }).theme.radii
this.radiiInvalid = false
} catch (e) {
this.radiiInvalid = true
@ -713,9 +724,8 @@ export default {
},
shadowsLocal: {
handler () {
if (Object.getOwnPropertyNames(this.previewColors).length === 1) return
try {
this.updatePreviewColorsAndShadows()
this.updatePreviewShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
@ -727,7 +737,7 @@ export default {
fontsLocal: {
handler () {
try {
this.previewFonts = generateFonts({ fonts: this.fontsLocal })
this.previewTheme.fonts = generateFonts({ fonts: this.fontsLocal }).theme.fonts
this.fontsInvalid = false
} catch (e) {
this.fontsInvalid = true
@ -738,18 +748,16 @@ export default {
},
currentColors () {
try {
this.updatePreviewColorsAndShadows()
this.updatePreviewColors()
this.colorsInvalid = false
this.shadowsInvalid = false
} catch (e) {
this.colorsInvalid = true
this.shadowsInvalid = true
console.warn(e)
}
},
currentOpacity () {
try {
this.updatePreviewColorsAndShadows()
this.updatePreviewColors()
} catch (e) {
console.warn(e)
}

@ -1,4 +1,9 @@
.theme-tab {
.deprecation-warning {
padding: 0.5em;
margin: 2em;
}
padding-bottom: 2em;
.preset-switcher {
@ -10,6 +15,10 @@
margin-right: 0.25em;
}
.btn-group .btn {
margin: 0;
}
.style-control {
display: flex;
align-items: baseline;
@ -157,107 +166,6 @@
}
}
.preview-container {
border-top: 1px dashed;
border-bottom: 1px dashed;
border-color: var(--border);
margin: 1em 0;
padding: 1em;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-size: cover;
background-position: 50% 50%;
.dummy {
.post {
font-family: var(--postFont);
display: flex;
.content {
flex: 1;
h4 {
margin-bottom: 0.25em;
}
.icons {
margin-top: 0.5em;
display: flex;
i {
margin-right: 1em;
}
}
}
}
.after-post {
margin-top: 1em;
display: flex;
align-items: center;
}
.avatar,
.avatar-alt {
background:
linear-gradient(
135deg,
#b8e1fc 0%,
#a9d2f3 10%,
#90bae4 25%,
#90bcea 37%,
#90bff0 50%,
#6ba8e5 51%,
#a2daf5 83%,
#bdf3fd 100%
);
color: black;
font-family: sans-serif;
text-align: center;
margin-right: 1em;
}
.avatar-alt {
flex: 0 auto;
margin-left: 28px;
font-size: 12px;
min-width: 20px;
min-height: 20px;
line-height: 20px;
}
.avatar {
flex: 0 auto;
width: 48px;
height: 48px;
font-size: 14px;
line-height: 48px;
}
.actions {
display: flex;
align-items: baseline;
.checkbox {
display: inline-flex;
align-items: baseline;
margin-right: 1em;
flex: 1;
}
}
.separator {
margin: 1em;
border-bottom: 1px solid;
border-color: var(--border);
}
.btn {
min-width: 3em;
}
}
}
.radius-item {
flex-basis: auto;
}
@ -310,10 +218,6 @@
max-width: 50em;
}
.theme-preview-content {
padding: 20px;
}
.theme-warning {
display: flex;
align-items: baseline;

@ -1,5 +1,8 @@
<template>
<div class="theme-tab">
<div class="alert warning deprecation-warning">
{{ $t("settings.style.themes2_outdated") }}
</div>
<div class="presets-container">
<div class="save-load">
<div
@ -120,7 +123,19 @@
</div>
</div>
<preview :style="previewRules" />
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component :is="'style'" v-html="themeV3Preview"/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview id="theme-preview"/>
<div>
<button
class="btn button-default"
@click="updateTheme3Preview"
>
{{ $t("settings.style.update_preview") }}
</button>
</div>
<keep-alive>
<tab-switcher key="style-tweak">
@ -156,7 +171,7 @@
<OpacityInput
v-model="bgOpacityLocal"
name="bgOpacity"
:fallback="previewTheme.opacity.bg"
:fallback="previewTheme.opacity?.bg"
/>
<ColorInput
v-model="textColorLocal"
@ -167,14 +182,14 @@
<ColorInput
v-model="accentColorLocal"
name="accentColor"
:fallback="previewTheme.colors.link"
:fallback="previewTheme.colors?.link"
:label="$t('settings.accent')"
:show-optional-tickbox="typeof linkColorLocal !== 'undefined'"
/>
<ColorInput
v-model="linkColorLocal"
name="linkColor"
:fallback="previewTheme.colors.accent"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
:show-optional-tickbox="typeof accentColorLocal !== 'undefined'"
/>
@ -190,13 +205,13 @@
v-model="fgTextColorLocal"
name="fgTextColor"
:label="$t('settings.text')"
:fallback="previewTheme.colors.fgText"
:fallback="previewTheme.colors?.fgText"
/>
<ColorInput
v-model="fgLinkColorLocal"
name="fgLinkColor"
:label="$t('settings.links')"
:fallback="previewTheme.colors.fgLink"
:fallback="previewTheme.colors?.fgLink"
/>
<p>{{ $t('settings.style.common_colors.foreground_hint') }}</p>
</div>
@ -256,14 +271,14 @@
<ColorInput
v-model="postLinkColorLocal"
name="postLinkColor"
:fallback="previewTheme.colors.accent"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.postLink" />
<ColorInput
v-model="postGreentextColorLocal"
name="postGreentextColor"
:fallback="previewTheme.colors.cGreen"
:fallback="previewTheme.colors?.cGreen"
:label="$t('settings.greentext')"
/>
<ContrastRatio :contrast="previewContrast.postGreentext" />
@ -272,13 +287,13 @@
v-model="alertErrorColorLocal"
name="alertError"
:label="$t('settings.style.advanced_colors.alert_error')"
:fallback="previewTheme.colors.alertError"
:fallback="previewTheme.colors?.alertError"
/>
<ColorInput
v-model="alertErrorTextColorLocal"
name="alertErrorText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.alertErrorText"
:fallback="previewTheme.colors?.alertErrorText"
/>
<ContrastRatio
:contrast="previewContrast.alertErrorText"
@ -288,13 +303,13 @@
v-model="alertWarningColorLocal"
name="alertWarning"
:label="$t('settings.style.advanced_colors.alert_warning')"
:fallback="previewTheme.colors.alertWarning"
:fallback="previewTheme.colors?.alertWarning"
/>
<ColorInput
v-model="alertWarningTextColorLocal"
name="alertWarningText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.alertWarningText"
:fallback="previewTheme.colors?.alertWarningText"
/>
<ContrastRatio
:contrast="previewContrast.alertWarningText"
@ -304,13 +319,13 @@
v-model="alertNeutralColorLocal"
name="alertNeutral"
:label="$t('settings.style.advanced_colors.alert_neutral')"
:fallback="previewTheme.colors.alertNeutral"
:fallback="previewTheme.colors?.alertNeutral"
/>
<ColorInput
v-model="alertNeutralTextColorLocal"
name="alertNeutralText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.alertNeutralText"
:fallback="previewTheme.colors?.alertNeutralText"
/>
<ContrastRatio
:contrast="previewContrast.alertNeutralText"
@ -319,7 +334,7 @@
<OpacityInput
v-model="alertOpacityLocal"
name="alertOpacity"
:fallback="previewTheme.opacity.alert"
:fallback="previewTheme.opacity?.alert"
/>
</div>
<div class="color-item">
@ -328,13 +343,13 @@
v-model="badgeNotificationColorLocal"
name="badgeNotification"
:label="$t('settings.style.advanced_colors.badge_notification')"
:fallback="previewTheme.colors.badgeNotification"
:fallback="previewTheme.colors?.badgeNotification"
/>
<ColorInput
v-model="badgeNotificationTextColorLocal"
name="badgeNotificationText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.badgeNotificationText"
:fallback="previewTheme.colors?.badgeNotificationText"
/>
<ContrastRatio
:contrast="previewContrast.badgeNotificationText"
@ -346,19 +361,19 @@
<ColorInput
v-model="panelColorLocal"
name="panelColor"
:fallback="previewTheme.colors.panel"
:fallback="previewTheme.colors?.panel"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="panelOpacityLocal"
name="panelOpacity"
:fallback="previewTheme.opacity.panel"
:fallback="previewTheme.opacity?.panel"
:disabled="panelColorLocal === 'transparent'"
/>
<ColorInput
v-model="panelTextColorLocal"
name="panelTextColor"
:fallback="previewTheme.colors.panelText"
:fallback="previewTheme.colors?.panelText"
:label="$t('settings.text')"
/>
<ContrastRatio
@ -368,7 +383,7 @@
<ColorInput
v-model="panelLinkColorLocal"
name="panelLinkColor"
:fallback="previewTheme.colors.panelLink"
:fallback="previewTheme.colors?.panelLink"
:label="$t('settings.links')"
/>
<ContrastRatio
@ -381,20 +396,20 @@
<ColorInput
v-model="topBarColorLocal"
name="topBarColor"
:fallback="previewTheme.colors.topBar"
:fallback="previewTheme.colors?.topBar"
:label="$t('settings.background')"
/>
<ColorInput
v-model="topBarTextColorLocal"
name="topBarTextColor"
:fallback="previewTheme.colors.topBarText"
:fallback="previewTheme.colors?.topBarText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.topBarText" />
<ColorInput
v-model="topBarLinkColorLocal"
name="topBarLinkColor"
:fallback="previewTheme.colors.topBarLink"
:fallback="previewTheme.colors?.topBarLink"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.topBarLink" />
@ -404,19 +419,19 @@
<ColorInput
v-model="inputColorLocal"
name="inputColor"
:fallback="previewTheme.colors.input"
:fallback="previewTheme.colors?.input"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="inputOpacityLocal"
name="inputOpacity"
:fallback="previewTheme.opacity.input"
:fallback="previewTheme.opacity?.input"
:disabled="inputColorLocal === 'transparent'"
/>
<ColorInput
v-model="inputTextColorLocal"
name="inputTextColor"
:fallback="previewTheme.colors.inputText"
:fallback="previewTheme.colors?.inputText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.inputText" />
@ -426,33 +441,33 @@
<ColorInput
v-model="btnColorLocal"
name="btnColor"
:fallback="previewTheme.colors.btn"
:fallback="previewTheme.colors?.btn"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="btnOpacityLocal"
name="btnOpacity"
:fallback="previewTheme.opacity.btn"
:fallback="previewTheme.opacity?.btn"
:disabled="btnColorLocal === 'transparent'"
/>
<ColorInput
v-model="btnTextColorLocal"
name="btnTextColor"
:fallback="previewTheme.colors.btnText"
:fallback="previewTheme.colors?.btnText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnText" />
<ColorInput
v-model="btnPanelTextColorLocal"
name="btnPanelTextColor"
:fallback="previewTheme.colors.btnPanelText"
:fallback="previewTheme.colors?.btnPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPanelText" />
<ColorInput
v-model="btnTopBarTextColorLocal"
name="btnTopBarTextColor"
:fallback="previewTheme.colors.btnTopBarText"
:fallback="previewTheme.colors?.btnTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnTopBarText" />
@ -460,27 +475,27 @@
<ColorInput
v-model="btnPressedColorLocal"
name="btnPressedColor"
:fallback="previewTheme.colors.btnPressed"
:fallback="previewTheme.colors?.btnPressed"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnPressedTextColorLocal"
name="btnPressedTextColor"
:fallback="previewTheme.colors.btnPressedText"
:fallback="previewTheme.colors?.btnPressedText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedText" />
<ColorInput
v-model="btnPressedPanelTextColorLocal"
name="btnPressedPanelTextColor"
:fallback="previewTheme.colors.btnPressedPanelText"
:fallback="previewTheme.colors?.btnPressedPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedPanelText" />
<ColorInput
v-model="btnPressedTopBarTextColorLocal"
name="btnPressedTopBarTextColor"
:fallback="previewTheme.colors.btnPressedTopBarText"
:fallback="previewTheme.colors?.btnPressedTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedTopBarText" />
@ -488,52 +503,52 @@
<ColorInput
v-model="btnDisabledColorLocal"
name="btnDisabledColor"
:fallback="previewTheme.colors.btnDisabled"
:fallback="previewTheme.colors?.btnDisabled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnDisabledTextColorLocal"
name="btnDisabledTextColor"
:fallback="previewTheme.colors.btnDisabledText"
:fallback="previewTheme.colors?.btnDisabledText"
:label="$t('settings.text')"
/>
<ColorInput
v-model="btnDisabledPanelTextColorLocal"
name="btnDisabledPanelTextColor"
:fallback="previewTheme.colors.btnDisabledPanelText"
:fallback="previewTheme.colors?.btnDisabledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ColorInput
v-model="btnDisabledTopBarTextColorLocal"
name="btnDisabledTopBarTextColor"
:fallback="previewTheme.colors.btnDisabledTopBarText"
:fallback="previewTheme.colors?.btnDisabledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<h5>{{ $t('settings.style.advanced_colors.toggled') }}</h5>
<ColorInput
v-model="btnToggledColorLocal"
name="btnToggledColor"
:fallback="previewTheme.colors.btnToggled"
:fallback="previewTheme.colors?.btnToggled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnToggledTextColorLocal"
name="btnToggledTextColor"
:fallback="previewTheme.colors.btnToggledText"
:fallback="previewTheme.colors?.btnToggledText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledText" />
<ColorInput
v-model="btnToggledPanelTextColorLocal"
name="btnToggledPanelTextColor"
:fallback="previewTheme.colors.btnToggledPanelText"
:fallback="previewTheme.colors?.btnToggledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledPanelText" />
<ColorInput
v-model="btnToggledTopBarTextColorLocal"
name="btnToggledTopBarTextColor"
:fallback="previewTheme.colors.btnToggledTopBarText"
:fallback="previewTheme.colors?.btnToggledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledTopBarText" />
@ -543,20 +558,20 @@
<ColorInput
v-model="tabColorLocal"
name="tabColor"
:fallback="previewTheme.colors.tab"
:fallback="previewTheme.colors?.tab"
:label="$t('settings.background')"
/>
<ColorInput
v-model="tabTextColorLocal"
name="tabTextColor"
:fallback="previewTheme.colors.tabText"
:fallback="previewTheme.colors?.tabText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabText" />
<ColorInput
v-model="tabActiveTextColorLocal"
name="tabActiveTextColor"
:fallback="previewTheme.colors.tabActiveText"
:fallback="previewTheme.colors?.tabActiveText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabActiveText" />
@ -566,13 +581,13 @@
<ColorInput
v-model="borderColorLocal"
name="borderColor"
:fallback="previewTheme.colors.border"
:fallback="previewTheme.colors?.border"
:label="$t('settings.style.common.color')"
/>
<OpacityInput
v-model="borderOpacityLocal"
name="borderOpacity"
:fallback="previewTheme.opacity.border"
:fallback="previewTheme.opacity?.border"
:disabled="borderColorLocal === 'transparent'"
/>
</div>
@ -581,25 +596,25 @@
<ColorInput
v-model="faintColorLocal"
name="faintColor"
:fallback="previewTheme.colors.faint"
:fallback="previewTheme.colors?.faint"
:label="$t('settings.text')"
/>
<ColorInput
v-model="faintLinkColorLocal"
name="faintLinkColor"
:fallback="previewTheme.colors.faintLink"
:fallback="previewTheme.colors?.faintLink"
:label="$t('settings.links')"
/>
<ColorInput
v-model="panelFaintColorLocal"
name="panelFaintColor"
:fallback="previewTheme.colors.panelFaint"
:fallback="previewTheme.colors?.panelFaint"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<OpacityInput
v-model="faintOpacityLocal"
name="faintOpacity"
:fallback="previewTheme.opacity.faint"
:fallback="previewTheme.opacity?.faint"
/>
</div>
<div class="color-item">
@ -608,12 +623,12 @@
v-model="underlayColorLocal"
name="underlay"
:label="$t('settings.style.advanced_colors.underlay')"
:fallback="previewTheme.colors.underlay"
:fallback="previewTheme.colors?.underlay"
/>
<OpacityInput
v-model="underlayOpacityLocal"
name="underlayOpacity"
:fallback="previewTheme.opacity.underlay"
:fallback="previewTheme.opacity?.underlay"
:disabled="underlayOpacityLocal === 'transparent'"
/>
</div>
@ -623,7 +638,7 @@
v-model="wallpaperColorLocal"
name="wallpaper"
:label="$t('settings.style.advanced_colors.wallpaper')"
:fallback="previewTheme.colors.wallpaper"
:fallback="previewTheme.colors?.wallpaper"
/>
</div>
<div class="color-item">
@ -632,13 +647,13 @@
v-model="pollColorLocal"
name="poll"
:label="$t('settings.background')"
:fallback="previewTheme.colors.poll"
:fallback="previewTheme.colors?.poll"
/>
<ColorInput
v-model="pollTextColorLocal"
name="pollText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.pollText"
:fallback="previewTheme.colors?.pollText"
/>
</div>
<div class="color-item">
@ -647,7 +662,7 @@
v-model="iconColorLocal"
name="icon"
:label="$t('settings.style.advanced_colors.icons')"
:fallback="previewTheme.colors.icon"
:fallback="previewTheme.colors?.icon"
/>
</div>
<div class="color-item">
@ -656,20 +671,20 @@
v-model="highlightColorLocal"
name="highlight"
:label="$t('settings.background')"
:fallback="previewTheme.colors.highlight"
:fallback="previewTheme.colors?.highlight"
/>
<ColorInput
v-model="highlightTextColorLocal"
name="highlightText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.highlightText"
:fallback="previewTheme.colors?.highlightText"
/>
<ContrastRatio :contrast="previewContrast.highlightText" />
<ColorInput
v-model="highlightLinkColorLocal"
name="highlightLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.highlightLink"
:fallback="previewTheme.colors?.highlightLink"
/>
<ContrastRatio :contrast="previewContrast.highlightLink" />
</div>
@ -679,26 +694,26 @@
v-model="popoverColorLocal"
name="popover"
:label="$t('settings.background')"
:fallback="previewTheme.colors.popover"
:fallback="previewTheme.colors?.popover"
/>
<OpacityInput
v-model="popoverOpacityLocal"
name="popoverOpacity"
:fallback="previewTheme.opacity.popover"
:fallback="previewTheme.opacity?.popover"
:disabled="popoverOpacityLocal === 'transparent'"
/>
<ColorInput
v-model="popoverTextColorLocal"
name="popoverText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.popoverText"
:fallback="previewTheme.colors?.popoverText"
/>
<ContrastRatio :contrast="previewContrast.popoverText" />
<ColorInput
v-model="popoverLinkColorLocal"
name="popoverLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.popoverLink"
:fallback="previewTheme.colors?.popoverLink"
/>
<ContrastRatio :contrast="previewContrast.popoverLink" />
</div>
@ -708,20 +723,20 @@
v-model="selectedPostColorLocal"
name="selectedPost"
:label="$t('settings.background')"
:fallback="previewTheme.colors.selectedPost"
:fallback="previewTheme.colors?.selectedPost"
/>
<ColorInput
v-model="selectedPostTextColorLocal"
name="selectedPostText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.selectedPostText"
:fallback="previewTheme.colors?.selectedPostText"
/>
<ContrastRatio :contrast="previewContrast.selectedPostText" />
<ColorInput
v-model="selectedPostLinkColorLocal"
name="selectedPostLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.selectedPostLink"
:fallback="previewTheme.colors?.selectedPostLink"
/>
<ContrastRatio :contrast="previewContrast.selectedPostLink" />
</div>
@ -731,20 +746,20 @@
v-model="selectedMenuColorLocal"
name="selectedMenu"
:label="$t('settings.background')"
:fallback="previewTheme.colors.selectedMenu"
:fallback="previewTheme.colors?.selectedMenu"
/>
<ColorInput
v-model="selectedMenuTextColorLocal"
name="selectedMenuText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.selectedMenuText"
:fallback="previewTheme.colors?.selectedMenuText"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuText" />
<ColorInput
v-model="selectedMenuLinkColorLocal"
name="selectedMenuLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.selectedMenuLink"
:fallback="previewTheme.colors?.selectedMenuLink"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div>
@ -753,57 +768,57 @@
<ColorInput
v-model="chatBgColorLocal"
name="chatBgColor"
:fallback="previewTheme.colors.bg"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
<ColorInput
v-model="chatMessageIncomingBgColorLocal"
name="chatMessageIncomingBgColor"
:fallback="previewTheme.colors.bg"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageIncomingTextColorLocal"
name="chatMessageIncomingTextColor"
:fallback="previewTheme.colors.text"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageIncomingLinkColorLocal"
name="chatMessageIncomingLinkColor"
:fallback="previewTheme.colors.link"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageIncomingBorderColorLocal"
name="chatMessageIncomingBorderLinkColor"
:fallback="previewTheme.colors.fg"
:fallback="previewTheme.colors?.fg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
<ColorInput
v-model="chatMessageOutgoingBgColorLocal"
name="chatMessageOutgoingBgColor"
:fallback="previewTheme.colors.bg"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageOutgoingTextColorLocal"
name="chatMessageOutgoingTextColor"
:fallback="previewTheme.colors.text"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageOutgoingLinkColorLocal"
name="chatMessageOutgoingLinkColor"
:fallback="previewTheme.colors.link"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageOutgoingBorderColorLocal"
name="chatMessageOutgoingBorderLinkColor"
:fallback="previewTheme.colors.bg"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
</div>
@ -826,7 +841,7 @@
v-model="btnRadiusLocal"
name="btnRadius"
:label="$t('settings.btnRadius')"
:fallback="previewTheme.radii.btn"
:fallback="previewTheme.radii?.btn"
max="16"
hard-min="0"
/>
@ -834,7 +849,7 @@
v-model="inputRadiusLocal"
name="inputRadius"
:label="$t('settings.inputRadius')"
:fallback="previewTheme.radii.input"
:fallback="previewTheme.radii?.input"
max="9"
hard-min="0"
/>
@ -842,7 +857,7 @@
v-model="checkboxRadiusLocal"
name="checkboxRadius"
:label="$t('settings.checkboxRadius')"
:fallback="previewTheme.radii.checkbox"
:fallback="previewTheme.radii?.checkbox"
max="16"
hard-min="0"
/>
@ -850,7 +865,7 @@
v-model="panelRadiusLocal"
name="panelRadius"
:label="$t('settings.panelRadius')"
:fallback="previewTheme.radii.panel"
:fallback="previewTheme.radii?.panel"
max="50"
hard-min="0"
/>
@ -858,7 +873,7 @@
v-model="avatarRadiusLocal"
name="avatarRadius"
:label="$t('settings.avatarRadius')"
:fallback="previewTheme.radii.avatar"
:fallback="previewTheme.radii?.avatar"
max="28"
hard-min="0"
/>
@ -866,7 +881,7 @@
v-model="avatarAltRadiusLocal"
name="avatarAltRadius"
:label="$t('settings.avatarAltRadius')"
:fallback="previewTheme.radii.avatarAlt"
:fallback="previewTheme.radii?.avatarAlt"
max="28"
hard-min="0"
/>
@ -874,7 +889,7 @@
v-model="attachmentRadiusLocal"
name="attachmentRadius"
:label="$t('settings.attachmentRadius')"
:fallback="previewTheme.radii.attachment"
:fallback="previewTheme.radii?.attachment"
max="50"
hard-min="0"
/>
@ -882,7 +897,7 @@
v-model="tooltipRadiusLocal"
name="tooltipRadius"
:label="$t('settings.tooltipRadius')"
:fallback="previewTheme.radii.tooltip"
:fallback="previewTheme.radii?.tooltip"
max="50"
hard-min="0"
/>
@ -890,7 +905,7 @@
v-model="chatMessageRadiusLocal"
name="chatMessageRadius"
:label="$t('settings.chatMessageRadius')"
:fallback="previewTheme.radii.chatMessage || 2"
:fallback="previewTheme.radii?.chatMessage || 2"
max="50"
hard-min="0"
/>
@ -996,26 +1011,26 @@
v-model="fontsLocal.interface"
name="ui"
:label="$t('settings.style.fonts.components.interface')"
:fallback="previewTheme.fonts.interface"
:fallback="previewTheme.fonts?.interface"
no-inherit="1"
/>
<FontControl
v-model="fontsLocal.input"
name="input"
:label="$t('settings.style.fonts.components.input')"
:fallback="previewTheme.fonts.input"
:fallback="previewTheme.fonts?.input"
/>
<FontControl
v-model="fontsLocal.post"
name="post"
:label="$t('settings.style.fonts.components.post')"
:fallback="previewTheme.fonts.post"
:fallback="previewTheme.fonts?.post"
/>
<FontControl
v-model="fontsLocal.postCode"
name="postCode"
:label="$t('settings.style.fonts.components.postCode')"
:fallback="previewTheme.fonts.postCode"
:fallback="previewTheme.fonts?.postCode"
/>
</div>
</tab-switcher>

@ -17,6 +17,15 @@ export default {
'Attachment',
'PollGraph'
],
validInnerComponentsLite: [
'Text',
'Link',
'Icon',
'Border',
'ButtonUnstyled',
'RichContent',
'Avatar'
],
defaultRules: [
{
directives: {

@ -376,6 +376,20 @@
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"mention_links": "Mention links",
"appearance": "Appearance",
"confirm_new_setting": "Confirm new setting?",
"confirm_new_question": "Does this look ok? Setting will be reverted in 10 seconds.",
"revert": "Revert",
"confirm": "Confirm",
"text_size": "Text and interface size",
"text_size_tip": "Use {0} for absolute values, {1} will scale with browser default text size.",
"text_size_tip2": "Values other than {0} might break some things and themes",
"emoji_size": "Emoji size",
"navbar_size": "Top bar size",
"panel_header_size": "Panel header size",
"visual_tweaks": "Minor visual tweaks",
"theme_debug": "Show what background theme engine assumes when dealing with transparancy (DEBUG)",
"scale_and_layout": "Interface scale and layout",
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
@ -729,6 +743,42 @@
"enable_web_push_always_show_tip": "Some browsers (Chromium, Chrome) require that push messages always result in a notification, otherwise generic 'Website was updated in background' is shown, enable this to prevent this notification from showing, as Chrome seem to hide push notifications if tab is in focus. Can result in showing duplicate notifications on other browsers.",
"more_settings": "More settings",
"style": {
"custom_theme_used": "(Custom theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
"underlay_override_mode_opaque": "Replace with solid color",
"underlay_override_mode_transparent": "Remove entirely (might break some themes)",
"force_interface_roundness": "Override interface roundness/sharpness",
"forced_roundness_mode_disabled": "Use theme defaults",
"forced_roundness_mode_sharp": "Force sharp edges",
"forced_roundness_mode_nonsharp": "Force not-so-sharp (1px roundness) edges",
"forced_roundness_mode_round": "Force round edges"
},
"font": {
"group-builtin": "Browser default fonts",
"builtin" : {
"serif": "Serif",
"sans-serif": "Sans-serif",
"monospace": "Monospace",
"inherit": "Unchanged"
},
"group-local": "Locally installed fonts",
"local-unavailable1": "List of locally installed fonts unavailalbe",
"local-unavailable2": "Use manual entry to specify custom font",
"font_list_unavailable": "Couldn't get locally installed fonts: {error}",
"lookup_local_fonts": "Load list of fonts installed on this computer",
"enter_manually": "Enter font name family manually",
"entry": "Enter {fontFamily}",
"select": "Select font"
}
},
"interface_font_user_override": "Override theme/browser font used",
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
@ -852,7 +902,7 @@
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"postCode": "Monospaced text in a post (rich text)"
"monospace": "Monospaced text"
},
"family": "Font name",
"size": "Size (in px)",

@ -1,10 +1,21 @@
import Cookies from 'js-cookie'
import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import { applyConfig } from '../services/style_setter/style_setter.js'
import messages from '../i18n/messages'
import { set } from 'lodash'
import localeService from '../services/locale/locale.service.js'
const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage'
const APPEARANCE_SETTINGS_KEYS = new Set([
'sidebarColumnWidth',
'contentColumnWidth',
'notifsColumnWidth',
'textSize',
'navbarSize',
'panelHeaderSize',
'forcedRoundness',
'emojiSize',
'emojiReactionsScale'
])
const browserLocale = (window.navigator.language || 'en').split('-')[0]
@ -24,11 +35,30 @@ export const multiChoiceProperties = [
export const defaultState = {
expertLevel: 0, // used to track which settings to show and hide
colors: {},
theme: undefined,
customTheme: undefined,
customThemeSource: undefined,
forceThemeRecompilation: false,
// Theme stuff
theme: undefined, // Very old theme store, stores preset name, still in use
// V1
colors: {}, // VERY old theme store, just colors of V1, probably not even used anymore
// V2
customTheme: undefined, // "snapshot", previously was used as actual theme store for V2 so it's still used in case of PleromaFE downgrade event.
customThemeSource: undefined, // "source", stores original theme data
// V3
themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions
forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
theme3hacks: { // Hacks, user overrides that are independent of theme used
underlay: 'none',
fonts: {
interface: undefined,
input: undefined,
post: undefined,
monospace: undefined
}
},
hideISP: false,
hideInstanceWallpaper: false,
hideShoutbox: false,
@ -117,7 +147,12 @@ export const defaultState = {
sidebarColumnWidth: '25rem',
contentColumnWidth: '45rem',
notifsColumnWidth: '25rem',
emojiReactionsScale: 1.0,
emojiReactionsScale: undefined,
textSize: undefined, // instance default
emojiSize: undefined, // instance default
navbarSize: undefined, // instance default
panelHeaderSize: undefined, // instance default
forcedRoundness: undefined, // instance default
navbarColumnStretch: false,
greentext: undefined, // instance default
useAtIcon: undefined, // instance default
@ -175,6 +210,10 @@ const config = {
}
},
mutations: {
setOptionTemporarily (state, { name, value }) {
set(state, name, value)
applyConfig(state)
},
setOption (state, { name, value }) {
set(state, name, value)
},
@ -205,6 +244,37 @@ const config = {
setHighlight ({ commit, dispatch }, { user, color, type }) {
commit('setHighlight', { user, color, type })
},
setOptionTemporarily ({ commit, dispatch, state, rootState }, { name, value }) {
if (rootState.interface.temporaryChangesTimeoutId !== null) {
console.warn('Can\'t track more than one temporary change')
return
}
const oldValue = state[name]
commit('setOptionTemporarily', { name, value })
const confirm = () => {
dispatch('setOption', { name, value })
commit('clearTemporaryChanges')
}
const revert = () => {
commit('setOptionTemporarily', { name, value: oldValue })
commit('clearTemporaryChanges')
}
commit('setTemporaryChanges', {
timeoutId: setTimeout(revert, 10000),
confirm,
revert
})
},
setThemeV2 ({ commit, dispatch }, { customTheme, customThemeSource }) {
commit('setOption', { name: 'theme', value: 'custom' })
commit('setOption', { name: 'customTheme', value: customTheme })
commit('setOption', { name: 'customThemeSource', value: customThemeSource })
dispatch('setTheme', { themeData: customThemeSource, recompile: true })
},
setOption ({ commit, dispatch, state }, { name, value }) {
const exceptions = new Set([
'useStreamingApi'
@ -222,24 +292,26 @@ const config = {
dispatch('disableMastoSockets')
dispatch('setOption', { name: 'useStreamingApi', value: false })
})
break
}
}
} else {
commit('setOption', { name, value })
if (APPEARANCE_SETTINGS_KEYS.has(name)) {
applyConfig(state)
}
if (name.startsWith('theme3hacks')) {
dispatch('setTheme', { recompile: true })
}
switch (name) {
case 'theme':
setPreset(value)
if (value === 'custom') break
dispatch('setTheme', { themeName: value, recompile: true, saveData: true })
break
case 'sidebarColumnWidth':
case 'contentColumnWidth':
case 'notifsColumnWidth':
case 'emojiReactionsScale':
applyConfig(state)
break
case 'customTheme':
case 'customThemeSource':
applyTheme(value)
case 'themeDebug': {
dispatch('setTheme', { recompile: true })
break
}
case 'interfaceLanguage':
messages.setLanguage(this.getters.i18n, value)
dispatch('loadUnicodeEmojiData', value)

@ -1,5 +1,3 @@
import { getPreset, applyTheme } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import apiService from '../services/api/api.service.js'
import { instanceDefaultProperties } from './config.js'
import { langCodeToCldrName, ensureFinalFallback } from '../i18n/languages.js'
@ -44,7 +42,7 @@ const defaultState = {
registrationOpen: true,
server: 'http://localhost:4040/',
textlimit: 5000,
themeData: undefined,
themeData: undefined, // used for theme editor v2
vapidPublicKey: undefined,
// Stuff from static/config.json
@ -98,6 +96,13 @@ const defaultState = {
sidebarRight: false,
subjectLineBehavior: 'email',
theme: 'pleroma-dark',
emojiReactionsScale: 0.5,
textSize: '14px',
emojiSize: '2.2rem',
navbarSize: '3.5rem',
panelHeaderSize: '3.2rem',
forcedRoundness: -1,
fontsOverride: {},
virtualScrolling: true,
sensitiveByDefault: false,
conversationDisplay: 'linear',
@ -279,9 +284,6 @@ const instance = {
dispatch('initializeSocket')
}
break
case 'theme':
dispatch('setTheme', value)
break
}
},
async getStaticEmoji ({ commit }) {
@ -370,27 +372,6 @@ const instance = {
console.warn(e)
}
},
setTheme ({ commit, rootState }, themeName) {
commit('setInstanceOption', { name: 'theme', value: themeName })
getPreset(themeName)
.then(themeData => {
commit('setInstanceOption', { name: 'themeData', value: themeData })
// No need to apply theme if there's user theme already
const { customTheme } = rootState.config
const { themeApplied } = rootState.interface
if (customTheme || themeApplied) return
// New theme presets don't have 'theme' property, they use 'source'
const themeSource = themeData.source
if (!themeData.theme || (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION)) {
applyTheme(themeSource)
} else {
applyTheme(themeData.theme)
}
commit('setThemeApplied')
})
},
fetchEmoji ({ dispatch, state }) {
if (!state.customEmojiFetched) {
state.customEmojiFetched = true

@ -1,5 +1,13 @@
import { getPreset, applyTheme, tryLoadCache } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION, generatePreset } from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const defaultState = {
localFonts: null,
themeApplied: false,
temporaryChangesTimeoutId: null, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {}, // used for applying temporary options
temporaryChangesRevert: () => {}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
@ -14,7 +22,8 @@ const defaultState = {
cssFilter: window.CSS && window.CSS.supports && (
window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
)
),
localFonts: typeof window.queryLocalFonts === 'function'
},
layoutType: 'normal',
globalNotices: [],
@ -36,6 +45,17 @@ const interfaceMod = {
state.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setTemporaryChanges (state, { timeoutId, confirm, revert }) {
state.temporaryChangesTimeoutId = timeoutId
state.temporaryChangesConfirm = confirm
state.temporaryChangesRevert = revert
},
clearTemporaryChanges (state) {
clearTimeout(state.temporaryChangesTimeoutId)
state.temporaryChangesTimeoutId = null
state.temporaryChangesConfirm = () => {}
state.temporaryChangesRevert = () => {}
},
setThemeApplied (state) {
state.themeApplied = true
},
@ -90,6 +110,10 @@ const interfaceMod = {
},
setLastTimeline (state, value) {
state.lastTimeline = value
},
setFontsList (state, value) {
// Set is used here so that we filter out duplicate fonts (possibly same font but with different weight)
state.localFonts = [...(new Set(value.map(font => font.family))).values()]
}
},
actions: {
@ -164,10 +188,203 @@ const interfaceMod = {
commit('setLayoutType', wideLayout ? 'wide' : normalOrMobile)
}
},
queryLocalFonts ({ commit, dispatch, state }) {
if (state.localFonts !== null) return
commit('setFontsList', [])
if (!state.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
commit('setFontsList', fonts)
})
.catch((e) => {
dispatch('pushGlobalNotice', {
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e
},
level: 'error'
})
})
},
setLastTimeline ({ commit }, value) {
commit('setLastTimeline', value)
},
setTheme ({ commit, rootState }, { themeName, themeData, recompile, saveData } = {}) {
const {
theme: instanceThemeName
} = rootState.instance
const {
theme: userThemeName,
customTheme: userThemeSnapshot,
customThemeSource: userThemeSource,
forceThemeRecompilation,
themeDebug,
theme3hacks
} = rootState.config
const actualThemeName = userThemeName || instanceThemeName
const forceRecompile = forceThemeRecompilation || recompile
let promise = null
if (themeData) {
promise = Promise.resolve(normalizeThemeData(themeData))
} else if (themeName) {
promise = getPreset(themeName).then(themeData => normalizeThemeData(themeData))
} else if (userThemeSource || userThemeSnapshot) {
if (userThemeSource && userThemeSource.themeEngineVersion === CURRENT_VERSION) {
promise = Promise.resolve(normalizeThemeData(userThemeSource))
} else {
promise = Promise.resolve(normalizeThemeData(userThemeSnapshot))
}
} else if (actualThemeName && actualThemeName !== 'custom') {
promise = getPreset(actualThemeName).then(themeData => {
const realThemeData = normalizeThemeData(themeData)
if (actualThemeName === instanceThemeName) {
// This sole line is the reason why this whole block is above the recompilation check
commit('setInstanceOption', { name: 'themeData', value: { theme: realThemeData } })
}
return realThemeData
})
} else {
throw new Error('Cannot load any theme!')
}
// If we're not not forced to recompile try using
// cache (tryLoadCache return true if load successful)
if (!forceRecompile && !themeDebug && tryLoadCache()) {
commit('setThemeApplied')
return
}
promise
.then(realThemeData => {
const theme2ruleset = convertTheme2To3(realThemeData)
if (saveData) {
commit('setOption', { name: 'theme', value: themeName || actualThemeName })
commit('setOption', { name: 'customTheme', value: realThemeData })
commit('setOption', { name: 'customThemeSource', value: realThemeData })
}
const hacks = []
Object.entries(theme3hacks).forEach(([key, value]) => {
switch (key) {
case 'fonts': {
Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
if (!font?.family) return
switch (fontKey) {
case 'interface':
hacks.push({
component: 'Root',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'input':
hacks.push({
component: 'Input',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'post':
hacks.push({
component: 'RichContent',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'monospace':
hacks.push({
component: 'Root',
directives: {
'--monoFont': 'generic | ' + font.family
}
})
break
}
})
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
}
}
})
const ruleset = [
...theme2ruleset,
...hacks
]
applyTheme(
ruleset,
() => commit('setThemeApplied'),
themeDebug
)
})
return promise
}
}
}
export default interfaceMod
export const normalizeThemeData = (input) => {
let themeData = input
if (Array.isArray(themeData)) {
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
}
if (themeData.themeFileVerison === 1) {
return generatePreset(themeData).theme
}
// New theme presets don't have 'theme' property, they use 'source'
const themeSource = themeData.source
let out // shout, shout let it all out
if (!themeData.theme || (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION)) {
out = themeSource || themeData
} else {
out = themeData.theme
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}

@ -60,11 +60,12 @@
.panel-heading,
.panel-footer {
--panel-heading-height-padding: 0.6em;
--__panel-heading-gap: 0.5em;
--__panel-heading-height: 3.2em;
--panel-heading-height-padding: calc(var(--panel-header-height) * 0.2);
--__panel-heading-gap: calc(var(--panel-header-height) * 0.1565);
--__panel-heading-height: var(--panel-header-height);
--__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0));
font-size: calc(var(--panelHeaderSize) / 3.2);
backdrop-filter: var(--__panel-backdrop-filter);
position: relative;
box-sizing: border-box;

@ -1,7 +1,5 @@
import { hex2rgb } from '../color_convert/color_convert.js'
import { generatePreset } from '../theme_data/theme_data.service.js'
import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from '../theme_data/theme2_to_theme3.js'
import { getCssRules } from '../theme_data/css_utils.js'
import { defaultState } from '../../modules/config.js'
import { chunk } from 'lodash'
@ -45,25 +43,21 @@ const adoptStyleSheets = (styles) => {
// is nothing to do here.
}
export const generateTheme = async (input, callbacks) => {
export const generateTheme = async (inputRuleset, callbacks, debug) => {
const {
onNewRule = (rule, isLazy) => {},
onLazyFinished = () => {},
onEagerFinished = () => {}
} = callbacks
let extraRules
if (input.themeFileVersion === 1) {
extraRules = convertTheme2To3(input)
} else {
const { theme } = generatePreset(input)
extraRules = convertTheme2To3(theme)
}
// Assuming that "worst case scenario background" is panel background since it's the most likely one
const themes3 = init(extraRules, extraRules[0].directives['--bg'].split('|')[1].trim())
const themes3 = init({
inputRuleset,
ultimateBackgroundColor: inputRuleset[0].directives['--bg'].split('|')[1].trim(),
debug
})
getCssRules(themes3.eager, themes3.staticVars).forEach(rule => {
getCssRules(themes3.eager, debug).forEach(rule => {
// Hacks to support multiple selectors on same component
if (rule.match(/::-webkit-scrollbar-button/)) {
const parts = rule.split(/[{}]/g)
@ -93,7 +87,7 @@ export const generateTheme = async (input, callbacks) => {
const processChunk = () => {
const chunk = chunks[counter]
Promise.all(chunk.map(x => x())).then(result => {
getCssRules(result.filter(x => x), themes3.staticVars).forEach(rule => {
getCssRules(result.filter(x => x), debug).forEach(rule => {
if (rule.match(/\.modal-view/)) {
const parts = rule.split(/[{}]/g)
const newRule = [
@ -152,7 +146,7 @@ export const tryLoadCache = () => {
}
}
export const applyTheme = async (input, onFinish = (data) => {}) => {
export const applyTheme = async (input, onFinish = (data) => {}, debug) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
@ -177,7 +171,8 @@ export const applyTheme = async (input, onFinish = (data) => {}) => {
onFinish(cache)
localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
}
}
},
debug
)
setTimeout(lazyProcessFunc, 0)
@ -185,15 +180,52 @@ export const applyTheme = async (input, onFinish = (data) => {}) => {
return Promise.resolve()
}
const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) =>
({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale })
const extractStyleConfig = ({
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
forcedRoundness
}) => {
const result = {
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize
}
switch (forcedRoundness) {
case 'disable':
break
case '0':
result.forcedRoundness = '0'
break
case '1':
result.forcedRoundness = '1px'
break
case '2':
result.forcedRoundness = '0.4rem'
break
default:
}
return result
}
const defaultConfigColumns = configColumns(defaultState)
const defaultStyleConfig = extractStyleConfig(defaultState)
export const applyConfig = (config) => {
const columns = configColumns(config)
export const applyConfig = (input) => {
const config = extractStyleConfig(input)
if (columns === defaultConfigColumns) {
if (config === defaultStyleConfig) {
return
}
@ -202,16 +234,25 @@ export const applyConfig = (config) => {
body.classList.add('hidden')
const rules = Object
.entries(columns)
.entries(config)
.filter(([k, v]) => v)
.map(([k, v]) => `--${k}: ${v}`).join(';')
document.getElementById('style-config')?.remove()
const styleEl = document.createElement('style')
styleEl.id = 'style-config'
head.appendChild(styleEl)
const styleSheet = styleEl.sheet
styleSheet.toString()
styleSheet.insertRule(`:root { ${rules} }`, 'index-max')
if (Object.prototype.hasOwnProperty.call(config, 'forcedRoundness')) {
styleSheet.insertRule(` * {
--roundness: var(--forcedRoundness) !important;
}`, 'index-max')
}
body.classList.remove('hidden')
}
@ -269,5 +310,3 @@ export const getPreset = (val) => {
return { theme: data, source: theme.source }
})
}
export const setPreset = (val) => getPreset(val).then(data => applyTheme(data))

@ -2,11 +2,6 @@ import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
// This changes what backgrounds are used to "stacked" solid colors so you can see
// what theme engine "thinks" is actual background color is for purposes of text color
// generation and for when --stacked variable is used
const DEBUG = false
export const parseCssShadow = (text) => {
const dimensions = /(\d[a-z]*\s?){2,4}/.exec(text)?.[0]
const inset = /inset/.exec(text)?.[0]
@ -66,7 +61,10 @@ export const getCssShadowFilter = (input) => {
.join(' ')
}
export const getCssRules = (rules) => rules.map(rule => {
// `debug` changes what backgrounds are used to "stacked" solid colors so you can see
// what theme engine "thinks" is actual background color is for purposes of text color
// generation and for when --stacked variable is used
export const getCssRules = (rules, debug) => rules.map(rule => {
let selector = rule.selector
if (!selector) {
selector = 'html'
@ -93,7 +91,7 @@ export const getCssRules = (rules) => rules.map(rule => {
].join(';\n ')
}
case 'background': {
if (DEBUG) {
if (debug) {
return `
--background: ${getCssColorString(rule.dynamicVars.stacked)};
background-color: ${getCssColorString(rule.dynamicVars.stacked)};
@ -161,3 +159,15 @@ export const getCssRules = (rules) => rules.map(rule => {
footer
].join('\n')
}).filter(x => x)
export const getScopedVersion = (rules, newScope) => {
return rules.map(x => {
if (x.startsWith('html')) {
return x.replace('html', newScope)
} else if (x.startsWith('#content')) {
return x.replace('#content', newScope)
} else {
return newScope + ' > ' + x
}
})
}

@ -39,7 +39,23 @@ export const getAllPossibleCombinations = (array) => {
return combos.reduce((acc, x) => [...acc, ...x], [])
}
// Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true) selector
/**
* Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true)
* selector.
*
* "path" here refers to "fake" selector that cannot be actually used in UI but is used for internal
* purposes
*
* @param {Object} components - object containing all components definitions
*
* @returns {Function}
* @param {Object} rule - rule in question to convert to CSS selector
* @param {boolean} ignoreOutOfTreeSelector - wthether to ignore aformentioned field in
* component definition and use selector
* @param {boolean} isParent - (mostly) internal argument used when recursing
*
* @returns {String} CSS selector (or path)
*/
export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => {
if (!rule && !isParent) return null
const component = components[rule.component]
@ -79,6 +95,17 @@ export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelecto
return selectors.trim()
}
/**
* Check if combination matches
*
* @param {Object} criteria - criteria to match against
* @param {Object} subject - rule/combination to check match
* @param {boolean} strict - strict checking:
* By default every variant and state inherits from "normal" state/variant
* so when checking if combination matches, it WILL match against "normal"
* state/variant. In strict mode inheritance is ignored an "normal" does
* not match
*/
export const combinationsMatch = (criteria, subject, strict) => {
if (criteria.component !== subject.component) return false
@ -101,6 +128,15 @@ export const combinationsMatch = (criteria, subject, strict) => {
return true
}
/**
* Search for rule that matches `criteria` in set of rules
* meant to be used in a ruleset.filter() function
*
* @param {Object} criteria - criteria to search for
* @param {boolean} strict - whether search strictly or not (see combinationsMatch)
*
* @return function that returns true/false if subject matches
*/
export const findRules = (criteria, strict) => subject => {
// If we searching for "general" rules - ignore "specific" ones
if (criteria.parent === null && !!subject.parent) return false
@ -125,6 +161,7 @@ export const findRules = (criteria, strict) => subject => {
return true
}
// Pre-fills 'normal' state/variant if missing
export const normalizeCombination = rule => {
rule.variant = rule.variant ?? 'normal'
rule.state = [...new Set(['normal', ...(rule.state || [])])]

@ -12,7 +12,9 @@ export const basePaletteKeys = new Set([
'cBlue',
'cRed',
'cGreen',
'cOrange'
'cOrange',
'wallpaper'
])
export const fontsKeys = new Set([
@ -138,7 +140,7 @@ export const convertTheme2To3 = (data) => {
Object.keys(data.opacity || {}).forEach(key => {
if (!opacityKeys.has(key) || data.opacity[key] === undefined) return null
const originalOpacity = data.opacity[key]
const rule = {}
const rule = { source: '2to3' }
switch (key) {
case 'alert':
@ -213,7 +215,7 @@ export const convertTheme2To3 = (data) => {
Object.keys(data.radii || {}).forEach(key => {
if (!radiiKeys.has(key) || data.radii[key] === undefined) return null
const originalRadius = data.radii[key]
const rule = {}
const rule = { source: '2to3' }
switch (key) {
case 'btn':
@ -265,8 +267,9 @@ export const convertTheme2To3 = (data) => {
const newRules = []
Object.keys(data.fonts || {}).forEach(key => {
if (!fontsKeys.has(key)) return
if (!data.fonts[key]) return
const originalFont = data.fonts[key].family
const rule = {}
const rule = { source: '2to3' }
switch (key) {
case 'interface':
@ -300,7 +303,7 @@ export const convertTheme2To3 = (data) => {
Object.keys(data.shadows || {}).forEach(key => {
if (!shadowsKeys.has(key)) return
const originalShadow = data.shadows[key]
const rule = {}
const rule = { source: '2to3' }
switch (key) {
case 'panel':
@ -369,7 +372,7 @@ export const convertTheme2To3 = (data) => {
const extendedRules = Object.entries(extendedBaseKeys).map(([prefix, keys]) => {
if (nonComponentPrefixes.has(prefix)) return null
const rule = {}
const rule = { source: '2to3' }
if (prefix === 'alertPopup') {
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
@ -402,7 +405,7 @@ export const convertTheme2To3 = (data) => {
const leftoverKey = key.replace(prefix, '')
const parts = (leftoverKey || 'Bg').match(/[A-Z][a-z]*/g)
const last = parts.slice(-1)[0]
let newRule = { directives: {} }
let newRule = { source: '2to3', directives: {} }
let variantArray = []
switch (last) {
@ -462,12 +465,12 @@ export const convertTheme2To3 = (data) => {
if (prefix === 'popover' && variantArray[0] === 'Post') {
newRule.component = 'Post'
newRule.parent = { component: 'Popover' }
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter(x => x !== 'Post')
}
if (prefix === 'selectedMenu' && variantArray[0] === 'Popover') {
newRule.parent = { component: 'Popover' }
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter(x => x !== 'Popover')
}
@ -477,12 +480,12 @@ export const convertTheme2To3 = (data) => {
case 'alert': {
const hasPanel = variantArray.find(x => x === 'Panel')
if (hasPanel) {
newRule.parent = { component: 'PanelHeader' }
newRule.parent = { source: '2to3hack', component: 'PanelHeader', parent: newRule.parent }
variantArray = variantArray.filter(x => x !== 'Panel')
}
const hasTop = variantArray.find(x => x === 'Top') // TopBar
if (hasTop) {
newRule.parent = { component: 'TopBar' }
newRule.parent = { source: '2to3hack', component: 'TopBar', parent: newRule.parent }
variantArray = variantArray.filter(x => x !== 'Top' && x !== 'Bar')
}
break

@ -117,7 +117,6 @@ export const topoSort = (
// Put it into the output list
output.push(node)
} else if (grays.has(node)) {
console.debug('Cyclic depenency in topoSort, ignoring')
output.push(node)
} else if (blacks.has(node)) {
// do nothing

@ -149,16 +149,42 @@ const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum
export const init = (extraRuleset, ultimateBackgroundColor) => {
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
* @param {string} rootComponentName - [UNTESTED] which component to start from, meant for previewing a
* part of the theme (i.e. just the button) for themes 3 editor.
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
onlyNormalState = false,
rootComponentName = 'Root'
}) => {
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = {}
const stacked = {}
const computed = {}
const rulesetUnsorted = [
...Object.values(components)
.map(c => (c.defaultRules || []).map(r => ({ component: c.name, ...r })))
.map(c => (c.defaultRules || []).map(r => ({ component: c.name, ...r, source: 'Built-in' })))
.reduce((acc, arr) => [...acc, ...arr], []),
...extraRuleset
...inputRuleset
].map(rule => {
normalizeCombination(rule)
let currentParent = rule.parent
@ -395,11 +421,16 @@ export const init = (extraRuleset, ultimateBackgroundColor) => {
const processInnerComponent = (component, parent) => {
const combinations = []
const {
validInnerComponents = [],
states: originalStates = {},
variants: originalVariants = {}
} = component
const validInnerComponents = (
liteMode
? (component.validInnerComponentsLite || component.validInnerComponents)
: component.validInnerComponents
) || []
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants }
@ -411,22 +442,26 @@ export const init = (extraRuleset, ultimateBackgroundColor) => {
// Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal')
const stateCombinations = [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map(combination => ['normal', ...combination])
.filter(combo => {
// Optimization: filter out some hard-coded combinations that don't make sense
if (combo.indexOf('disabled') >= 0) {
return !(
combo.indexOf('hover') >= 0 ||
combo.indexOf('focused') >= 0 ||
combo.indexOf('pressed') >= 0
)
}
return true
})
]
const stateCombinations = onlyNormalState
? [
['normal']
]
: [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map(combination => ['normal', ...combination])
.filter(combo => {
// Optimization: filter out some hard-coded combinations that don't make sense
if (combo.indexOf('disabled') >= 0) {
return !(
combo.indexOf('hover') >= 0 ||
combo.indexOf('focused') >= 0 ||
combo.indexOf('pressed') >= 0
)
}
return true
})
]
const stateVariantCombination = Object.keys(variants).map(variant => {
return stateCombinations.map(state => ({ variant, state }))
@ -451,9 +486,11 @@ export const init = (extraRuleset, ultimateBackgroundColor) => {
}
const t0 = performance.now()
const combinations = processInnerComponent(components.Root)
const combinations = processInnerComponent(components[rootComponentName] ?? components.Root)
const t1 = performance.now()
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations.map((combination) => {
if (combination.lazy) {
@ -463,7 +500,9 @@ export const init = (extraRuleset, ultimateBackgroundColor) => {
}
}).filter(x => x)
const t2 = performance.now()
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
return {
lazy: result.filter(x => typeof x === 'function'),

@ -66,7 +66,7 @@ describe('Theme Data 3', () => {
this.timeout(5000)
it('Test initialization without anything', () => {
const out = init([], '#DEADAF')
const out = init({ inputRuleset: [], ultimateBackgroundColor: '#DEADAF' })
expect(out).to.have.property('eager')
expect(out).to.have.property('lazy')
@ -85,13 +85,16 @@ describe('Theme Data 3', () => {
})
it('Test initialization with a basic palette', () => {
const out = init([{
component: 'Root',
directives: {
'--bg': 'color | #008080',
'--fg': 'color | #00C0A0'
}
}], '#DEADAF')
const out = init({
inputRuleset: [{
component: 'Root',
directives: {
'--bg': 'color | #008080',
'--fg': 'color | #00C0A0'
}
}],
ultimateBackgroundColor: '#DEADAF'
})
expect(out.staticVars).to.have.property('bg').equal('#008080')
expect(out.staticVars).to.have.property('fg').equal('#00C0A0')
@ -105,17 +108,20 @@ describe('Theme Data 3', () => {
})
it('Test initialization with opacity', () => {
const out = init([{
component: 'Root',
directives: {
'--bg': 'color | #008080'
}
}, {
component: 'Panel',
directives: {
opacity: 0.5
}
}], '#DEADAF')
const out = init({
inputRuleset: [{
component: 'Root',
directives: {
'--bg': 'color | #008080'
}
}, {
component: 'Panel',
directives: {
opacity: 0.5
}
}],
ultimateBackgroundColor: '#DEADAF'
})
expect(out.staticVars).to.have.property('bg').equal('#008080')

Loading…
Cancel
Save