Initial commit: Tonalmine, a Material Design 3 theme for Redmine 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
surtic86
2026-10-01 06:05:29 +02:00
co-authored by Claude Opus 5.5
commit 06cd1db99c
47 changed files with 4302 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
/*
* tonalmine: per-user appearance (mode + color palette).
*
* Redmine loads this file in <head>, 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'],
['teal', 'Teal', '#006A6A'],
['green', 'Green', '#386A20'],
['orange', 'Orange', '#8B5000']
];
var MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']];
var KEY = 'tonalmine';
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');
}
var prefs = load();
apply(prefs);
function button(attr, value, label, pressed, extra) {
return '<button type="button" ' + attr + '="' + value + '" aria-pressed="' + pressed + '"' + (extra || '') + '>' + label + '</button>';
}
function pickerHtml() {
var mode = prefs.mode || 'auto';
var palette = prefs.palette || PALETTES[0][0];
return '<div class="nm-appearance">' +
'<div class="nm-appearance__title">Appearance</div>' +
'<div class="nm-segmented" role="group" aria-label="Mode">' +
MODES.map(function (m) { return button('data-nm-mode', m[0], m[1], m[0] === mode); }).join('') +
'</div>' +
'<div class="nm-swatches" role="group" aria-label="Color">' +
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('') +
'</div></div>';
}
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-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-palette]');
if (!target) return;
if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode');
else prefs.palette = target.getAttribute('data-nm-palette');
save(prefs);
apply(prefs);
sync();
});
document.addEventListener('DOMContentLoaded', function () {
var menu = document.querySelector('#account .dropdown-content .dropdown-divider');
if (menu) menu.insertAdjacentHTML('afterend', pickerHtml() + '<div class="dropdown-divider"></div>');
var flyout = document.querySelector('.js-flyout-menu');
if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml());
});
})();