ソースを参照

Merge remote-tracking branch 'origin/develop' into bookmark-folders

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
marcin mikołajczak 1 ヶ月 前
コミット
216ca52073
35 ファイル変更1198 行追加446 行削除
  1. 1 0
      changelog.d/better-shadow-control.fix
  2. 1 0
      changelog.d/splashscreen.add
  3. 127 2
      index.html
  4. 2 1
      package.json
  5. 22 0
      src/App.js
  6. 166 0
      src/App.scss
  7. BIN
      src/assets/pleromatan_apology.png
  8. 1 0
      src/assets/pleromatan_apology.png
  9. BIN
      src/assets/pleromatan_apology_fox.png
  10. 1 0
      src/assets/pleromatan_apology_fox.png
  11. 12 9
      src/boot/after_store.js
  12. 11 0
      src/components/button.style.js
  13. 21 11
      src/components/checkbox/checkbox.vue
  14. 18 6
      src/components/color_input/color_input.scss
  15. 16 4
      src/components/color_input/color_input.vue
  16. 212 0
      src/components/component_preview/component_preview.vue
  17. 36 7
      src/components/input.style.js
  18. 2 0
      src/components/opacity_input/opacity_input.vue
  19. 34 1
      src/components/select/select.vue
  20. 12 1
      src/components/settings_modal/tabs/theme_tab/theme_tab.js
  21. 8 7
      src/components/settings_modal/tabs/theme_tab/theme_tab.scss
  22. 11 46
      src/components/settings_modal/tabs/theme_tab/theme_tab.vue
  23. 73 63
      src/components/shadow_control/shadow_control.js
  24. 105 0
      src/components/shadow_control/shadow_control.scss
  25. 143 213
      src/components/shadow_control/shadow_control.vue
  26. 16 0
      src/i18n/en.json
  27. 37 6
      src/i18n/nan-TW.json
  28. 67 47
      src/main.js
  29. 15 12
      src/services/style_setter/style_setter.js
  30. 1 1
      src/services/theme_data/theme_data.service.js
  31. 27 9
      src/services/theme_data/theme_data_3.service.js
  32. BIN
      static/pleromatan_apology.png
  33. BIN
      static/pleromatan_apology_fox.png
  34. BIN
      static/pleromatan_orz.png
  35. BIN
      static/pleromatan_orz_fox.png

+ 1 - 0
changelog.d/better-shadow-control.fix

@@ -0,0 +1 @@
+Updated shadow editor, hopefully fixed long-standing bugs, added ability to specify shadow's name.

+ 1 - 0
changelog.d/splashscreen.add

@@ -0,0 +1 @@
+Splash screen + loading indicator to make process of identifying initialization issues and load performance

+ 127 - 2
index.html

@@ -4,13 +4,138 @@
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
     <link rel="icon" type="image/png" href="/favicon.png">
+    <!-- putting styles here to avoid having to wait for styles to load up -->
+    <style id="splashscreen">
+     #splash {
+       --scale: 1;
+       width: 100vw;
+       height: 100vh;
+       display: grid;
+       grid-template-rows: auto;
+       grid-template-columns: auto;
+       align-content: center;
+       align-items: center;
+       justify-content: center;
+       justify-items: center;
+       flex-direction: column;
+       background: #0f161e;
+       font-family: sans-serif;
+       color: #b9b9ba;
+       position: absolute;
+       z-index: 9999;
+       font-size: calc(1vw + 1vh + 1vmin);
+     }
+
+     #splash-credit {
+       position: absolute;
+       font-size: 14px;
+       bottom: 16px;
+       right: 16px;
+     }
+
+     #splash-container {
+       align-items: center;
+     }
+
+     #mascot-container {
+       display: flex;
+       align-items: flex-end;
+       justify-content: center;
+       perspective: 60em;
+       perspective-origin: 0 -15em;
+       transform-style: preserve-3d;
+     }
+
+     #mascot {
+       width: calc(10em * var(--scale));
+       height: calc(10em * var(--scale));
+       object-fit: contain;
+       object-position: bottom;
+       transform: translateZ(-2em);
+     }
+
+     #throbber {
+       display: grid;
+       width: calc(5em * 0.5 * var(--scale));
+       height: calc(8em * 0.5 * var(--scale));
+       margin-left: 4.1em;
+       z-index: 2;
+       grid-template-rows: repeat(8, 1fr);
+       grid-template-columns: repeat(5, 1fr);
+       grid-template-areas: "P P . L L"
+                            "P P . L L"
+                            "P P . L L"
+                            "P P . L L"
+                            "P P . . ."
+                            "P P . . ."
+                            "P P . E E"
+                            "P P . E E";
+     }
+
+     .chunk {
+       background-color: #e2b188;
+       box-shadow: 0.01em 0.01em 0.1em 0 #e2b188;
+     }
+
+     #chunk-P {
+       grid-area: P;
+       border-top-left-radius:  calc(var(--logoChunkSize) / 2);
+     }
+
+     #chunk-L {
+       grid-area: L;
+       border-bottom-right-radius: calc(var(--logoChunkSize) / 2);
+     }
+
+     #chunk-E {
+       grid-area: E;
+       border-bottom-right-radius: calc(var(--logoChunkSize) / 2);
+     }
+
+     #status {
+       margin-top: 1em;
+       line-height: 2;
+       width: 100%;
+       text-align: center;
+     }
+
+     @media (prefers-reduced-motion) {
+       #throbber {
+         animation: none !important;
+       }
+     }
+    </style>
     <style id="pleroma-eager-styles" type="text/css"></style>
     <style id="pleroma-lazy-styles" type="text/css"></style>
     <!--server-generated-meta-->
   </head>
-  <body class="hidden">
+  <body style="margin: 0; padding: 0">
     <noscript>To use Pleroma, please enable JavaScript.</noscript>
-    <div id="app"></div>
+    <div id="splash">
+      <!-- we are hiding entire graphic so no point showing credit -->
+      <div aria-hidden="true" id="splash-credit">
+        Art by pipivovott
+      </div>
+      <div id="splash-container">
+        <div aria-hidden="true" id="mascot-container">
+          <div id="throbber">
+            <div class="chunk" id="chunk-P">
+            </div>
+            <div class="chunk" id="chunk-L">
+            </div>
+            <div class="chunk" id="chunk-E">
+            </div>
+          </div>
+          <img id="mascot" src="/static/pleromatan_apology.png">
+        </div>
+        <div id="status" class="css-ok">
+          <!-- (。>﹏<) -->
+          <!-- it's a pseudographic, don't want screenreader read out nonsense -->
+          <span aria-hidden="true" class="initial-text">(。&gt;﹏&lt;)</span>
+        </div>
+      </div>
+    </div>
+    <div id="app" class="hidden"></div>
     <div id="modal"></div>
     <!-- built files will be auto injected -->
     <div id="popovers" />

+ 2 - 1
package.json

@@ -132,5 +132,6 @@
   "engines": {
     "node": ">= 16.0.0",
     "npm": ">= 3.0.0"
-  }
+  },
+  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
 }

+ 22 - 0
src/App.js

@@ -44,16 +44,29 @@ export default {
   data: () => ({
     mobileActivePanel: 'timeline'
   }),
+  watch: {
+    themeApplied (value) {
+      this.removeSplash()
+    }
+  },
   created () {
     // Load the locale from the storage
     const val = this.$store.getters.mergedConfig.interfaceLanguage
     this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
     window.addEventListener('resize', this.updateMobileState)
   },
+  mounted () {
+    if (this.$store.state.interface.themeApplied) {
+      this.removeSplash()
+    }
+  },
   unmounted () {
     window.removeEventListener('resize', this.updateMobileState)
   },
   computed: {
+    themeApplied () {
+      return this.$store.state.interface.themeApplied
+    },
     classes () {
       return [
         {
@@ -130,6 +143,15 @@ export default {
     updateMobileState () {
       this.$store.dispatch('setLayoutWidth', windowWidth())
       this.$store.dispatch('setLayoutHeight', windowHeight())
+    },
+    removeSplash () {
+      document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4))
+      const splashscreenRoot = document.querySelector('#splash')
+      splashscreenRoot.addEventListener('transitionend', () => {
+        splashscreenRoot.remove()
+      })
+      splashscreenRoot.classList.add('hidden')
+      document.querySelector('#app').classList.remove('hidden')
     }
   }
 }

+ 166 - 0
src/App.scss

@@ -914,3 +914,169 @@ option {
   color: var(--selectionText);
   background-color: var(--selectionBackground);
 }
+
+#splash {
+  pointer-events: none;
+  transition: opacity 2s;
+  opacity: 1;
+
+  &.hidden {
+    opacity: 0;
+  }
+
+  #status {
+    &.css-ok {
+      &::before {
+        display: inline-block;
+        content: "CSS OK";
+      }
+    }
+
+    .initial-text {
+      display: none;
+    }
+  }
+
+  #throbber {
+    animation-duration: 3s;
+    animation-name: bounce;
+    animation-iteration-count: infinite;
+    animation-direction: normal;
+    transform-origin: bottom center;
+
+    &.dead {
+      animation-name: dead;
+      animation-duration: 2s;
+      animation-iteration-count: 1;
+      transform: rotateX(90deg) rotateY(0) rotateZ(-45deg);
+    }
+
+    @keyframes dead {
+      0% {
+        transform: rotateX(0) rotateY(0) rotateZ(0);
+      }
+
+      5% {
+        transform: rotateX(0) rotateY(0) rotateZ(1deg);
+      }
+
+      10% {
+        transform: rotateX(0) rotateY(0) rotateZ(-2deg);
+      }
+
+      15% {
+        transform: rotateX(0) rotateY(0) rotateZ(3deg);
+      }
+
+      20% {
+        transform: rotateX(0) rotateY(0) rotateZ(0);
+      }
+
+      25% {
+        transform: rotateX(0) rotateY(0) rotateZ(0);
+      }
+
+      30% {
+        transform: rotateX(10deg) rotateY(0) rotateZ(0);
+      }
+
+      35% {
+        transform: rotateX(-10deg) rotateY(0) rotateZ(0);
+      }
+
+      40% {
+        transform: rotateX(10deg) rotateY(0) rotateZ(0);
+      }
+
+      45% {
+        transform: rotateX(-10deg) rotateY(0) rotateZ(0);
+      }
+
+      50% {
+        transform: rotateX(10deg) rotateY(0) rotateZ(0);
+      }
+
+      100% {
+        transform: rotateX(90deg) rotateY(0) rotateZ(-45deg);
+        transition-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); /* easeInQuint */
+      }
+    }
+
+    @keyframes bounce {
+      0% {
+        scale: 1 1;
+        translate: 0 0;
+        animation-timing-function: ease-out;
+      }
+
+      10% {
+        scale: 1.2 0.8;
+        translate: 0 0;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-out;
+      }
+
+      30% {
+        scale: 0.9 1.1;
+        translate: 0 -40%;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-in;
+      }
+
+      40% {
+        scale: 1.1 0.9;
+        translate: 0 -50%;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-in;
+      }
+
+      45% {
+        scale: 0.9 1.1;
+        translate: 0 -45%;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-in;
+      }
+
+      50% {
+        scale: 1.05 0.95;
+        translate: 0 -40%;
+        animation-timing-function: ease-in;
+      }
+
+      55% {
+        scale: 0.985 1.025;
+        translate: 0 -35%;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-in;
+      }
+
+      60% {
+        scale: 1.0125 0.9985;
+        translate: 0 -30%;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-in;
+      }
+
+      80% {
+        scale: 1.0063 0.9938;
+        translate: 0 -10%;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-in-ou;
+      }
+
+      90% {
+        scale: 1.2 0.8;
+        translate: 0 0;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-out;
+      }
+
+      100% {
+        scale: 1 1;
+        translate: 0 0;
+        transform: rotateZ(var(--defaultZ));
+        animation-timing-function: ease-out;
+      }
+    }
+  }
+}

BIN
src/assets/pleromatan_apology.png


+ 1 - 0
src/assets/pleromatan_apology.png

@@ -0,0 +1 @@
+../../static/pleromatan_apology.png

BIN
src/assets/pleromatan_apology_fox.png


+ 1 - 0
src/assets/pleromatan_apology_fox.png

@@ -0,0 +1 @@
+../../static/pleromatan_apology_fox.png

+ 12 - 9
src/boot/after_store.js

@@ -328,11 +328,7 @@ const setConfig = async ({ store }) => {
 
 const checkOAuthToken = async ({ store }) => {
   if (store.getters.getUserToken()) {
-    try {
-      await store.dispatch('loginUser', store.getters.getUserToken())
-    } catch (e) {
-      console.error(e)
-    }
+    return store.dispatch('loginUser', store.getters.getUserToken())
   }
   return Promise.resolve()
 }
@@ -350,19 +346,26 @@ const afterStoreSetup = async ({ store, i18n }) => {
   const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
   store.dispatch('setInstanceOption', { name: 'server', value: server })
 
+  document.querySelector('#status').textContent = i18n.global.t('splash.settings')
   await setConfig({ store })
-  await store.dispatch('setTheme')
+  document.querySelector('#status').textContent = i18n.global.t('splash.theme')
+  try {
+    await store.dispatch('setTheme').catch((e) => { console.error('Error setting theme', e) })
+  } catch (e) {
+    return Promise.reject(e)
+  }
 
-  applyConfig(store.state.config)
+  applyConfig(store.state.config, i18n.global)
 
   // Now we can try getting the server settings and logging in
   // Most of these are preloaded into the index.html so blocking is minimized
+  document.querySelector('#status').textContent = i18n.global.t('splash.instance')
   await Promise.all([
     checkOAuthToken({ store }),
     getInstancePanel({ store }),
     getNodeInfo({ store }),
     getInstanceConfig({ store })
-  ])
+  ]).catch(e => Promise.reject(e))
 
   // Start fetching things that don't need to block the UI
   store.dispatch('fetchMutes')
@@ -396,9 +399,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
 
   // remove after vue 3.3
   app.config.unwrapInjectedRef = true
+  document.querySelector('#status').textContent = i18n.global.t('splash.almost')
 
   app.mount('#app')
-
   return app
 }
 

+ 11 - 0
src/components/button.style.js

@@ -96,6 +96,17 @@ export default {
         textOpacity: 0.25,
         textOpacityMode: 'blend'
       }
+    },
+    {
+      component: 'Icon',
+      parent: {
+        component: 'Button',
+        state: ['disabled']
+      },
+      directives: {
+        textOpacity: 0.25,
+        textOpacityMode: 'blend'
+      }
     }
   ]
 }

+ 21 - 11
src/components/checkbox/checkbox.vue

@@ -3,6 +3,13 @@
     class="checkbox"
     :class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }"
   >
+    <span
+      v-if="!!$slots.before"
+      class="label -before"
+      :class="{ faint: disabled }"
+    >
+      <slot name="before" />
+    </span>
     <input
       type="checkbox"
       class="visible-for-screenreader-only"
@@ -14,11 +21,13 @@
     <i
       class="input -checkbox checkbox-indicator"
       :aria-hidden="true"
+      :class="{ disabled }"
       @transitionend.capture="onTransitionEnd"
     />
     <span
       v-if="!!$slots.default"
-      class="label"
+      class="label -after"
+      :class="{ faint: disabled }"
     >
       <slot />
     </span>
@@ -93,14 +102,9 @@ export default {
     box-sizing: border-box;
   }
 
-  &.disabled {
-    .checkbox-indicator::before,
-    .label {
-      opacity: 0.5;
-    }
-
-    .label {
-      color: var(--text);
+  .disabled {
+    .checkbox-indicator::before {
+      background-color: var(--background);
     }
   }
 
@@ -121,8 +125,14 @@ export default {
     }
   }
 
-  & > span {
-    margin-left: 0.5em;
+  & > .label {
+    &.-after {
+      margin-left: 0.5em;
+    }
+
+    &.-before {
+      margin-right: 0.5em;
+    }
   }
 }
 </style>

+ 18 - 6
src/components/color_input/color_input.scss

@@ -1,12 +1,15 @@
 .color-input {
   display: inline-flex;
 
+  .label {
+    flex: 1 1 auto;
+  }
+
   &-field.input {
     display: inline-flex;
     flex: 0 0 0;
     max-width: 9em;
     align-items: stretch;
-    padding: 0.2em 8px;
 
     input {
       color: var(--text);
@@ -25,6 +28,7 @@
     .nativeColor {
       cursor: pointer;
       flex: 0 0 auto;
+      padding: 0;
 
       input {
         appearance: none;
@@ -41,10 +45,10 @@
     .invalidIndicator,
     .transparentIndicator {
       flex: 0 0 2em;
-      margin: 0 0.5em;
+      margin: 0.2em 0.5em;
       min-width: 2em;
       align-self: stretch;
-      min-height: 1.5em;
+      min-height: 1.1em;
       border-radius: var(--roundness);
     }
 
@@ -81,9 +85,17 @@
         border-bottom-right-radius: var(--roundness);
       }
     }
-  }
 
-  .label {
-    flex: 1 1 auto;
+    &.disabled,
+    &:disabled {
+      .nativeColor input,
+      .computedIndicator,
+      .validIndicator,
+      .invalidIndicator,
+      .transparentIndicator {
+        /* stylelint-disable-next-line declaration-no-important */
+        opacity: 0.25 !important;
+      }
+    }
   }
 }

+ 16 - 4
src/components/color_input/color_input.vue

@@ -6,6 +6,7 @@
     <label
       :for="name"
       class="label"
+      :class="{ faint: !present || disabled }"
     >
       {{ label }}
     </label>
@@ -14,16 +15,20 @@
       :model-value="present"
       :disabled="disabled"
       class="opt"
-      @update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
+      @update:modelValue="update(typeof modelValue === 'undefined' ? fallback : undefined)"
     />
-    <div class="input color-input-field">
+    <div
+      class="input color-input-field"
+      :class="{ disabled: !present || disabled }"
+    >
       <input
         :id="name + '-t'"
         class="textColor unstyled"
+        :class="{ disabled: !present || disabled }"
         type="text"
         :value="modelValue || fallback"
         :disabled="!present || disabled"
-        @input="$emit('update:modelValue', $event.target.value)"
+        @input="updateValue($event.target.value)"
       >
       <div
         v-if="validColor"
@@ -51,7 +56,8 @@
           type="color"
           :value="modelValue || fallback"
           :disabled="!present || disabled"
-          @input="$emit('update:modelValue', $event.target.value)"
+          :class="{ disabled: !present || disabled }"
+          @input="updateValue($event.target.value)"
         >
       </label>
     </div>
@@ -60,6 +66,7 @@
 <script>
 import Checkbox from '../checkbox/checkbox.vue'
 import { hex2rgb } from '../../services/color_convert/color_convert.js'
+import { throttle } from 'lodash'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -125,6 +132,11 @@ export default {
     computedColor () {
       return this.modelValue && this.modelValue.startsWith('--')
     }
+  },
+  methods: {
+    updateValue: throttle(function (value) {
+      this.$emit('update:modelValue', value)
+    }, 100)
   }
 }
 </script>

+ 212 - 0
src/components/component_preview/component_preview.vue

@@ -0,0 +1,212 @@
+<template>
+<div
+  class="ComponentPreview"
+  :class="{ '-shadow-controls': shadowControl }"
+>
+  <label
+    class="header"
+    v-show="shadowControl"
+    :class="{ faint: disabled }"
+  >
+    {{ $t('settings.style.shadows.offset') }}
+  </label>
+  <input
+    v-show="shadowControl"
+    :value="shadow?.y"
+    :disabled="disabled"
+    :class="{ disabled }"
+    class="input input-number y-shift-number"
+    type="number"
+    @input="e => updateProperty('y', e.target.value)"
+  >
+  <input
+    v-show="shadowControl"
+    :value="shadow?.y"
+    :disabled="disabled"
+    :class="{ disabled }"
+    class="input input-range y-shift-slider"
+    type="range"
+    max="20"
+    min="-20"
+    @input="e => updateProperty('y', e.target.value)"
+  >
+  <div
+    class="preview-window"
+    :class="{ '-light-grid': lightGrid }"
+  >
+    <div
+      class="preview-block"
+      :style="previewStyle"
+    />
+  </div>
+  <input
+    v-show="shadowControl"
+    :value="shadow?.x"
+    :disabled="disabled"
+    :class="{ disabled }"
+    class="input input-number x-shift-number"
+    type="number"
+    @input="e => updateProperty('x', e.target.value)"
+  >
+  <input
+    v-show="shadowControl"
+    :value="shadow?.x"
+    :disabled="disabled"
+    :class="{ disabled }"
+    class="input input-range x-shift-slider"
+    type="range"
+    max="20"
+    min="-20"
+    @input="e => updateProperty('x', e.target.value)"
+  >
+  <Checkbox
+    id="lightGrid"
+    v-model="lightGrid"
+    :disabled="shadow == null"
+    name="lightGrid"
+    class="input-light-grid"
+  >
+    {{ $t('settings.style.shadows.light_grid') }}
+  </Checkbox>
+</div>
+</template>
+
+<style lang="scss">
+.ComponentPreview {
+  display: grid;
+  grid-template-columns: 3em 1fr 3em;
+  grid-template-rows: 2em 1fr 2em;
+  grid-template-areas:
+    ".       header  y-num  "
+    ".       preview y-slide"
+    "x-num   x-slide .      "
+    "options options options";
+  grid-gap: 0.5em;
+
+  .header {
+    grid-area: header;
+    justify-self: center;
+    align-self: baseline;
+    line-height: 2;
+  }
+
+  .input-light-grid {
+    grid-area: options;
+    justify-self: center;
+  }
+
+  .input-number {
+    min-width: 2em;
+  }
+
+  .x-shift-number {
+    grid-area: x-num;
+  }
+
+  .x-shift-slider {
+    grid-area: x-slide;
+    height: auto;
+    align-self: start;
+    min-width: 10em;
+  }
+
+  .y-shift-number {
+    grid-area: y-num;
+  }
+
+  .y-shift-slider {
+    grid-area: y-slide;
+    writing-mode: vertical-lr;
+    justify-self: left;
+    min-height: 10em;
+  }
+
+  .x-shift-slider,
+  .y-shift-slider {
+    padding: 0;
+  }
+
+  .preview-window {
+    --__grid-color1: rgb(102 102 102);
+    --__grid-color2: rgb(153 153 153);
+    --__grid-color1-disabled: rgba(102 102 102 / 20%);
+    --__grid-color2-disabled: rgba(153 153 153 / 20%);
+
+    &.-light-grid {
+      --__grid-color1: rgb(205 205 205);
+      --__grid-color2: rgb(255 255 255);
+      --__grid-color1-disabled: rgba(205 205 205 / 20%);
+      --__grid-color2-disabled: rgba(255 255 255 / 20%);
+    }
+
+    grid-area: preview;
+    aspect-ratio: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-width: 10em;
+    min-height: 10em;
+    background-color: var(--__grid-color2);
+    background-image:
+      linear-gradient(45deg, var(--__grid-color1) 25%, transparent 25%),
+      linear-gradient(-45deg, var(--__grid-color1) 25%, transparent 25%),
+      linear-gradient(45deg, transparent 75%, var(--__grid-color1) 75%),
+      linear-gradient(-45deg, transparent 75%, var(--__grid-color1) 75%);
+    background-size: 20px 20px;
+    background-position: 0 0, 0 10px, 10px -10px, -10px 0;
+    border-radius: var(--roundness);
+
+    &.disabled {
+      background-color: var(--__grid-color2-disabled);
+      background-image:
+        linear-gradient(45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
+        linear-gradient(-45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
+        linear-gradient(45deg, transparent 75%, var(--__grid-color1-disabled) 75%),
+        linear-gradient(-45deg, transparent 75%, var(--__grid-color1-disabled) 75%);
+    }
+
+    .preview-block {
+      background: var(--background, var(--bg));
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      min-width: 33%;
+      min-height: 33%;
+      max-width: 80%;
+      max-height: 80%;
+      border-width: 0;
+      border-style: solid;
+      border-color: var(--border);
+      border-radius: var(--roundness);
+      box-shadow: var(--shadow);
+    }
+  }
+}
+</style>
+<script>
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+export default {
+  props: [
+    'shadow',
+    'shadowControl',
+    'previewClass',
+    'previewStyle',
+    'disabled'
+  ],
+  data () {
+    return {
+      lightGrid: false
+    }
+  },
+  emits: ['update:shadow'],
+  components: {
+    Checkbox
+  },
+  methods: {
+    updateProperty (axis, value) {
+      this.$emit('update:shadow', { axis, value })
+    }
+  }
+}
+</script>

+ 36 - 7
src/components/input.style.js

@@ -10,17 +10,18 @@ const hoverGlow = {
 export default {
   name: 'Input',
   selector: '.input',
-  variant: {
+  states: {
+    hover: ':hover:not(.disabled)',
+    focused: ':focus-within',
+    disabled: '.disabled'
+  },
+  variants: {
     checkbox: '.-checkbox',
     radio: '.-radio'
   },
-  states: {
-    disabled: ':disabled',
-    hover: ':hover:not(:disabled)',
-    focused: ':focus-within'
-  },
   validInnerComponents: [
-    'Text'
+    'Text',
+    'Icon'
   ],
   defaultRules: [
     {
@@ -55,6 +56,34 @@ export default {
       directives: {
         shadow: [hoverGlow, '--defaultInputBevel']
       }
+    },
+    {
+      state: ['disabled'],
+      directives: {
+        background: '--parent'
+      }
+    },
+    {
+      component: 'Text',
+      parent: {
+        component: 'Input',
+        state: ['disabled']
+      },
+      directives: {
+        textOpacity: 0.25,
+        textOpacityMode: 'blend'
+      }
+    },
+    {
+      component: 'Icon',
+      parent: {
+        component: 'Input',
+        state: ['disabled']
+      },
+      directives: {
+        textOpacity: 0.25,
+        textOpacityMode: 'blend'
+      }
     }
   ]
 }

+ 2 - 0
src/components/opacity_input/opacity_input.vue

@@ -6,6 +6,7 @@
     <label
       :for="name"
       class="label"
+      :class="{ faint: !present || disabled }"
     >
       {{ $t('settings.style.common.opacity') }}
     </label>
@@ -22,6 +23,7 @@
       type="number"
       :value="modelValue || fallback"
       :disabled="!present || disabled"
+      :class="{ disabled: !present || disabled }"
       max="1"
       min="0"
       step=".05"

+ 34 - 1
src/components/select/select.vue

@@ -6,13 +6,14 @@
     <select
       :disabled="disabled"
       :value="modelValue"
-      v-bind="attrs"
+      v-bind="$attrs"
       @change="$emit('update:modelValue', $event.target.value)"
     >
       <slot />
     </select>
     {{ ' ' }}
     <FAIcon
+      v-if="!$attrs.size && !$attrs.multiple"
       class="select-down-icon"
       icon="chevron-down"
     />
@@ -39,6 +40,38 @@ label.Select {
     z-index: 1;
     height: 2em;
     line-height: 16px;
+
+    &[multiple],
+    &[size] {
+      height: 100%;
+      padding: 0.2em;
+
+      option {
+        background-color: transparent;
+
+        &.-active {
+          color: var(--selectionText);
+          background-color: var(--selectionBackground);
+        }
+      }
+    }
+  }
+
+  &.disabled,
+  &:disabled {
+    background-color: var(--background);
+    opacity: 1; /* override browser */
+    color: var(--faint);
+
+    select {
+      &[multiple],
+      &[size] {
+        option.-active {
+          color: var(--faint);
+          background: transparent;
+        }
+      }
+    }
   }
 
   .select-down-icon {

+ 12 - 1
src/components/settings_modal/tabs/theme_tab/theme_tab.js

@@ -314,7 +314,18 @@ export default {
       },
       set (val) {
         if (val) {
-          this.shadowsLocal[this.shadowSelected] = this.currentShadowFallback.map(_ => Object.assign({}, _))
+          this.shadowsLocal[this.shadowSelected] = (this.currentShadowFallback || [])
+            .map(s => ({
+              name: null,
+              x: 0,
+              y: 0,
+              blur: 0,
+              spread: 0,
+              inset: false,
+              color: '#000000',
+              alpha: 1,
+              ...s
+            }))
         } else {
           delete this.shadowsLocal[this.shadowSelected]
         }

+ 8 - 7
src/components/settings_modal/tabs/theme_tab/theme_tab.scss

@@ -25,7 +25,9 @@
     margin-bottom: 5px;
 
     .label {
+      margin-right: 1em;
       flex: 1;
+      line-height: 2;
     }
 
     .opt {
@@ -48,15 +50,14 @@
 
       &[type="range"] {
         flex: 1;
-        min-width: 3em;
-        align-self: flex-start;
+        min-width: 2em;
+        align-self: center;
+        margin: 0 0.5em;
       }
-    }
 
-    &.disabled {
-      input,
-      select {
-        opacity: 0.5;
+      &[type="checkbox"] + i {
+        height: 1.1em;
+        align-self: center;
       }
     }
   }

+ 11 - 46
src/components/settings_modal/tabs/theme_tab/theme_tab.vue

@@ -123,10 +123,13 @@
       </div>
     </div>
 
-    <!-- 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"/>
+    <!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
+    <component
+      :is="'style'"
+      v-html="themeV3Preview"
+    />
+    <!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
+    <preview id="theme-preview" />
 
     <div>
       <button
@@ -934,24 +937,14 @@
               </Select>
             </div>
             <div class="override">
-              <label
-                for="override"
-                class="label"
-              >
-                {{ $t('settings.style.shadows.override') }}
-              </label>
-              {{ ' ' }}
-              <input
+              <Checkbox
                 id="override"
                 v-model="currentShadowOverriden"
                 name="override"
                 class="input-override"
-                type="checkbox"
               >
-              <label
-                class="checkbox-label"
-                for="override"
-              />
+                {{ $t('settings.style.shadows.override') }}
+              </Checkbox>
             </div>
             <button
               class="btn button-default"
@@ -962,38 +955,10 @@
           </div>
           <ShadowControl
             v-model="currentShadow"
-            :ready="!!currentShadowFallback"
+            :separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
             :fallback="currentShadowFallback"
           />
-          <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'">
-            <i18n-t
-              scope="global"
-              keypath="settings.style.shadows.filter_hint.always_drop_shadow"
-              tag="p"
-            >
-              <code>filter: drop-shadow()</code>
-            </i18n-t>
-            <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
-            <i18n-t
-              scope="global"
-              keypath="settings.style.shadows.filter_hint.drop_shadow_syntax"
-              tag="p"
-            >
-              <code>drop-shadow</code>
-              <code>spread-radius</code>
-              <code>inset</code>
-            </i18n-t>
-            <i18n-t
-              scope="global"
-              keypath="settings.style.shadows.filter_hint.inset_classic"
-              tag="p"
-            >
-              <code>box-shadow</code>
-            </i18n-t>
-            <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
-          </div>
         </div>
-
         <div
           :label="$t('settings.style.fonts._tab_label')"
           class="fonts-container"

+ 73 - 63
src/components/shadow_control/shadow_control.js

@@ -1,9 +1,12 @@
-import ColorInput from '../color_input/color_input.vue'
-import OpacityInput from '../opacity_input/opacity_input.vue'
-import Select from '../select/select.vue'
-import { getCssShadow } from '../../services/theme_data/theme_data.service.js'
-import { hex2rgb } from '../../services/color_convert/color_convert.js'
+import ColorInput from 'src/components/color_input/color_input.vue'
+import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
+import Select from 'src/components/select/select.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import Popover from 'src/components/popover/popover.vue'
+import ComponentPreview from 'src/components/component_preview/component_preview.vue'
+import { getCssShadow, getCssShadowFilter } from '../../services/theme_data/theme_data.service.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
+import { throttle } from 'lodash'
 import {
   faTimes,
   faChevronDown,
@@ -30,93 +33,100 @@ const toModel = (object = {}) => ({
 })
 
 export default {
-  // 'modelValue' and 'Fallback' can be undefined, but if they are
-  // initially vue won't detect it when they become something else
-  // therefore i'm using "ready" which should be passed as true when
-  // data becomes available
   props: [
-    'modelValue', 'fallback', 'ready'
+    'modelValue', 'fallback', 'separateInset', 'noPreview', 'disabled'
   ],
-  emits: ['update:modelValue'],
+  emits: ['update:modelValue', 'subShadowSelected'],
   data () {
     return {
       selectedId: 0,
       // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason)
-      cValue: (this.modelValue || this.fallback || []).map(toModel)
+      cValue: (this.modelValue ?? this.fallback ?? []).map(toModel)
     }
   },
   components: {
     ColorInput,
     OpacityInput,
-    Select
+    Select,
+    Checkbox,
+    Popover,
+    ComponentPreview
+  },
+  beforeUpdate () {
+    this.cValue = (this.modelValue ?? this.fallback ?? []).map(toModel)
+  },
+  computed: {
+    selected () {
+      const selected = this.cValue[this.selectedId]
+      if (selected) {
+        return { ...selected }
+      }
+      return null
+    },
+    present () {
+      return this.selected != null && !this.usingFallback
+    },
+    shadowsAreNull () {
+      return this.modelValue == null
+    },
+    currentFallback () {
+      return this.fallback?.[this.selectedId]
+    },
+    moveUpValid () {
+      return this.selectedId > 0
+    },
+    moveDnValid () {
+      return this.selectedId < this.cValue.length - 1
+    },
+    usingFallback () {
+      return this.modelValue == null
+    },
+    style () {
+      if (this.separateInset) {
+        return {
+          filter: getCssShadowFilter(this.cValue),
+          boxShadow: getCssShadow(this.cValue, true)
+        }
+      }
+      return {
+        boxShadow: getCssShadow(this.cValue)
+      }
+    }
+  },
+  watch: {
+    selected (value) {
+      this.$emit('subShadowSelected', this.selectedId)
+    }
   },
   methods: {
+    updateProperty: throttle(function (prop, value) {
+      this.cValue[this.selectedId][prop] = value
+      if (prop === 'inset' && value === false && this.separateInset) {
+        this.cValue[this.selectedId].spread = 0
+      }
+      this.$emit('update:modelValue', this.cValue)
+    }, 100),
     add () {
       this.cValue.push(toModel(this.selected))
-      this.selectedId = this.cValue.length - 1
+      this.selectedId = Math.max(this.cValue.length - 1, 0)
+      this.$emit('update:modelValue', this.cValue)
     },
     del () {
       this.cValue.splice(this.selectedId, 1)
       this.selectedId = this.cValue.length === 0 ? undefined : Math.max(this.selectedId - 1, 0)
+      this.$emit('update:modelValue', this.cValue)
     },
     moveUp () {
       const movable = this.cValue.splice(this.selectedId, 1)[0]
       this.cValue.splice(this.selectedId - 1, 0, movable)
       this.selectedId -= 1
+      this.$emit('update:modelValue', this.cValue)
     },
     moveDn () {
       const movable = this.cValue.splice(this.selectedId, 1)[0]
       this.cValue.splice(this.selectedId + 1, 0, movable)
       this.selectedId += 1
-    }
-  },
-  beforeUpdate () {
-    this.cValue = this.modelValue || this.fallback
-  },
-  computed: {
-    anyShadows () {
-      return this.cValue.length > 0
-    },
-    anyShadowsFallback () {
-      return this.fallback.length > 0
-    },
-    selected () {
-      if (this.ready && this.anyShadows) {
-        return this.cValue[this.selectedId]
-      } else {
-        return toModel({})
-      }
-    },
-    currentFallback () {
-      if (this.ready && this.anyShadowsFallback) {
-        return this.fallback[this.selectedId]
-      } else {
-        return toModel({})
-      }
-    },
-    moveUpValid () {
-      return this.ready && this.selectedId > 0
-    },
-    moveDnValid () {
-      return this.ready && this.selectedId < this.cValue.length - 1
-    },
-    present () {
-      return this.ready &&
-        typeof this.cValue[this.selectedId] !== 'undefined' &&
-        !this.usingFallback
-    },
-    usingFallback () {
-      return typeof this.modelValue === 'undefined'
-    },
-    rgb () {
-      return hex2rgb(this.selected.color)
-    },
-    style () {
-      return this.ready
-        ? {
-            boxShadow: getCssShadow(this.fallback)
-          }
-        : {}
+      this.$emit('update:modelValue', this.cValue)
     }
   }
 }

+ 105 - 0
src/components/shadow_control/shadow_control.scss

@@ -0,0 +1,105 @@
+.settings-modal .settings-modal-panel .shadow-control {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: stretch;
+  grid-gap: 0.25em;
+  margin-bottom: 1em;
+
+  .shadow-switcher {
+    order: 1;
+    flex: 1 0 6em;
+    min-width: 6em;
+    margin-right: 0.125em;
+    display: flex;
+    flex-direction: column;
+
+    .shadow-list {
+      flex: 1 0 auto;
+    }
+
+    .arrange-buttons {
+      flex: 0 0 auto;
+      display: grid;
+      grid-auto-columns: 1fr;
+      grid-auto-flow: column;
+      margin-top: 0.25em;
+
+      .button-default {
+        margin: 0;
+        padding: 0;
+      }
+    }
+  }
+
+  .shadow-tweak {
+    order: 3;
+    flex: 2 0 10em;
+    min-width: 10em;
+    margin-left: 0.125em;
+    margin-right: 0.125em;
+
+    /* hack */
+    .input-boolean {
+      flex: 1;
+      display: flex;
+
+      .label {
+        flex: 1;
+      }
+    }
+
+    .input-string {
+      flex: 1 0 5em;
+    }
+
+    .id-control {
+      align-items: stretch;
+
+      .shadow-switcher,
+      .btn {
+        min-width: 1px;
+        margin-right: 5px;
+      }
+
+      .btn {
+        padding: 0 0.4em;
+        margin: 0 0.1em;
+      }
+    }
+  }
+
+  &.-no-preview {
+    .shadow-tweak {
+      order: 0;
+      flex: 2 0 8em;
+      max-width: 100%;
+    }
+
+    .input-range {
+      min-width: 5em;
+    }
+  }
+
+  .inset-alert {
+    padding: 0.25em 0.5em;
+  }
+
+  &.disabled {
+    .inset-alert {
+      opacity: 0.2;
+    }
+  }
+
+  .shadow-preview {
+    order: 2;
+    flex: 3 3 15em;
+    min-width: 10em;
+    margin-left: 0.125em;
+    align-self: start;
+  }
+}
+
+.inset-tooltip {
+  padding: 0.5em;
+  max-width: 30em;
+}

+ 143 - 213
src/components/shadow_control/shadow_control.vue

@@ -1,91 +1,51 @@
 <template>
   <div
-    class="shadow-control"
-    :class="{ disabled: !present }"
+    class="label shadow-control"
+    :class="{ disabled: disabled || !present, '-no-preview': noPreview }"
   >
-    <div class="shadow-preview-container">
-      <div
-        :disabled="!present"
-        class="y-shift-control"
-      >
-        <input
-          v-model="selected.y"
-          :disabled="!present"
-          class="input input-number"
-          type="number"
-        >
-        <div class="wrap">
-          <input
-            v-model="selected.y"
-            :disabled="!present"
-            class="input input-range"
-            type="range"
-            max="20"
-            min="-20"
-          >
-        </div>
-      </div>
-      <div class="preview-window">
-        <div
-          class="preview-block"
-          :style="style"
-        />
-      </div>
-      <div
-        :disabled="!present"
-        class="x-shift-control"
+    <ComponentPreview
+      v-if="!noPreview"
+      class="shadow-preview"
+      :shadow-control="true"
+      :shadow="selected"
+      :preview-style="style"
+      :disabled="disabled || !present"
+      @update:shadow="({ axis, value }) => updateProperty(axis, value)"
+    />
+    <div class="shadow-switcher">
+      <Select
+        id="shadow-list"
+        v-model="selectedId"
+        class="shadow-list"
+        size="10"
+        :disabled="shadowsAreNull"
       >
-        <input
-          v-model="selected.x"
-          :disabled="!present"
-          class="input input-number"
-          type="number"
+        <option
+          v-for="(shadow, index) in cValue"
+          :key="index"
+          :value="index"
+          :class="{ '-active': index === Number(selectedId) }"
         >
-        <div class="wrap">
-          <input
-            v-model="selected.x"
-            :disabled="!present"
-            class="input input-range"
-            type="range"
-            max="20"
-            min="-20"
-          >
-        </div>
-      </div>
-    </div>
-
-    <div class="shadow-tweak">
+          {{ shadow?.name ?? $t('settings.style.shadows.shadow_id', { value: index }) }}
+        </option>
+      </Select>
       <div
-        :disabled="usingFallback"
-        class="id-control style-control"
+        class="id-control btn-group arrange-buttons"
       >
-        <Select
-          id="shadow-switcher"
-          v-model="selectedId"
-          class="shadow-switcher"
-          :disabled="!ready || usingFallback"
-        >
-          <option
-            v-for="(shadow, index) in cValue"
-            :key="index"
-            :value="index"
-          >
-            {{ $t('settings.style.shadows.shadow_id', { value: index }) }}
-          </option>
-        </Select>
         <button
           class="btn button-default"
-          :disabled="!ready || !present"
-          @click="del"
+          :disabled="disabled || shadowsAreNull"
+          @click="add"
         >
           <FAIcon
             fixed-width
-            icon="times"
+            icon="plus"
           />
         </button>
         <button
           class="btn button-default"
-          :disabled="!moveUpValid"
+          :disabled="disabled || !moveUpValid"
+          :class="{ disabled: disabled || !moveUpValid }"
           @click="moveUp"
         >
           <FAIcon
@@ -95,7 +55,8 @@
         </button>
         <button
           class="btn button-default"
-          :disabled="!moveDnValid"
+          :disabled="disabled || !moveDnValid"
+          :class="{ disabled: disabled || !moveDnValid }"
           @click="moveDn"
         >
           <FAIcon
@@ -105,222 +66,191 @@
         </button>
         <button
           class="btn button-default"
-          :disabled="usingFallback"
-          @click="add"
+          :disabled="disabled || !present"
+          :class="{ disabled: disabled || !present }"
+          @click="del"
         >
           <FAIcon
             fixed-width
-            icon="plus"
+            icon="times"
           />
         </button>
       </div>
+    </div>
+    <div class="shadow-tweak">
       <div
-        :disabled="!present"
-        class="inset-control style-control"
+        :class="{ disabled: disabled || !present }"
+        class="name-control style-control"
       >
         <label
-          for="inset"
+          for="name"
           class="label"
+          :class="{ faint: disabled || !present }"
         >
-          {{ $t('settings.style.shadows.inset') }}
+          {{ $t('settings.style.shadows.name') }}
         </label>
         <input
+          id="name"
+          :value="selected?.name"
+          :disabled="disabled || !present"
+          :class="{ disabled: disabled || !present }"
+          name="name"
+          class="input input-string"
+          @input="e => updateProperty('name', e.target.value)"
+        >
+      </div>
+      <div
+        :disabled="disabled || !present"
+        class="inset-control style-control"
+      >
+        <Checkbox
           id="inset"
-          v-model="selected.inset"
-          :disabled="!present"
+          :value="selected?.inset"
+          :disabled="disabled || !present"
           name="inset"
-          class="input -checkbox input-inset visible-for-screenreader-only"
-          type="checkbox"
+          class="input-inset input-boolean"
+          @input="e => updateProperty('inset', e.target.checked)"
         >
-        <label
-          class="checkbox-label"
-          for="inset"
-          :aria-hidden="true"
-        />
+          <template #before>
+            {{ $t('settings.style.shadows.inset') }}
+          </template>
+        </Checkbox>
       </div>
       <div
-        :disabled="!present"
+        :disabled="disabled || !present"
+        :class="{ disabled: disabled || !present }"
         class="blur-control style-control"
       >
         <label
-          for="spread"
+          for="blur"
           class="label"
+          :class="{ faint: disabled || !present }"
         >
           {{ $t('settings.style.shadows.blur') }}
         </label>
         <input
           id="blur"
-          v-model="selected.blur"
-          :disabled="!present"
+          :value="selected?.blur"
+          :disabled="disabled || !present"
+          :class="{ disabled: disabled || !present }"
           name="blur"
           class="input input-range"
           type="range"
           max="20"
           min="0"
+          @input="e => updateProperty('blur', e.target.value)"
         >
         <input
-          v-model="selected.blur"
-          :disabled="!present"
-          class="input input-number"
+          :value="selected?.blur"
+          class="input input-number -small"
+          :disabled="disabled || !present"
+          :class="{ disabled: disabled || !present }"
           type="number"
           min="0"
+          @input="e => updateProperty('blur', e.target.value)"
         >
       </div>
       <div
-        :disabled="!present"
         class="spread-control style-control"
+        :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
       >
         <label
           for="spread"
           class="label"
+          :class="{ faint: disabled || !present || (separateInset && !selected?.inset) }"
         >
           {{ $t('settings.style.shadows.spread') }}
         </label>
         <input
           id="spread"
-          v-model="selected.spread"
-          :disabled="!present"
+          :value="selected?.spread"
+          :disabled="disabled || !present || (separateInset && !selected?.inset)"
+          :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
           name="spread"
           class="input input-range"
           type="range"
           max="20"
           min="-20"
+          @input="e => updateProperty('spread', e.target.value)"
         >
         <input
-          v-model="selected.spread"
-          :disabled="!present"
-          class="input input-number"
+          :value="selected?.spread"
+          class="input input-number -small"
+          :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
+          :disabled="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
           type="number"
+          @input="e => updateProperty('spread', e.target.value)"
         >
       </div>
       <ColorInput
-        v-model="selected.color"
-        :disabled="!present"
+        :model-value="selected?.color"
+        :disabled="disabled || !present"
         :label="$t('settings.style.common.color')"
-        :fallback="currentFallback.color"
+        :fallback="currentFallback?.color"
         :show-optional-tickbox="false"
         name="shadow"
+        @update:modelValue="e => updateProperty('color', e)"
       />
       <OpacityInput
-        v-model="selected.alpha"
-        :disabled="!present"
+        :model-value="selected?.alpha"
+        :disabled="disabled || !present"
+        @update:modelValue="e => updateProperty('alpha', e)"
       />
       <i18n-t
         scope="global"
         keypath="settings.style.shadows.hintV3"
+        :class="{ faint: disabled || !present }"
         tag="p"
       >
         <code>--variable,mod</code>
       </i18n-t>
+      <Popover
+        v-if="separateInset"
+        trigger="hover"
+      >
+        <template #trigger>
+          <div
+            class="inset-alert alert warning"
+          >
+            <FAIcon icon="exclamation-triangle" />
+            &nbsp;
+            {{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }}
+          </div>
+        </template>
+        <template #content>
+          <div class="inset-tooltip">
+            <i18n-t
+              scope="global"
+              keypath="settings.style.shadows.filter_hint.always_drop_shadow"
+              tag="p"
+            >
+              <code>filter: drop-shadow()</code>
+            </i18n-t>
+            <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
+            <i18n-t
+              scope="global"
+              keypath="settings.style.shadows.filter_hint.drop_shadow_syntax"
+              tag="p"
+            >
+              <code>drop-shadow</code>
+              <code>spread-radius</code>
+              <code>inset</code>
+            </i18n-t>
+            <i18n-t
+              scope="global"
+              keypath="settings.style.shadows.filter_hint.inset_classic"
+              tag="p"
+            >
+              <code>box-shadow</code>
+            </i18n-t>
+            <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
+          </div>
+        </template>
+      </Popover>
     </div>
   </div>
 </template>
 
 <script src="./shadow_control.js"></script>
 
-<style lang="scss">
-.shadow-control {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: center;
-  margin-bottom: 1em;
-
-  .shadow-preview-container,
-  .shadow-tweak {
-    margin: 5px 6px 0 0;
-  }
-
-  .shadow-preview-container {
-    flex: 0;
-    display: flex;
-    flex-wrap: wrap;
-
-    input[type="number"] {
-      width: 5em;
-      min-width: 2em;
-    }
-
-    .x-shift-control,
-    .y-shift-control {
-      display: flex;
-      flex: 0;
-
-      &[disabled="disabled"] * {
-        opacity: 0.5;
-      }
-    }
-
-    .x-shift-control {
-      align-items: flex-start;
-    }
-
-    .x-shift-control .wrap,
-    input[type="range"] {
-      margin: 0;
-      width: 15em;
-      height: 2em;
-    }
-
-    .y-shift-control {
-      flex-direction: column;
-      align-items: flex-end;
-
-      .wrap {
-        width: 2em;
-        height: 15em;
-      }
-
-      input[type="range"] {
-        transform-origin: 1em 1em;
-        transform: rotate(90deg);
-      }
-    }
-
-    .preview-window {
-      flex: 1;
-      background-color: #999;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      background-image:
-        linear-gradient(45deg, #666 25%, transparent 25%),
-        linear-gradient(-45deg, #666 25%, transparent 25%),
-        linear-gradient(45deg, transparent 75%, #666 75%),
-        linear-gradient(-45deg, transparent 75%, #666 75%);
-      background-size: 20px 20px;
-      background-position: 0 0, 0 10px, 10px -10px, -10px 0;
-      border-radius: var(--roundness);
-
-      .preview-block {
-        width: 33%;
-        height: 33%;
-        border-radius: var(--roundness);
-      }
-    }
-  }
-
-  .shadow-tweak {
-    flex: 1;
-    min-width: 280px;
-
-    .id-control {
-      align-items: stretch;
-
-      .shadow-switcher {
-        flex: 1;
-      }
-
-      .shadow-switcher,
-      .btn {
-        min-width: 1px;
-        margin-right: 5px;
-      }
-
-      .btn {
-        padding: 0 0.4em;
-        margin: 0 0.1em;
-      }
-    }
-  }
-}
-</style>
+<style src="./shadow_control.scss" lang="scss"></style>

+ 16 - 0
src/i18n/en.json

@@ -876,6 +876,9 @@
         "component": "Component",
         "override": "Override",
         "shadow_id": "Shadow #{value}",
+        "offset": "Shadow offset",
+        "light_grid": "Use light checkerboard",
+        "name": "Name",
         "blur": "Blur",
         "spread": "Spread",
         "inset": "Inset",
@@ -883,6 +886,7 @@
         "filter_hint": {
           "always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
           "drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
+          "avatar_inset_short": "Separate inset shadow",
           "avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
           "spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
           "inset_classic": "Inset shadows will be using {0}"
@@ -1408,6 +1412,18 @@
   "unicode_domain_indicator": {
     "tooltip": "This domain contains non-ascii characters."
   },
+  "splash": {
+    "loading": "Loading...",
+    "theme": "Applying theme, please wait warmly...",
+    "instance": "Getting instance info...",
+    "settings": "Applying settings...",
+    "almost": "Reticulating splines...",
+    "fun_1": "Drink more water",
+    "fun_2": "Take it easy!",
+    "fun_3": "Suya...",
+    "fun_4": "My Pleroma machine is full power!",
+    "error": "Something went wrong"
+  },
   "bookmark_folders": {
     "select_folder": "Select bookmark folder",
     "creating_folder": "Creating bookmark folder",

+ 37 - 6
src/i18n/nan-TW.json

@@ -190,7 +190,8 @@
     "mobile_notifications_close": "關掉通知",
     "announcements": "公告",
     "search": "Tshuē",
-    "mobile_notifications_mark_as_seen": "Lóng 標做有讀"
+    "mobile_notifications_mark_as_seen": "Lóng 標做有讀",
+    "quotes": "引用"
   },
   "notifications": {
     "broken_favorite": "狀態毋知影,leh tshiau-tshuē……",
@@ -212,7 +213,8 @@
     "unread_follow_requests": "{num}ê新ê跟tuè請求",
     "configuration_tip": "用{theSettings},lí通自訂siánn物佇tsia顯示。{dismiss}",
     "configuration_tip_settings": "設定",
-    "configuration_tip_dismiss": "Mài koh顯示"
+    "configuration_tip_dismiss": "Mài koh顯示",
+    "subscribed_status": "有發送ê"
   },
   "polls": {
     "add_poll": "開投票",
@@ -252,7 +254,8 @@
     },
     "load_all_hint": "載入頭前 {saneAmount} ê 繪文字,規个攏載入效能可能 ē khah 食力。",
     "load_all": "Kā {emojiAmount} ê 繪文字攏載入",
-    "regional_indicator": "地區指引 {letter}"
+    "regional_indicator": "地區指引 {letter}",
+    "hide_custom_emoji": "Khàm掉自訂ê繪文字"
   },
   "errors": {
     "storage_unavailable": "Pleroma buē-tàng the̍h 著瀏覽器儲存 ê。Lí ê 登入狀態抑是局部設定 buē 儲存,mā 凡勢 tú 著意料外 ê 問題。拍開 cookie 看māi。"
@@ -263,7 +266,8 @@
     "emoji_reactions": "繪文字 ê 反應",
     "reports": "檢舉",
     "moves": "用者 ê 移民",
-    "load_older": "載入 koh khah 早 ê 互動"
+    "load_older": "載入 koh khah 早 ê 互動",
+    "statuses": "訂ê"
   },
   "post_status": {
     "edit_status": "編輯狀態",
@@ -935,7 +939,34 @@
     "notification_extra_chats": "顯示bô讀ê開講",
     "notification_extra_announcements": "顯示bô讀ê公告",
     "notification_extra_follow_requests": "顯示新ê跟tuè請求",
-    "notification_extra_tip": "顯示自訂其他通知ê撇步"
+    "notification_extra_tip": "顯示自訂其他通知ê撇步",
+    "confirm_new_setting": "Lí敢確認新ê設定?",
+    "text_size_tip": "用 {0} 做絕對值,{1} ē根據瀏覽器ê標準文字sài-suh放大縮小。",
+    "theme_debug": "佇處理透明ê時,顯示背景主題ia̋n-jín 所假使ê(DEBUG)",
+    "units": {
+      "time": {
+        "s": "秒鐘",
+        "m": "分鐘",
+        "h": "點鐘",
+        "d": "工"
+      }
+    },
+    "actor_type": "Tsit ê口座是:",
+    "actor_type_Person": "一般ê用者",
+    "actor_type_description": "標記lí ê口座做群組,ē hōo自動轉送提起伊ê狀態。",
+    "actor_type_Group": "群組",
+    "actor_type_Service": "機器lâng",
+    "appearance": "外觀",
+    "confirm_new_question": "Tse看起來kám好?設定ē佇10秒鐘後改轉去。",
+    "revert": "改轉去",
+    "confirm": "確認",
+    "text_size": "文字kap界面ê sài-suh",
+    "text_size_tip2": "毋是 {0} ê值可能ē破壞一寡物件kap主題",
+    "emoji_size": "繪文字ê sài-suh",
+    "navbar_size": "頂 liâu-á êsài-suh",
+    "panel_header_size": "面pang標題ê sài-suh",
+    "visual_tweaks": "細細ê外觀調整",
+    "scale_and_layout": "界面ê sài-suh kap排列"
   },
   "status": {
     "favorites": "收藏",
@@ -1001,7 +1032,7 @@
     "show_only_conversation_under_this": "Kan-ta顯示tsit ê狀態ê回應",
     "status_history": "狀態ê歷史",
     "reaction_count_label": "{num}ê lâng用表情反應",
-    "hide_quote": "Khàm引用ê狀態",
+    "hide_quote": "Khàm引用ê狀態",
     "display_quote": "顯示引用ê狀態",
     "invisible_quote": "引用ê狀態bē當用:{link}",
     "more_actions": "佇tsit ê狀態ê其他動作"

+ 67 - 47
src/main.js

@@ -58,56 +58,76 @@ const persistedStateOptions = {
 };
 
 (async () => {
-  let storageError = false
-  const plugins = [pushNotifications]
-  try {
-    const persistedState = await createPersistedState(persistedStateOptions)
-    plugins.push(persistedState)
-  } catch (e) {
-    console.error(e)
-    storageError = true
+  const isFox = Math.floor(Math.random() * 2) > 0 ? '_fox' : ''
+
+  const splashError = (i18n, e) => {
+    const throbber = document.querySelector('#throbber')
+    throbber.addEventListener('animationend', () => {
+      document.querySelector('#mascot').src = `/static/pleromatan_orz${isFox}.png`
+    })
+    throbber.classList.add('dead')
+    document.querySelector('#status').textContent = i18n.global.t('splash.error')
+    console.error('PleromaFE failed to initialize: ', e)
   }
-  const store = createStore({
-    modules: {
-      i18n: {
-        getters: {
-          i18n: () => i18n.global
-        }
+
+  try {
+    let storageError
+    const plugins = [pushNotifications]
+    try {
+      const persistedState = await createPersistedState(persistedStateOptions)
+      plugins.push(persistedState)
+    } catch (e) {
+      console.error('Storage error', e)
+      storageError = e
+    }
+    document.querySelector('#mascot').src = `/static/pleromatan_apology${isFox}.png`
+    document.querySelector('#status').removeAttribute('class')
+    document.querySelector('#status').textContent = i18n.global.t('splash.loading')
+    document.querySelector('#splash-credit').textContent = i18n.global.t('update.art_by', { linkToArtist: 'pipivovott' })
+    const store = createStore({
+      modules: {
+        i18n: {
+          getters: {
+            i18n: () => i18n.global
+          }
+        },
+        interface: interfaceModule,
+        instance: instanceModule,
+        // TODO refactor users/statuses modules, they depend on each other
+        users: usersModule,
+        statuses: statusesModule,
+        notifications: notificationsModule,
+        lists: listsModule,
+        api: apiModule,
+        config: configModule,
+        profileConfig: profileConfigModule,
+        serverSideStorage: serverSideStorageModule,
+        adminSettings: adminSettingsModule,
+        shout: shoutModule,
+        oauth: oauthModule,
+        authFlow: authFlowModule,
+        mediaViewer: mediaViewerModule,
+        oauthTokens: oauthTokensModule,
+        reports: reportsModule,
+        polls: pollsModule,
+        postStatus: postStatusModule,
+        editStatus: editStatusModule,
+        statusHistory: statusHistoryModule,
+        chats: chatsModule,
+        announcements: announcementsModule,
+        bookmarkFolders: bookmarkFoldersModule
       },
-      interface: interfaceModule,
-      instance: instanceModule,
-      // TODO refactor users/statuses modules, they depend on each other
-      users: usersModule,
-      statuses: statusesModule,
-      notifications: notificationsModule,
-      lists: listsModule,
-      api: apiModule,
-      config: configModule,
-      profileConfig: profileConfigModule,
-      serverSideStorage: serverSideStorageModule,
-      adminSettings: adminSettingsModule,
-      shout: shoutModule,
-      oauth: oauthModule,
-      authFlow: authFlowModule,
-      mediaViewer: mediaViewerModule,
-      oauthTokens: oauthTokensModule,
-      reports: reportsModule,
-      polls: pollsModule,
-      postStatus: postStatusModule,
-      editStatus: editStatusModule,
-      statusHistory: statusHistoryModule,
-      chats: chatsModule,
-      announcements: announcementsModule,
-      bookmarkFolders: bookmarkFoldersModule
-    },
-    plugins,
-    strict: false // Socket modifies itself, let's ignore this for now.
-    // strict: process.env.NODE_ENV !== 'production'
-  })
-  if (storageError) {
-    store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
+      plugins,
+      strict: false // Socket modifies itself, let's ignore this for now.
+      // strict: process.env.NODE_ENV !== 'production'
+    })
+    if (storageError) {
+      store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
+    }
+    return await afterStoreSetup({ store, i18n })
+  } catch (e) {
+    splashError(i18n, e)
   }
-  afterStoreSetup({ store, i18n })
 })()
 
 // These are inlined by webpack's DefinePlugin

+ 15 - 12
src/services/style_setter/style_setter.js

@@ -43,16 +43,16 @@ const adoptStyleSheets = (styles) => {
   // is nothing to do here.
 }
 
-export const generateTheme = async (inputRuleset, callbacks, debug) => {
+export const generateTheme = (inputRuleset, callbacks, debug) => {
   const {
     onNewRule = (rule, isLazy) => {},
     onLazyFinished = () => {},
     onEagerFinished = () => {}
   } = callbacks
 
-  // Assuming that "worst case scenario background" is panel background since it's the most likely one
   const themes3 = init({
     inputRuleset,
+    // Assuming that "worst case scenario background" is panel background since it's the most likely one
     ultimateBackgroundColor: inputRuleset[0].directives['--bg'].split('|')[1].trim(),
     debug
   })
@@ -146,11 +146,11 @@ export const tryLoadCache = () => {
   }
 }
 
-export const applyTheme = async (input, onFinish = (data) => {}, debug) => {
+export const applyTheme = (input, onFinish = (data) => {}, debug) => {
   const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
   const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
 
-  const { lazyProcessFunc } = await generateTheme(
+  const { lazyProcessFunc } = generateTheme(
     input,
     {
       onNewRule (rule, isLazy) {
@@ -169,15 +169,22 @@ export const applyTheme = async (input, onFinish = (data) => {}, debug) => {
         adoptStyleSheets([eagerStyles, lazyStyles])
         const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] }
         onFinish(cache)
-        localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
+        try {
+          localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
+        } catch (e) {
+          localStorage.removeItem('pleroma-fe-theme-cache')
+          try {
+            localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
+          } catch (e) {
+            console.warn('cannot save cache!', e)
+          }
+        }
       }
     },
     debug
   )
 
   setTimeout(lazyProcessFunc, 0)
-
-  return Promise.resolve()
 }
 
 const extractStyleConfig = ({
@@ -222,7 +229,7 @@ const extractStyleConfig = ({
 
 const defaultStyleConfig = extractStyleConfig(defaultState)
 
-export const applyConfig = (input) => {
+export const applyConfig = (input, i18n) => {
   const config = extractStyleConfig(input)
 
   if (config === defaultStyleConfig) {
@@ -230,8 +237,6 @@ export const applyConfig = (input) => {
   }
 
   const head = document.head
-  const body = document.body
-  body.classList.add('hidden')
 
   const rules = Object
     .entries(config)
@@ -252,8 +257,6 @@ export const applyConfig = (input) => {
         --roundness: var(--forcedRoundness) !important;
     }`, 'index-max')
   }
-
-  body.classList.remove('hidden')
 }
 
 export const getThemes = () => {

+ 1 - 1
src/services/theme_data/theme_data.service.js

@@ -452,7 +452,7 @@ export const getCssShadow = (input, usesDropShadow) => {
     ]).join(' ')).join(', ')
 }
 
-const getCssShadowFilter = (input) => {
+export const getCssShadowFilter = (input) => {
   if (input.length === 0) {
     return 'none'
   }

+ 27 - 9
src/services/theme_data/theme_data_3.service.js

@@ -182,7 +182,7 @@ export const init = ({
 
   const rulesetUnsorted = [
     ...Object.values(components)
-      .map(c => (c.defaultRules || []).map(r => ({ component: c.name, ...r, source: 'Built-in' })))
+      .map(c => (c.defaultRules || []).map(r => ({ source: 'Built-in', component: c.name, ...r })))
       .reduce((acc, arr) => [...acc, ...arr], []),
     ...inputRuleset
   ].map(rule => {
@@ -198,18 +198,33 @@ export const init = ({
 
   const ruleset = rulesetUnsorted
     .map((data, index) => ({ data, index }))
-    .sort(({ data: a, index: ai }, { data: b, index: bi }) => {
+    .toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
       const parentsA = unroll(a).length
       const parentsB = unroll(b).length
 
-      if (parentsA === parentsB) {
-        if (a.component === 'Text') return -1
-        if (b.component === 'Text') return 1
+      let aScore = 0
+      let bScore = 0
+
+      aScore += parentsA * 1000
+      bScore += parentsB * 1000
+
+      aScore += a.variant !== 'normal' ? 100 : 0
+      bScore += b.variant !== 'normal' ? 100 : 0
+
+      aScore += a.state.filter(x => x !== 'normal').length * 1000
+      bScore += b.state.filter(x => x !== 'normal').length * 1000
+
+      aScore += a.component === 'Text' ? 1 : 0
+      bScore += b.component === 'Text' ? 1 : 0
+
+      // Debug
+      a.specifityScore = aScore
+      b.specifityScore = bScore
+
+      if (aScore === bScore) {
         return ai - bi
       }
-      if (parentsA === 0 && parentsB !== 0) return -1
-      if (parentsB === 0 && parentsA !== 0) return 1
-      return parentsA - parentsB
+      return aScore - bScore
     })
     .map(({ data }) => data)
 
@@ -235,7 +250,10 @@ export const init = ({
 
     // Inheriting all of the applicable rules
     const existingRules = ruleset.filter(findRules(combination))
-    const computedDirectives = existingRules.map(r => r.directives).reduce((acc, directives) => ({ ...acc, ...directives }), {})
+    const computedDirectives =
+          existingRules
+            .map(r => r.directives)
+            .reduce((acc, directives) => ({ ...acc, ...directives }), {})
     const computedRule = {
       ...combination,
       directives: computedDirectives

BIN
static/pleromatan_apology.png


BIN
static/pleromatan_apology_fox.png


BIN
static/pleromatan_orz.png


BIN
static/pleromatan_orz_fox.png