vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Refresh the web surface with Everforest, readable type, and clearer navigation
796ac8c0ce77461174cf520d5fad4dea37744662 · Keenan Nemetz · 11d ago · parent ac00929f
server/web/static/theme.js +119
| @@ -0,0 +1,119 @@ | |||
| 1 | // Everforest is Vole's permanent theme, and the account page also carries a | ||
| 2 | // text-size preset. Both are browser preferences: a saved choice wins; with no | ||
| 3 | // saved theme, the medium variant follows the operating system. This runs before | ||
| 4 | // the stylesheet links in the document head so neither choice flashes. | ||
| 5 | (() => { | ||
| 6 | const root = document.documentElement; | ||
| 7 | const themeKey = 'vole-theme'; | ||
| 8 | const scaleKey = 'vole-font-scale'; | ||
| 9 | const dark = window.matchMedia('(prefers-color-scheme: dark)'); | ||
| 10 | const themes = new Set([ | ||
| 11 | 'everforest-dark-hard', | ||
| 12 | 'everforest-dark-medium', | ||
| 13 | 'everforest-dark-soft', | ||
| 14 | 'everforest-light-hard', | ||
| 15 | 'everforest-light-medium', | ||
| 16 | 'everforest-light-soft', | ||
| 17 | ]); | ||
| 18 | const scales = new Set(['small', 'medium', 'large']); | ||
| 19 | |||
| 20 | const systemTheme = () => (dark.matches ? 'everforest-dark-medium' : 'everforest-light-medium'); | ||
| 21 | |||
| 22 | const read = (key) => { | ||
| 23 | try { | ||
| 24 | return localStorage.getItem(key); | ||
| 25 | } catch (err) { | ||
| 26 | return null; | ||
| 27 | } | ||
| 28 | }; | ||
| 29 | const save = (key, value) => { | ||
| 30 | try { | ||
| 31 | localStorage.setItem(key, value); | ||
| 32 | } catch (err) { | ||
| 33 | // The page still switches; only the remembered choice is lost. | ||
| 34 | } | ||
| 35 | }; | ||
| 36 | const clear = (key) => { | ||
| 37 | try { | ||
| 38 | localStorage.removeItem(key); | ||
| 39 | } catch (err) { | ||
| 40 | // Nothing to clear when storage is unavailable. | ||
| 41 | } | ||
| 42 | }; | ||
| 43 | |||
| 44 | // A valid saved variant is an explicit choice. Anything else (including the | ||
| 45 | // old letterpress key) is cleared so the system default can apply. | ||
| 46 | let chosen = false; | ||
| 47 | const savedTheme = read(themeKey); | ||
| 48 | if (themes.has(savedTheme)) { | ||
| 49 | chosen = true; | ||
| 50 | root.dataset.theme = savedTheme; | ||
| 51 | } else { | ||
| 52 | if (savedTheme) clear(themeKey); | ||
| 53 | root.dataset.theme = systemTheme(); | ||
| 54 | } | ||
| 55 | |||
| 56 | const savedScale = read(scaleKey); | ||
| 57 | if (scales.has(savedScale)) { | ||
| 58 | root.dataset.fontScale = savedScale; | ||
| 59 | } else { | ||
| 60 | if (savedScale) clear(scaleKey); | ||
| 61 | root.dataset.fontScale = 'medium'; | ||
| 62 | } | ||
| 63 | |||
| 64 | const sync = () => { | ||
| 65 | const currentTheme = chosen ? root.dataset.theme : 'system'; | ||
| 66 | document.querySelectorAll('[data-theme-select]').forEach((select) => { | ||
| 67 | const system = select.querySelector('option[value="system"]'); | ||
| 68 | if (system) system.textContent = `System · ${dark.matches ? 'Dark' : 'Light'}`; | ||
| 69 | select.value = currentTheme; | ||
| 70 | }); | ||
| 71 | const currentScale = root.dataset.fontScale || 'medium'; | ||
| 72 | document.querySelectorAll('[data-font-scale-select]').forEach((select) => { | ||
| 73 | select.value = currentScale; | ||
| 74 | }); | ||
| 75 | }; | ||
| 76 | |||
| 77 | document.addEventListener('change', (event) => { | ||
| 78 | const target = event.target instanceof Element ? event.target : null; | ||
| 79 | |||
| 80 | const themeSelect = target && target.closest('[data-theme-select]'); | ||
| 81 | if (themeSelect) { | ||
| 82 | const value = themeSelect.value; | ||
| 83 | if (value === 'system') { | ||
| 84 | chosen = false; | ||
| 85 | clear(themeKey); | ||
| 86 | root.dataset.theme = systemTheme(); | ||
| 87 | } else if (themes.has(value)) { | ||
| 88 | chosen = true; | ||
| 89 | root.dataset.theme = value; | ||
| 90 | save(themeKey, value); | ||
| 91 | } | ||
| 92 | sync(); | ||
| 93 | return; | ||
| 94 | } | ||
| 95 | |||
| 96 | const scaleSelect = target && target.closest('[data-font-scale-select]'); | ||
| 97 | if (scaleSelect) { | ||
| 98 | const value = scaleSelect.value; | ||
| 99 | if (!scales.has(value)) return; | ||
| 100 | root.dataset.fontScale = value; | ||
| 101 | save(scaleKey, value); | ||
| 102 | sync(); | ||
| 103 | } | ||
| 104 | }); | ||
| 105 | |||
| 106 | const followSystem = () => { | ||
| 107 | if (chosen) return; | ||
| 108 | root.dataset.theme = systemTheme(); | ||
| 109 | sync(); | ||
| 110 | }; | ||
| 111 | if (dark.addEventListener) dark.addEventListener('change', followSystem); | ||
| 112 | else if (dark.addListener) dark.addListener(followSystem); | ||
| 113 | |||
| 114 | if (document.readyState === 'loading') { | ||
| 115 | document.addEventListener('DOMContentLoaded', sync, { once: true }); | ||
| 116 | } else { | ||
| 117 | sync(); | ||
| 118 | } | ||
| 119 | })(); | ||