/* * tonalmine: per-user appearance (mode + color palette). * * Redmine loads this file in , so the saved choice is applied before the page renders * (no flash of the default colors). The picker is added to the profile menu and the mobile * flyout. The choice is stored per browser in localStorage. */ (function () { // Ids must match PALETTES in tools/palettes/build_palettes.mjs (the build checks this). var PALETTES = [ ['purple', 'Purple', '#6750A4'], ['blue', 'Blue', '#0061A4'], ['green', 'Green', '#386A20'], ['orange', 'Orange', '#8B5000'] ]; var MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']]; var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']]; var KEY = 'tonalmine'; var CUSTOM_SEL = ':root[data-palette="custom"]'; var root = document.documentElement; function load() { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; } } function save(prefs) { try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch (e) { /* private mode: applies to this page only */ } } function apply(prefs) { if (prefs.palette && prefs.palette !== PALETTES[0][0]) root.setAttribute('data-palette', prefs.palette); else root.removeAttribute('data-palette'); if (prefs.mode === 'light' || prefs.mode === 'dark') root.setAttribute('data-mode', prefs.mode); else root.removeAttribute('data-mode'); if (prefs.contrast === 'medium' || prefs.contrast === 'high') root.setAttribute('data-contrast', prefs.contrast); else root.removeAttribute('data-contrast'); // The custom palette's rules are generated once (see withScheme) and kept with the prefs. if (prefs.palette === 'custom' && prefs.css) { var style = document.getElementById('nm-custom-palette'); if (!style) { style = document.createElement('style'); style.id = 'nm-custom-palette'; document.head.appendChild(style); } style.textContent = prefs.css; } } // The scheme generator (scheme.js, ~90 KB) is only loaded when someone picks a custom color. // Redmine serves theme assets under digested names only, so its URL comes from the CSS // variable --nm-scheme-js (Redmine rewrites url() in CSS to the digested path). var schemeScript; function withScheme(done) { if (window.tonalmineScheme) return done(window.tonalmineScheme); if (!schemeScript) { var url = getComputedStyle(root).getPropertyValue('--nm-scheme-js').match(/url\(["']?([^"')]+)/)[1]; schemeScript = document.createElement('script'); schemeScript.src = url; document.head.appendChild(schemeScript); } schemeScript.addEventListener('load', function () { done(window.tonalmineScheme); }); } var prefs = load(); apply(prefs); function button(attr, value, label, pressed, extra) { return ''; } function pickerHtml() { var mode = prefs.mode || 'auto'; var contrast = prefs.contrast || 'standard'; var palette = prefs.palette || PALETTES[0][0]; return '
' + '
Appearance
' + '
' + MODES.map(function (m) { return button('data-nm-mode', m[0], m[1], m[0] === mode); }).join('') + '
' + '
Contrast
' + '
' + CONTRASTS.map(function (c) { return button('data-nm-contrast', c[0], c[1], c[0] === contrast); }).join('') + '
' + '
' + PALETTES.map(function (p) { return button('data-nm-palette', p[0], '', p[0] === palette, ' class="nm-swatch" title="' + p[1] + '" aria-label="' + p[1] + '" style="--nm-swatch: ' + p[2] + '"'); }).join('') + '' + '
'; } function sync() { document.querySelectorAll('[data-nm-mode]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto'))); }); document.querySelectorAll('[data-nm-contrast]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard'))); }); document.querySelectorAll('[data-nm-palette]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0]))); }); } document.addEventListener('click', function (event) { var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-palette]'); if (!target) return; if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode'); else if (target.hasAttribute('data-nm-contrast')) prefs.contrast = target.getAttribute('data-nm-contrast'); // The custom swatch opens the color dialog; it only selects the palette once a color was picked. else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette'); save(prefs); apply(prefs); sync(); }); document.addEventListener('input', function (event) { if (!event.target.matches('[data-nm-custom]')) return; var seed = event.target.value; withScheme(function (scheme) { prefs.palette = 'custom'; prefs.seed = seed; prefs.css = scheme.paletteCss(CUSTOM_SEL, seed); save(prefs); apply(prefs); sync(); document.querySelectorAll('[data-nm-custom]').forEach(function (input) { input.value = seed; input.parentNode.style.setProperty('--nm-swatch', seed); }); }); }); document.addEventListener('DOMContentLoaded', function () { var menu = document.querySelector('#account .dropdown-content .dropdown-divider'); if (menu) menu.insertAdjacentHTML('afterend', pickerHtml() + ''); var flyout = document.querySelector('.js-flyout-menu'); if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml()); }); })();