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:
co-authored by
Claude Opus 5.5
parent
77040e24a8
commit
20b7269df2
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+152
-32
@@ -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,52 +145,111 @@
|
|||||||
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) {
|
||||||
|
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')));
|
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto')));
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-nm-contrast]').forEach(function (b) {
|
picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) {
|
||||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard')));
|
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard')));
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-nm-palette]').forEach(function (b) {
|
picker.querySelectorAll('[data-nm-palette]').forEach(function (b) {
|
||||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
|
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-default-input]').forEach(function (input) {
|
||||||
|
input.value = draft[input.getAttribute('data-nm-default-input')] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
sync();
|
||||||
document.querySelectorAll('[data-nm-custom]').forEach(function (input) {
|
}
|
||||||
input.value = seed;
|
|
||||||
input.parentNode.style.setProperty('--nm-swatch', seed);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user