feat: redmine_tonalmine plugin for default and per-account appearance

Admins set the default mode, contrast and color on Settings > Display,
shown as three labelled form rows below the Theme select. Logged-in
users' choices are saved on their account and override only the parts
they changed; anonymous visitors get the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-10-05 14:12:44 +02:00
co-authored by Claude Opus 5.5
parent 77040e24a8
commit 20b7269df2
11 changed files with 482 additions and 40 deletions
+17 -2
View File
@@ -8,6 +8,10 @@ Material Design 3 theme for Redmine 7.0.
2. Restart Redmine (theme assets are compiled at boot). 2. Restart Redmine (theme assets are compiled at boot).
3. Select **Tonalmine** in Administration → Settings → Display → Theme. 3. Select **Tonalmine** in Administration → Settings → Display → Theme.
Optional: the `redmine_tonalmine` plugin adds a default appearance for administrators and saves each
user's choice on their account. Copy the `redmine_tonalmine/` folder into `<redmine>/plugins/` and
restart Redmine (no migrations).
## Development ## Development
Requires Docker. Requires Docker.
@@ -49,8 +53,19 @@ the map, so run it after every Redmine update.
### Colors ### Colors
Each user picks a mode (Auto / Light / Dark), a contrast level (Standard / Medium / High) and a color (Purple, Blue, Green, Orange) in the Each user picks a mode (Auto / Light / Dark), a contrast level (Standard / Medium / High) and a color (Purple, Blue, Green, Orange) in the
**Appearance** section of the profile menu (top right; in the mobile menu at the bottom). The choice is **Appearance** section of the profile menu (top right; in the mobile menu at the bottom). Auto follows
stored per browser (`localStorage`); Auto follows the operating system. The last (rainbow) swatch the operating system. Without the plugin, the choice is stored per browser (`localStorage`).
With the `redmine_tonalmine` plugin:
- Administrators set the default appearance in Administration → Settings → Display, below the Theme
select. It shows while Tonalmine is selected; when switching from another theme, save once first.
- Logged-in users' choices are saved on their account (`pref[:tonalmine]`) and follow them to every
browser. Only the parts a user changed override the default; **Reset to default** clears them.
A choice already stored in the browser is moved to the account on the first visit.
- Anonymous visitors get the default; their own choice is still stored per browser.
`node e2e/check-appearance.mjs` checks this end to end (it empties the default when done). The last (rainbow) swatch
lets users pick any color: `javascripts/scheme.js` is loaded on demand, generates the scheme in the lets users pick any color: `javascripts/scheme.js` is loaded on demand, generates the scheme in the
browser, and the generated CSS is stored with the choice, so later page loads don't need it. browser, and the generated CSS is stored with the choice, so later page loads don't need it.
+1
View File
@@ -8,6 +8,7 @@ services:
- redmine-files:/usr/src/redmine/files - redmine-files:/usr/src/redmine/files
- ../tonalmine:/usr/src/redmine/themes/tonalmine - ../tonalmine:/usr/src/redmine/themes/tonalmine
- ./plugins:/usr/src/redmine/plugins - ./plugins:/usr/src/redmine/plugins
- ../redmine_tonalmine:/usr/src/redmine/plugins/redmine_tonalmine
- .:/seed:ro - .:/seed:ro
volumes: volumes:
+167
View File
@@ -0,0 +1,167 @@
// Checks the redmine_tonalmine plugin: the admin default appearance (Display settings tab), users'
// choices saved on their account, Reset to default, the one-time upload of a localStorage choice,
// and the endpoint's validation. Needs the plugin mounted (dev/docker-compose.yml) and the seed users.
// Usage: node check-appearance.mjs (exits 1 on any failure; leaves the admin default empty)
import { chromium } from 'playwright';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const browser = await chromium.launch();
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : `\n ${detail}`}`);
}
async function login(user, password = 'password123', setup) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
await page.goto(`${BASE}/login`);
if (setup) await page.evaluate(setup);
await page.fill('#username', user);
await page.fill('#password', password);
await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
await page.waitForLoadState('networkidle');
return page;
}
// The <html> attributes theme.js sets; absent means the built-in default (auto, standard, purple).
const looks = (page) => page.evaluate(() => {
const d = document.documentElement.dataset;
return `${d.mode ?? 'auto'} ${d.contrast ?? 'standard'} ${d.palette ?? 'purple'}`;
});
async function expectLook(name, page, want) {
const got = await looks(page);
check(name, got === want, `expected "${want}", got "${got}"`);
}
const put = (page, body, withToken = true) => page.evaluate(async ([body, withToken]) => {
const headers = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
if (withToken) headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch('/tonalmine/appearance', { method: 'PUT', headers, body: JSON.stringify(body) });
return response.status;
}, [body, withToken]);
// Clicks a picker control in the profile menu and waits until the account has it.
async function pick(page, selector) {
const saved = page.waitForResponse((r) => r.url().includes('/tonalmine/appearance'));
await page.evaluate((s) => document.querySelector(`#account ${s}`).click(), selector);
return (await saved).status();
}
// Picks a custom color the way the color dialog does (input while dragging, change at the end).
const pickColor = (page, scope, seed) => page.evaluate(([scope, seed]) => {
const input = document.querySelector(`${scope} [data-nm-custom]`);
input.value = seed;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}, [scope, seed]);
const customCss = (page) => page.evaluate(() => document.getElementById('nm-custom-palette')?.textContent.length ?? 0);
async function saveDefault(page, change) {
await page.goto(`${BASE}/settings?tab=display`);
await change();
await Promise.all([page.waitForURL(/tab=display/), page.click('#tab-content-display form [type=submit]')]);
await page.waitForLoadState('networkidle');
}
const resetUser = async (user) => {
const page = await login(user);
await put(page, {});
await page.context().close();
};
await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser));
// 1. Admin default on the Display settings tab
const admin = await login('admin', 'admin');
await admin.goto(`${BASE}/settings?tab=display`);
// Mode, contrast and color are three form rows
const rowsShown = () => admin.$$eval('p:has([data-nm-default])', (rows) => rows.length === 3 && rows.map((r) => !!r.offsetParent));
check('default picker shown while Tonalmine is selected', (await rowsShown())?.every(Boolean));
await admin.selectOption('#settings_ui_theme', '');
check('default picker hidden and not posted for another theme', (await rowsShown())?.every((shown) => !shown) &&
(await admin.$$eval('[data-nm-default-input]', (inputs) => inputs.every((i) => i.disabled))));
await saveDefault(admin, async () => {
await admin.click('[data-nm-default] [data-nm-palette="blue"]');
await admin.click('[data-nm-default] [data-nm-mode="dark"]');
});
check('default picker shows the saved default', await admin.evaluate(() =>
document.querySelector('[data-nm-default] [data-nm-palette="blue"]').getAttribute('aria-pressed') === 'true' &&
document.querySelector('[data-nm-default] [data-nm-mode="dark"]').getAttribute('aria-pressed') === 'true'));
// 2. A user without a choice gets the default
const jdoe = await login('jdoe');
await expectLook('user without a choice gets the default', jdoe, 'dark standard blue');
// 3. The user's choice is saved on the account and only overrides that part
check('choosing Light is saved (204)', (await pick(jdoe, '[data-nm-mode="light"]')) === 204);
const jdoe2 = await login('jdoe');
await expectLook('choice follows the user to another browser', jdoe2, 'light standard blue');
// 4. Parts the user didn't change follow a new default
await saveDefault(admin, () => admin.click('[data-nm-default] [data-nm-palette="green"]'));
await jdoe2.reload({ waitUntil: 'networkidle' });
await expectLook('unchanged parts follow the new default', jdoe2, 'light standard green');
// 5. Reset to default
check('Reset to default is shown after a change', await jdoe2.isVisible('#account [data-nm-reset]') ||
await jdoe2.evaluate(() => !document.querySelector('#account [data-nm-reset]').hidden));
check('reset is saved (204)', (await pick(jdoe2, '[data-nm-reset]')) === 204);
await expectLook('reset returns to the default', jdoe2, 'dark standard green');
await jdoe2.reload({ waitUntil: 'networkidle' });
await expectLook('reset is kept after reload', jdoe2, 'dark standard green');
check('Reset to default is hidden without a choice', await jdoe2.evaluate(() => document.querySelector('#account [data-nm-reset]').hidden));
// 5b. A custom color, with its generated CSS, is saved on the account
const savedColor = jdoe2.waitForResponse((r) => r.url().includes('/tonalmine/appearance'));
await pickColor(jdoe2, '#account', '#c0392b');
check('custom color is saved (204)', (await savedColor).status() === 204);
const jdoe3 = await login('jdoe');
await expectLook('custom color follows the user', jdoe3, 'dark standard custom');
check('custom color CSS comes from the account', (await customCss(jdoe3)) > 1000);
await pick(jdoe3, '[data-nm-reset]');
// 6. A localStorage choice from before the plugin moves to the account once
const msmith = await login('msmith', 'password123', () => localStorage.setItem('tonalmine', '{"contrast":"high"}'));
await expectLook('localStorage choice is applied after login', msmith, 'dark high green');
check('localStorage is cleared for logged-in users', await msmith.evaluate(() => localStorage.getItem('tonalmine') === null));
const msmith2 = await login('msmith');
await expectLook('uploaded choice is on the account', msmith2, 'dark high green');
// 7. Validation; the request without a CSRF token goes last, since Redmine then ends the session
const pweber = await login('pweber');
check('valid choice is saved (204)', (await put(pweber, { contrast: 'medium' })) === 204);
for (const body of [{ mode: 'neon' }, { palette: 'custom' }, { palette: 'custom', seed: '#123456', css: '</style>' }, { foo: 1 }, { mode: 5 }]) {
const status = await put(pweber, body);
check(`invalid ${JSON.stringify(body)} is refused (422)`, status === 422, `got ${status}`);
}
const tokenless = await put(pweber, { contrast: 'high' }, false);
check('request without CSRF token is refused (422)', tokenless === 422, `got ${tokenless}`);
const pweber2 = await login('pweber');
await expectLook('refused requests left the choice unchanged', pweber2, 'dark medium green');
// 8. Anonymous visitors get the default
const anonymous = await (await browser.newContext()).newPage();
await anonymous.goto(`${BASE}/login`);
await expectLook('anonymous visitor gets the default', anonymous, 'dark standard green');
// 8b. A custom default color reaches everyone
await saveDefault(admin, async () => {
await pickColor(admin, '[data-nm-default]', '#1e88e5');
await admin.waitForFunction(() => document.querySelector('[data-nm-default-input="css"]').value.length > 1000);
});
await anonymous.reload();
await expectLook('custom default reaches anonymous visitors', anonymous, 'dark standard custom');
check('custom default CSS is rendered', (await customCss(anonymous)) > 1000);
// 9. Clean up: empty default, so other checks and screenshots see the built-in look
await saveDefault(admin, () => admin.$$eval('[data-nm-default-input]', (inputs) => inputs.forEach((i) => { i.value = ''; })));
await anonymous.reload();
await expectLook('empty default restores the built-in look', anonymous, 'auto standard purple');
await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser));
await browser.close();
process.exit(failures ? 1 : 0);
@@ -0,0 +1,17 @@
# Saves the current user's appearance (sent by theme.js). The body replaces the whole saved
# choice; an empty object resets the user to the default.
class TonalmineAppearanceController < ApplicationController
before_action :require_login
def update
prefs = params.to_unsafe_h.except('controller', 'action', 'tonalmine_appearance')
errors = RedmineTonalmine::Appearance.errors(prefs)
if errors.any?
render json: {errors: errors}, status: :unprocessable_content
else
User.current.pref[:tonalmine] = RedmineTonalmine::Appearance.clean(prefs)
User.current.pref.save
head :no_content
end
end
end
+3
View File
@@ -0,0 +1,3 @@
# No format segment: Redmine treats .json requests as API calls, which skip the session and the
# CSRF check. theme.js sends JSON to this plain path with the X-CSRF-Token header instead.
put 'tonalmine/appearance', to: 'tonalmine_appearance#update', as: 'tonalmine_appearance', format: false
+14
View File
@@ -0,0 +1,14 @@
# Companion plugin for the Tonalmine theme: an administrator default appearance (set in
# Administration > Settings > Display, below the theme select) and each user's appearance saved
# on their account. The theme works without it; then the choice is stored per browser.
Redmine::Plugin.register :redmine_tonalmine do
name 'Tonalmine'
author 'Andreas Reinhold'
description 'Default appearance and per-user appearance for the Tonalmine theme'
version '0.1.0'
requires_redmine version_or_higher: '7.0.0'
# No :partial: the default is edited on the Display settings tab (see theme.js).
settings default: {}
end
RedmineTonalmine::Hooks # loads and registers the hook listener
@@ -0,0 +1,58 @@
module RedmineTonalmine
# Validates appearance hashes ({mode, contrast, palette, seed, css}) coming from users and from
# the Display settings form. Any part may be missing: it then falls back to the default.
module Appearance
MODES = %w[auto light dark].freeze
CONTRASTS = %w[standard medium high].freeze
# Ids must match PALETTES in tonalmine/javascripts/theme.js (tools/palettes checks this).
PALETTES = %w[purple blue green orange].freeze
KEYS = %w[mode contrast palette seed css].freeze
MAX_CSS = 32.kilobytes
module_function
# Returns a list of problems; empty when the hash is valid.
def errors(hash)
prefs = normalize(hash)
errors = hash.to_h.filter_map do |key, value|
if !KEYS.include?(key.to_s) then "unknown key #{key}"
elsif !value.nil? && !value.is_a?(String) then "invalid #{key}"
end
end
errors << 'invalid mode' if prefs['mode'] && !MODES.include?(prefs['mode'])
errors << 'invalid contrast' if prefs['contrast'] && !CONTRASTS.include?(prefs['contrast'])
errors.concat(custom_errors(prefs))
errors
end
# Returns only the valid parts, with string keys.
def clean(hash)
prefs = normalize(hash)
prefs.delete('mode') unless MODES.include?(prefs['mode'])
prefs.delete('contrast') unless CONTRASTS.include?(prefs['contrast'])
prefs = prefs.except('palette', 'seed', 'css') if custom_errors(prefs).any?
prefs.compact
end
# String keys, known keys only, blank values dropped (the settings form posts "" for unset parts).
def normalize(hash)
hash.to_h.transform_keys(&:to_s).slice(*KEYS).select { |_, value| value.is_a?(String) && value.present? }
end
# palette, seed and css belong together: a custom palette needs its seed and generated CSS.
# A built-in palette may keep the last custom color, so the custom swatch can switch back.
def custom_errors(prefs)
palette, seed, css = prefs.values_at('palette', 'seed', 'css')
return [] if palette.nil? && seed.nil? && css.nil?
required = palette == 'custom'
errors = []
errors << 'invalid palette' unless required || PALETTES.include?(palette)
errors << 'invalid seed' unless (seed.nil? && !required) || seed.to_s.match?(/\A#\h{6}\z/)
# The CSS is rendered into a <script> as JSON and applied via style.textContent; refusing
# "<" keeps it from ever closing either element.
errors << 'invalid css' unless (css.nil? && !required) || (css && !css.include?('<') && css.bytesize <= MAX_CSS)
errors
end
end
end
@@ -0,0 +1,19 @@
module RedmineTonalmine
# Hands the default and the current user's appearance to theme.js. Rendered in <head> right
# after the theme's scripts, so the colors are applied before the page is painted.
class Hooks < Redmine::Hook::ViewListener
def view_layouts_base_html_head(context = {})
defaults = Appearance.clean(Setting.plugin_redmine_tonalmine)
config = {defaults: defaults, user: nil, url: nil}
if User.current.logged?
user = Appearance.clean(User.current.pref[:tonalmine])
# The user's own palette replaces the default one: skip the default's generated CSS.
# Admins keep it: the Display settings tab posts the whole default back.
defaults.delete('css') if user['palette'] && !User.current.admin?
config.update(user: user, url: tonalmine_appearance_path)
end
# to_json escapes <, > and &, so values can't end the script element.
javascript_tag "window.Tonalmine && Tonalmine.configure(#{config.to_json});"
end
end
end
+155 -35
View File
@@ -1,12 +1,15 @@
/* /*
* tonalmine: per-user appearance (mode + color palette). * tonalmine: per-user appearance (mode, contrast, color palette).
* *
* Redmine loads this file in <head>, so the saved choice is applied before the page renders * 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 * (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. * 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 () { (function () {
// Ids must match PALETTES in tools/palettes/build_palettes.mjs (the build checks this). // 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 = [ var PALETTES = [
['purple', 'Purple', '#6750A4'], ['purple', 'Purple', '#6750A4'],
['blue', 'Blue', '#0061A4'], ['blue', 'Blue', '#0061A4'],
@@ -17,16 +20,13 @@
var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']]; var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']];
var KEY = 'tonalmine'; var KEY = 'tonalmine';
var CUSTOM_SEL = ':root[data-palette="custom"]'; var CUSTOM_SEL = ':root[data-palette="custom"]';
var DEFAULT_KEYS = ['mode', 'contrast', 'palette', 'seed', 'css'];
var root = document.documentElement; var root = document.documentElement;
function load() { function load() {
try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; } 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) { function apply(prefs) {
if (prefs.palette && prefs.palette !== PALETTES[0][0]) root.setAttribute('data-palette', prefs.palette); if (prefs.palette && prefs.palette !== PALETTES[0][0]) root.setAttribute('data-palette', prefs.palette);
else root.removeAttribute('data-palette'); else root.removeAttribute('data-palette');
@@ -61,19 +61,78 @@
schemeScript.addEventListener('load', function () { done(window.tonalmineScheme); }); schemeScript.addEventListener('load', function () { done(window.tonalmineScheme); });
} }
var prefs = load();
apply(prefs); // 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,
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 <head>, 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) { function button(attr, value, label, pressed, extra) {
return '<button type="button" ' + attr + '="' + value + '" aria-pressed="' + pressed + '"' + (extra || '') + '>' + label + '</button>'; return '<button type="button" ' + attr + '="' + value + '" aria-pressed="' + pressed + '"' + (extra || '') + '>' + label + '</button>';
} }
function pickerHtml() { // isDefault: the administrator's picker on the Display settings tab (no title, no reset).
function pickerHtml(prefs, isDefault) {
var mode = prefs.mode || 'auto'; var mode = prefs.mode || 'auto';
var contrast = prefs.contrast || 'standard'; var contrast = prefs.contrast || 'standard';
var palette = prefs.palette || PALETTES[0][0]; var palette = prefs.palette || PALETTES[0][0];
return '<div class="nm-appearance">' + return '<div class="nm-appearance' + (isDefault ? ' nm-appearance--default" data-nm-default' : '"') + '>' +
'<div class="nm-appearance__title">Appearance</div>' + (isDefault ? '' : '<div class="nm-appearance__title">Appearance</div>') +
'<div class="nm-segmented" role="group" aria-label="Mode">' + '<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('') + MODES.map(function (m) { return button('data-nm-mode', m[0], m[1], m[0] === mode); }).join('') +
'</div>' + '</div>' +
@@ -86,53 +145,112 @@
return button('data-nm-palette', p[0], '', p[0] === palette, 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] + '"'); ' class="nm-swatch" title="' + p[1] + '" aria-label="' + p[1] + '" style="--nm-swatch: ' + p[2] + '"');
}).join('') + }).join('') +
'<label class="nm-swatch nm-swatch--custom" data-nm-palette="custom" aria-pressed="' + (palette === 'custom') + '"' + '<label class="nm-swatch nm-swatch--custom no-css" data-nm-palette="custom" aria-pressed="' + (palette === 'custom') + '"' +
(prefs.seed ? ' style="--nm-swatch: ' + prefs.seed + '"' : '') + ' title="Custom color">' + (prefs.seed ? ' style="--nm-swatch: ' + prefs.seed + '"' : '') + ' title="Custom color">' +
'<input type="color" data-nm-custom value="' + (prefs.seed || PALETTES[0][2].toLowerCase()) + '" aria-label="Custom color"></label>' + '<input type="color" data-nm-custom value="' + (prefs.seed || PALETTES[0][2].toLowerCase()) + '" aria-label="Custom color"></label>' +
'</div></div>'; '</div>' +
(isDefault ? '' : '<button type="button" class="nm-appearance__reset" data-nm-reset' + (isEmpty(own) ? ' hidden' : '') + '>Reset to default</button>') +
'</div>';
} }
function sync() { function sync() {
document.querySelectorAll('[data-nm-mode]').forEach(function (b) { document.querySelectorAll('.nm-appearance').forEach(function (picker) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto'))); var prefs = picker.hasAttribute('data-nm-default') ? draft : effective();
picker.querySelectorAll('[data-nm-mode]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto')));
});
picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard')));
});
picker.querySelectorAll('[data-nm-palette]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
});
if (prefs.seed) {
picker.querySelectorAll('[data-nm-custom]').forEach(function (input) {
input.value = prefs.seed;
input.parentNode.style.setProperty('--nm-swatch', prefs.seed);
});
}
var reset = picker.querySelector('[data-nm-reset]');
if (reset) reset.hidden = isEmpty(own);
}); });
document.querySelectorAll('[data-nm-contrast]').forEach(function (b) { document.querySelectorAll('[data-nm-default-input]').forEach(function (input) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard'))); input.value = draft[input.getAttribute('data-nm-default-input')] || '';
});
document.querySelectorAll('[data-nm-palette]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
}); });
} }
// persist: save the user's choice (the default is saved with the settings form instead).
function changed(isDefault, persist) {
if (isDefault) apply(draft); // preview
else {
if (persist) save();
apply(effective());
}
sync();
}
document.addEventListener('click', function (event) { document.addEventListener('click', function (event) {
var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-palette]'); var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-palette], [data-nm-reset]');
if (!target) return; if (!target) return;
if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode'); var isDefault = !!target.closest('[data-nm-default]');
var prefs = isDefault ? draft : own;
if (target.hasAttribute('data-nm-reset')) own = {};
else 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'); 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. // 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'); else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette');
save(prefs); changed(isDefault, true);
apply(prefs);
sync();
}); });
document.addEventListener('input', function (event) { document.addEventListener('input', function (event) {
if (!event.target.matches('[data-nm-custom]')) return; if (!event.target.matches('[data-nm-custom]')) return;
var isDefault = !!event.target.closest('[data-nm-default]');
var seed = event.target.value; var seed = event.target.value;
withScheme(function (scheme) { withScheme(function (scheme) {
var prefs = isDefault ? draft : own;
prefs.palette = 'custom'; prefs.palette = 'custom';
prefs.seed = seed; prefs.seed = seed;
prefs.css = scheme.paletteCss(CUSTOM_SEL, seed); prefs.css = scheme.paletteCss(CUSTOM_SEL, seed);
save(prefs); // While the user drags, only localStorage keeps up; the account gets the final color on change.
apply(prefs); changed(isDefault, !url);
sync();
document.querySelectorAll('[data-nm-custom]').forEach(function (input) {
input.value = seed;
input.parentNode.style.setProperty('--nm-swatch', seed);
});
}); });
}); });
document.addEventListener('change', function (event) {
if (!url || !event.target.matches('[data-nm-custom]') || event.target.closest('[data-nm-default]')) return;
withScheme(save); // queued behind the input event's pending update
});
// Display settings tab (admins, plugin installed): edit the default appearance below the theme
// select. The hidden inputs are saved by the settings form as Setting.plugin_redmine_tonalmine.
function defaultPicker(select) {
draft = merge({}, defaults);
// One labelled form row per group (mode, contrast, color), each in its own default picker
// wrapper so sync() and the click handlers treat it like the whole picker.
var picker = document.createElement('div');
picker.innerHTML = pickerHtml(draft, true);
var groups = picker.querySelectorAll('[role="group"]');
var rows = ['Default appearance', 'Contrast', 'Color'].map(function (label, i) {
var row = document.createElement('p');
row.innerHTML = '<label>' + label + '</label><div class="nm-appearance nm-appearance--default" data-nm-default></div>';
row.lastChild.appendChild(groups[i]);
return row;
});
rows[2].insertAdjacentHTML('beforeend', DEFAULT_KEYS.map(function (key) {
return '<input type="hidden" name="settings[plugin_redmine_tonalmine][' + key + ']" data-nm-default-input="' + key + '">';
}).join(''));
select.closest('p').after(rows[0], rows[1], rows[2]);
// Only shown, and only posted, while Tonalmine is selected; otherwise the saved default stays.
function toggle() {
var on = select.value === 'tonalmine';
rows.forEach(function (row) { row.style.display = on ? '' : 'none'; });
rows[2].querySelectorAll('[data-nm-default-input]').forEach(function (input) { input.disabled = !on; });
}
select.addEventListener('change', toggle);
toggle();
sync();
}
// Calendar/Gantt hover cards: Redmine renders "<a>Tracker #id</a>: subject<br><br>" followed by // Calendar/Gantt hover cards: Redmine renders "<a>Tracker #id</a>: subject<br><br>" followed by
// "<strong>Label</strong>: value<br>" lines. Regroup them on first hover into a title and a // "<strong>Label</strong>: value<br>" lines. Regroup them on first hover into a title and a
// label/value grid (nodes are moved, not re-parsed, so links and avatars stay intact). // label/value grid (nodes are moved, not re-parsed, so links and avatars stay intact).
@@ -169,8 +287,10 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var menu = document.querySelector('#account .dropdown-content .dropdown-divider'); var menu = document.querySelector('#account .dropdown-content .dropdown-divider');
if (menu) menu.insertAdjacentHTML('afterend', pickerHtml() + '<div class="dropdown-divider"></div>'); if (menu) menu.insertAdjacentHTML('afterend', pickerHtml(effective()) + '<div class="dropdown-divider"></div>');
var flyout = document.querySelector('.js-flyout-menu'); var flyout = document.querySelector('.js-flyout-menu');
if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml()); if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml(effective()));
var select = document.getElementById('settings_ui_theme');
if (select && configured) defaultPicker(select);
}); });
})(); })();
+24 -1
View File
@@ -584,12 +584,35 @@ html .table-generator td.selected-cell, html .table-generator td:hover { backgro
cursor: pointer; cursor: pointer;
} }
/* Custom color: a native color input stretched invisibly over a rainbow (or the picked color) swatch */ /* Custom color: a native color input stretched invisibly over a rainbow (or the picked color) swatch */
.nm-swatch--custom { position: relative; background: var(--nm-swatch, conic-gradient(#e53935, #fdd835, #43a047, #1e88e5, #8e24aa, #e53935)); } /* label.: beats core label.no-css { inline-size: auto } (the class keeps .tabular label styles off) */
label.nm-swatch--custom { position: relative; inline-size: 28px; background: var(--nm-swatch, conic-gradient(#e53935, #fdd835, #43a047, #1e88e5, #8e24aa, #e53935)); }
.nm-swatch--custom input { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; padding: 0; border: 0; opacity: 0; cursor: pointer; } .nm-swatch--custom input { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; padding: 0; border: 0; opacity: 0; cursor: pointer; }
.nm-swatch--custom:focus-within { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; } .nm-swatch--custom:focus-within { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
/* theme.js lazy-loads the scheme generator from here; Redmine rewrites url() to the digested path */ /* theme.js lazy-loads the scheme generator from here; Redmine rewrites url() to the digested path */
:root { --nm-scheme-js: url(./scheme.js); } :root { --nm-scheme-js: url(./scheme.js); }
.nm-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--md-sys-color-surface-container), 0 0 0 4px var(--md-sys-color-on-surface); } .nm-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--md-sys-color-surface-container), 0 0 0 4px var(--md-sys-color-on-surface); }
/* Reset to default: M3 text button, shown once the user changed something */
.nm-appearance__reset {
margin-block-start: 12px;
block-size: 32px;
padding-inline: 12px;
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background: transparent;
color: var(--md-sys-color-primary);
font: var(--md-sys-typescale-label-large);
cursor: pointer;
}
.nm-appearance__reset:hover { background-image: var(--nm-state-hover); }
/* Default appearance on Administration > Settings > Display (redmine_tonalmine plugin) */
.nm-appearance--default { max-inline-size: 280px; padding: 0; }
/* Each group is its own form row: 40px tall like the fields (M3 segmented button height), so
the label lines up; the row padding spaces them */
.nm-appearance--default .nm-segmented { margin: 0; }
.nm-appearance--default .nm-segmented button { block-size: 40px; margin-block: 0; }
.nm-appearance--default .nm-swatches { min-block-size: 40px; align-items: center; }
/* The custom swatch is a <label class="no-css">; also undo .tabular.settings label margins and mobile full width */
html .tabular label.nm-swatch--custom { display: block; margin: 0; inline-size: 28px; }
html .flyout-menu .nm-appearance { padding-inline: 16px; } html .flyout-menu .nm-appearance { padding-inline: 16px; }
html .flyout-menu .nm-appearance__title { color: var(--md-sys-color-on-surface-variant); } html .flyout-menu .nm-appearance__title { color: var(--md-sys-color-on-surface-variant); }
+7 -2
View File
@@ -6,7 +6,8 @@ import { writeFileSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { ROLES, kebab, paletteCss } from './scheme.mjs'; import { ROLES, kebab, paletteCss } from './scheme.mjs';
// The first palette is the default. Ids must match PALETTES in tonalmine/javascripts/theme.js. // The first palette is the default. Ids must match PALETTES in tonalmine/javascripts/theme.js
// and redmine_tonalmine/lib/redmine_tonalmine/appearance.rb.
// An optional third entry is a Material Theme Builder export folder (light.css, dark.css and the // An optional third entry is a Material Theme Builder export folder (light.css, dark.css and the
// -mc/-hc contrast variants): // -mc/-hc contrast variants):
// its roles are copied as-is instead of generated, and the seed only drives success/warning. // its roles are copied as-is instead of generated, and the seed only drives success/warning.
@@ -53,8 +54,12 @@ const theme = resolve(process.cwd(), '../../tonalmine');
const out = `${theme}/stylesheets/palettes.css`; const out = `${theme}/stylesheets/palettes.css`;
writeFileSync(out, css); writeFileSync(out, css);
// Keep theme.js in sync: every palette id must appear in its PALETTES list. // Keep theme.js and the plugin's validation in sync: every palette id must appear in their PALETTES lists.
const js = readFileSync(`${theme}/javascripts/theme.js`, 'utf8'); const js = readFileSync(`${theme}/javascripts/theme.js`, 'utf8');
const missing = PALETTES.map(([id]) => id).filter((id) => !js.includes(`'${id}'`)); const missing = PALETTES.map(([id]) => id).filter((id) => !js.includes(`'${id}'`));
if (missing.length) { console.error(`theme.js is missing palettes: ${missing.join(', ')}`); process.exit(1); } if (missing.length) { console.error(`theme.js is missing palettes: ${missing.join(', ')}`); process.exit(1); }
const rb = readFileSync(resolve(theme, '../redmine_tonalmine/lib/redmine_tonalmine/appearance.rb'), 'utf8');
const rbIds = rb.match(/PALETTES = %w\[([^\]]*)\]/)[1].split(/\s+/);
const rbMissing = PALETTES.map(([id]) => id).filter((id) => !rbIds.includes(id));
if (rbMissing.length) { console.error(`appearance.rb is missing palettes: ${rbMissing.join(', ')}`); process.exit(1); }
console.log(`Wrote ${PALETTES.length} palettes to ${out}`); console.log(`Wrote ${PALETTES.length} palettes to ${out}`);