/* * tonalmine: per-user appearance (mode, contrast, size, 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. With the redmine_tonalmine plugin, * the plugin calls Tonalmine.configure() right after this file: it adds the administrator's * default, and logged-in users' choices are saved on their account instead. */ (function () { // Ids must match PALETTES in tools/palettes/build_palettes.mjs and // redmine_tonalmine/lib/redmine_tonalmine/appearance.rb (the palettes build checks both). 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 SIZES = [['compact', 'Compact'], ['normal', 'Normal'], ['large', 'Large']]; var KEY = 'tonalmine'; var CUSTOM_SEL = ':root[data-palette="custom"]'; var DEFAULT_KEYS = ['mode', 'contrast', 'size', 'palette', 'seed', 'css']; var root = document.documentElement; function load() { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; } } 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'); if (prefs.size === 'compact' || prefs.size === 'large') root.setAttribute('data-size', prefs.size); else root.removeAttribute('data-size'); // 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); }); } // own: the parts this user changed. defaults: the administrator's default (plugin only). // The page shows the defaults overridden by own, part by part. var own = load(); var defaults = {}; var url = null; // set when the user's choice is saved on their account (plugin, logged in) var configured = false; var draft = null; // the default being edited on the Display settings tab // palette, seed and css form one part: a custom palette brings its own generated CSS. function merge(base, top) { var src = top.palette ? top : base; return { mode: top.mode || base.mode, contrast: top.contrast || base.contrast, size: top.size || base.size, palette: src.palette, seed: src.seed, css: src.css }; } function effective() { return merge(defaults, own); } function isEmpty(prefs) { return !Object.keys(prefs).length; } function save() { if (!url) { try { localStorage.setItem(KEY, JSON.stringify(own)); } catch (e) { /* private mode: applies to this page only */ } return; } var token = document.querySelector('meta[name="csrf-token"]'); fetch(url, { method: 'PUT', credentials: 'same-origin', keepalive: true, // finish even if the user navigates away right after picking // X-Requested-With: an expired session answers 401 instead of redirecting to the login page. headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': token ? token.content : '' }, body: JSON.stringify(own) }).then(function (response) { if (response.status !== 204) throw new Error('HTTP ' + response.status); }).catch(function (error) { console.warn('tonalmine: appearance not saved', error); }); } // Called by the redmine_tonalmine plugin from , right after this file. window.Tonalmine = { configure: function (config) { defaults = config.defaults || {}; if (config.user) { url = config.url; var local = own; own = config.user; // A choice made in this browser before the plugin was installed moves to the account once. if (isEmpty(own) && !isEmpty(local)) { own = local; save(); } // Logged-in choices live on the account only, so the next person on this browser doesn't inherit them. try { localStorage.removeItem(KEY); } catch (e) { /* nothing stored */ } } configured = true; apply(effective()); } }; apply(effective()); function button(attr, value, label, pressed, extra) { return ''; } // isDefault: the administrator's picker on the Display settings tab (no title, no reset). function pickerHtml(prefs, isDefault) { var mode = prefs.mode || 'auto'; var contrast = prefs.contrast || 'standard'; var size = prefs.size || 'normal'; var palette = prefs.palette || PALETTES[0][0]; return '