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
@@ -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