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.
|
0584bb70
Keenan Nemetz
23h ago
|
1 | // Everforest and a few named palettes are built in, and the instance repository |
| 2 | // may add theme packs of its own. The account page also carries a text-size | |
| 3 | // preset. All of it is a browser preference: a saved choice wins; with no saved | |
| 4 | // theme, the medium Everforest variant follows the operating system. This runs | |
| 5 | // before the stylesheet links in the document head so neither choice flashes. | |
|
796ac8c0
Keenan Nemetz
11d ago
|
6 | (() => { |
| 7 | const root = document.documentElement; | |
| 8 | const themeKey = 'vole-theme'; | |
| 9 | const scaleKey = 'vole-font-scale'; | |
| 10 | const dark = window.matchMedia('(prefers-color-scheme: dark)'); | |
|
f7a34d87
Keenan Nemetz
1d ago
|
11 | // The server writes the instance's theme ids to data-themes before this |
| 12 | // script runs, so a saved custom choice is accepted on the first paint. | |
|
796ac8c0
Keenan Nemetz
11d ago
|
13 | const themes = new Set([ |
| 14 | 'everforest-dark-hard', | |
| 15 | 'everforest-dark-medium', | |
| 16 | 'everforest-dark-soft', | |
| 17 | 'everforest-light-hard', | |
| 18 | 'everforest-light-medium', | |
| 19 | 'everforest-light-soft', | |
|
0584bb70
Keenan Nemetz
23h ago
|
20 | 'catppuccin-mocha', |
| 21 | 'catppuccin-latte', | |
| 22 | 'nord', | |
| 23 | 'solarized-light', | |
|
97b3b022
Keenan Nemetz
23h ago
|
24 | 'rose-pine-moon', |
| 25 | 'rose-pine-dawn', | |
| 26 | 'kanagawa-wave', | |
| 27 | 'dracula', | |
| 28 | 'tokyo-night', | |
| 29 | 'ayu-light', | |
|
f7a34d87
Keenan Nemetz
1d ago
|
30 | ...(root.dataset.themes || '').split(',').filter(Boolean), |
|
796ac8c0
Keenan Nemetz
11d ago
|
31 | ]); |
| 32 | const scales = new Set(['small', 'medium', 'large']); | |
| 33 | ||
| 34 | const systemTheme = () => (dark.matches ? 'everforest-dark-medium' : 'everforest-light-medium'); | |
| 35 | ||
| 36 | const read = (key) => { | |
| 37 | try { | |
| 38 | return localStorage.getItem(key); | |
| 39 | } catch (err) { | |
| 40 | return null; | |
| 41 | } | |
| 42 | }; | |
| 43 | const save = (key, value) => { | |
| 44 | try { | |
| 45 | localStorage.setItem(key, value); | |
| 46 | } catch (err) { | |
| 47 | // The page still switches; only the remembered choice is lost. | |
| 48 | } | |
| 49 | }; | |
| 50 | const clear = (key) => { | |
| 51 | try { | |
| 52 | localStorage.removeItem(key); | |
| 53 | } catch (err) { | |
| 54 | // Nothing to clear when storage is unavailable. | |
| 55 | } | |
| 56 | }; | |
| 57 | ||
| 58 | // A valid saved variant is an explicit choice. Anything else (including the | |
| 59 | // old letterpress key) is cleared so the system default can apply. | |
| 60 | let chosen = false; | |
| 61 | const savedTheme = read(themeKey); | |
| 62 | if (themes.has(savedTheme)) { | |
| 63 | chosen = true; | |
| 64 | root.dataset.theme = savedTheme; | |
| 65 | } else { | |
| 66 | if (savedTheme) clear(themeKey); | |
| 67 | root.dataset.theme = systemTheme(); | |
| 68 | } | |
| 69 | ||
| 70 | const savedScale = read(scaleKey); | |
| 71 | if (scales.has(savedScale)) { | |
| 72 | root.dataset.fontScale = savedScale; | |
| 73 | } else { | |
| 74 | if (savedScale) clear(scaleKey); | |
| 75 | root.dataset.fontScale = 'medium'; | |
| 76 | } | |
| 77 | ||
| 78 | const sync = () => { | |
| 79 | const currentTheme = chosen ? root.dataset.theme : 'system'; | |
| 80 | document.querySelectorAll('[data-theme-select]').forEach((select) => { | |
| 81 | const system = select.querySelector('option[value="system"]'); | |
| 82 | if (system) system.textContent = `System · ${dark.matches ? 'Dark' : 'Light'}`; | |
| 83 | select.value = currentTheme; | |
| 84 | }); | |
| 85 | const currentScale = root.dataset.fontScale || 'medium'; | |
| 86 | document.querySelectorAll('[data-font-scale-select]').forEach((select) => { | |
| 87 | select.value = currentScale; | |
| 88 | }); | |
| 89 | }; | |
| 90 | ||
| 91 | document.addEventListener('change', (event) => { | |
| 92 | const target = event.target instanceof Element ? event.target : null; | |
| 93 | ||
| 94 | const themeSelect = target && target.closest('[data-theme-select]'); | |
| 95 | if (themeSelect) { | |
| 96 | const value = themeSelect.value; | |
| 97 | if (value === 'system') { | |
| 98 | chosen = false; | |
| 99 | clear(themeKey); | |
| 100 | root.dataset.theme = systemTheme(); | |
| 101 | } else if (themes.has(value)) { | |
| 102 | chosen = true; | |
| 103 | root.dataset.theme = value; | |
| 104 | save(themeKey, value); | |
| 105 | } | |
| 106 | sync(); | |
| 107 | return; | |
| 108 | } | |
| 109 | ||
| 110 | const scaleSelect = target && target.closest('[data-font-scale-select]'); | |
| 111 | if (scaleSelect) { | |
| 112 | const value = scaleSelect.value; | |
| 113 | if (!scales.has(value)) return; | |
| 114 | root.dataset.fontScale = value; | |
| 115 | save(scaleKey, value); | |
| 116 | sync(); | |
| 117 | } | |
| 118 | }); | |
| 119 | ||
| 120 | const followSystem = () => { | |
| 121 | if (chosen) return; | |
| 122 | root.dataset.theme = systemTheme(); | |
| 123 | sync(); | |
| 124 | }; | |
| 125 | if (dark.addEventListener) dark.addEventListener('change', followSystem); | |
| 126 | else if (dark.addListener) dark.addListener(followSystem); | |
| 127 | ||
| 128 | if (document.readyState === 'loading') { | |
| 129 | document.addEventListener('DOMContentLoaded', sync, { once: true }); | |
| 130 | } else { | |
| 131 | sync(); | |
| 132 | } | |
| 133 | })(); |