From 20b7269df27a686c9b678d666e92d3e2ba6db1b5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 5 Oct 2026 14:12:44 +0200 Subject: [PATCH] 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 --- README.md | 19 +- dev/docker-compose.yml | 1 + e2e/check-appearance.mjs | 167 +++++++++++++++ .../tonalmine_appearance_controller.rb | 17 ++ redmine_tonalmine/config/routes.rb | 3 + redmine_tonalmine/init.rb | 14 ++ .../lib/redmine_tonalmine/appearance.rb | 58 ++++++ .../lib/redmine_tonalmine/hooks.rb | 19 ++ tonalmine/javascripts/theme.js | 190 ++++++++++++++---- tonalmine/stylesheets/components.css | 25 ++- tools/palettes/build_palettes.mjs | 9 +- 11 files changed, 482 insertions(+), 40 deletions(-) create mode 100644 e2e/check-appearance.mjs create mode 100644 redmine_tonalmine/app/controllers/tonalmine_appearance_controller.rb create mode 100644 redmine_tonalmine/config/routes.rb create mode 100644 redmine_tonalmine/init.rb create mode 100644 redmine_tonalmine/lib/redmine_tonalmine/appearance.rb create mode 100644 redmine_tonalmine/lib/redmine_tonalmine/hooks.rb diff --git a/README.md b/README.md index ede2e47..6705d4a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Material Design 3 theme for Redmine 7.0. 2. Restart Redmine (theme assets are compiled at boot). 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 `/plugins/` and +restart Redmine (no migrations). + ## Development Requires Docker. @@ -49,8 +53,19 @@ the map, so run it after every Redmine update. ### Colors 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 -stored per browser (`localStorage`); Auto follows the operating system. The last (rainbow) swatch +**Appearance** section of the profile menu (top right; in the mobile menu at the bottom). Auto follows +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 browser, and the generated CSS is stored with the choice, so later page loads don't need it. diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 2b4c201..66ba500 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -8,6 +8,7 @@ services: - redmine-files:/usr/src/redmine/files - ../tonalmine:/usr/src/redmine/themes/tonalmine - ./plugins:/usr/src/redmine/plugins + - ../redmine_tonalmine:/usr/src/redmine/plugins/redmine_tonalmine - .:/seed:ro volumes: diff --git a/e2e/check-appearance.mjs b/e2e/check-appearance.mjs new file mode 100644 index 0000000..59845ed --- /dev/null +++ b/e2e/check-appearance.mjs @@ -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 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: '' }, { 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); diff --git a/redmine_tonalmine/app/controllers/tonalmine_appearance_controller.rb b/redmine_tonalmine/app/controllers/tonalmine_appearance_controller.rb new file mode 100644 index 0000000..ebbbb5b --- /dev/null +++ b/redmine_tonalmine/app/controllers/tonalmine_appearance_controller.rb @@ -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 diff --git a/redmine_tonalmine/config/routes.rb b/redmine_tonalmine/config/routes.rb new file mode 100644 index 0000000..ba1e84d --- /dev/null +++ b/redmine_tonalmine/config/routes.rb @@ -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 diff --git a/redmine_tonalmine/init.rb b/redmine_tonalmine/init.rb new file mode 100644 index 0000000..eada1a5 --- /dev/null +++ b/redmine_tonalmine/init.rb @@ -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 diff --git a/redmine_tonalmine/lib/redmine_tonalmine/appearance.rb b/redmine_tonalmine/lib/redmine_tonalmine/appearance.rb new file mode 100644 index 0000000..c3071a6 --- /dev/null +++ b/redmine_tonalmine/lib/redmine_tonalmine/appearance.rb @@ -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