From d5689dedb5e5bcff857fe14caed686ba372f74a1 Mon Sep 17 00:00:00 2001 From: Alex Varvarchuk Date: Thu, 5 Nov 2026 10:01:01 +0100 Subject: [PATCH 1/2] fix: make form errors, breadcrumbs and has_many headings readable Form errors: the theme never coloured validation messages, so ActiveAdmin's #932419 text and #fae6e4 summary block carried over into dark mode: dark red messages on the dark panel and a pale block above the form. Add $skinErrorTextColor and $skinErrorBgColor with Dark variants. The light defaults are the values ActiveAdmin already paints, so light forms keep their look. Breadcrumbs: the colour was derived from $skinMainFirstColor, but the text sits on the title bar. Mix it from $skinTitleBarColor, the fill it is on. has_many headings: ActiveAdmin renders the container as an li when f.has_many is inside f.inputs and as a div otherwise. The old rule matched only the li, so the div kept ActiveAdmin's #5e6469. Both now use --aa-text-muted. css_check gives these pairs the contrast guard status tags already have, and rejects invalid values for the new variables. Claude-Session: https://claude.ai/code/session_01TXyspr287VnxtQEFUec2t3 --- README.md | 2 + .../stylesheets/wigu/active_admin_theme.scss | 27 +++++++++++-- docs/swatches/3a2224.svg | 1 + docs/swatches/932419.svg | 1 + docs/swatches/fae6e4.svg | 1 + test/css_check.rb | 38 +++++++++++++++++++ 6 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 docs/swatches/3a2224.svg create mode 100644 docs/swatches/932419.svg create mode 100644 docs/swatches/fae6e4.svg diff --git a/README.md b/README.md index 6bad8d1..67806d0 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,8 @@ Three things changed shape and are worth knowing if you already set variables: | `$skinBorderColor` / `$skinBorderColorDark` | ![#e0e4e9](docs/swatches/e0e4e9.svg) `#e0e4e9` / ![#404750](docs/swatches/404750.svg) `#404750` | borders / grid lines | | `$skinInputBgColor` / `$skinInputBgColorDark` | ![#ffffff](docs/swatches/ffffff.svg) `#ffffff` / ![#1e2227](docs/swatches/1e2227.svg) `#1e2227` | form control background | | `$skinInputBorderColor` / `$skinInputBorderColorDark` | ![#c9ced4](docs/swatches/c9ced4.svg) `#c9ced4` / ![#4d555f](docs/swatches/4d555f.svg) `#4d555f` | form control border | +| `$skinErrorTextColor` / `$skinErrorTextColorDark` | ![#932419](docs/swatches/932419.svg) `#932419` / ![#f49b9b](docs/swatches/f49b9b.svg) `#f49b9b` | validation messages | +| `$skinErrorBgColor` / `$skinErrorBgColorDark` | ![#fae6e4](docs/swatches/fae6e4.svg) `#fae6e4` / ![#3a2224](docs/swatches/3a2224.svg) `#3a2224` | error summary above a form | #### Header menu diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 7928648..47ebe30 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -123,6 +123,11 @@ $skinLinkColorDark: #7cc0ec!default; // match); dark uses a lightened red, since the default red is too dark there. $skinDeleteLinkColor: $skinLinkColor!default; $skinDeleteLinkColorDark: #f49b9b!default; +// Validation errors: message text and summary block fill. +$skinErrorTextColor: #932419!default; +$skinErrorTextColorDark: #f49b9b!default; +$skinErrorBgColor: #fae6e4!default; +$skinErrorBgColorDark: #3a2224!default; // Tabs: fill and label colours per mode. yeti-web sets all four to make the // inactive tabs recede to the page background and the active one read as raised. $skinTabInactiveColor: #f7f9fb!default; @@ -186,6 +191,8 @@ $skinStatusTagErrorColor: #ce483b!default; // error / errored / red --aa-active-tab-text: #{$skinActiveTabTextColorDark}; --aa-inactive-tab-text: #{$skinInactiveTabTextColorDark}; --aa-delete-link: #{$skinDeleteLinkColorDark}; + --aa-error: #{$skinErrorTextColorDark}; + --aa-error-bg: #{$skinErrorBgColorDark}; } :root { color-scheme: light; @@ -217,6 +224,8 @@ $skinStatusTagErrorColor: #ce483b!default; // error / errored / red --aa-active-tab-text: #{$skinActiveTabTextColor}; --aa-inactive-tab-text: #{$skinInactiveTabTextColor}; --aa-delete-link: #{$skinDeleteLinkColor}; + --aa-error: #{$skinErrorTextColor}; + --aa-error-bg: #{$skinErrorBgColor}; // Bridge for the companion active_admin_datetimepicker plugin: feed its // --aa-datetimepicker-* inputs from this theme's palette, so the xdsoft picker @@ -337,6 +346,10 @@ $logout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' skinLinkColorDark: $skinLinkColorDark, skinDeleteLinkColor: $skinDeleteLinkColor, skinDeleteLinkColorDark: $skinDeleteLinkColorDark, + skinErrorTextColor: $skinErrorTextColor, + skinErrorTextColorDark: $skinErrorTextColorDark, + skinErrorBgColor: $skinErrorBgColor, + skinErrorBgColorDark: $skinErrorBgColorDark, skinTabInactiveColor: $skinTabInactiveColor, skinTabInactiveColorDark: $skinTabInactiveColorDark, skinActiveTabTextColor: $skinActiveTabTextColor, @@ -702,7 +715,7 @@ body.active_admin { text-shadow: none; margin-bottom: 0; a, span { - color: lighten( $skinMainFirstColor, 30% )!important; + color: mix(#ffffff, $skinTitleBarColor, 60%)!important; } } // Own a sensible title size so consumer apps don't each shrink AA's large @@ -1242,8 +1255,16 @@ body.active_admin { form fieldset > ol > li fieldset.fragments ol { margin-left: 20%; } -form fieldset ol > li.has_many_container h3 { - color: #8494a8; +form .has_many_container h3 { + color: var(--aa-text-muted); +} +form fieldset > ol > li p.inline-errors, +form fieldset > ol > li ul.errors, +form ul.errors { + color: var(--aa-error); +} +form ul.errors { + background: var(--aa-error-bg); } form input[type=submit], form input[type=button], form button, a.button, a:link.button, a:visited.button { background-image: none; diff --git a/docs/swatches/3a2224.svg b/docs/swatches/3a2224.svg new file mode 100644 index 0000000..bc35653 --- /dev/null +++ b/docs/swatches/3a2224.svg @@ -0,0 +1 @@ + diff --git a/docs/swatches/932419.svg b/docs/swatches/932419.svg new file mode 100644 index 0000000..84b4577 --- /dev/null +++ b/docs/swatches/932419.svg @@ -0,0 +1 @@ + diff --git a/docs/swatches/fae6e4.svg b/docs/swatches/fae6e4.svg new file mode 100644 index 0000000..ad19344 --- /dev/null +++ b/docs/swatches/fae6e4.svg @@ -0,0 +1 @@ + diff --git a/test/css_check.rb b/test/css_check.rb index 4afdbac..2a031b3 100644 --- a/test/css_check.rb +++ b/test/css_check.rb @@ -61,6 +61,8 @@ module CssCheck "$skinStatusTagWarnColor: none" => '$skinStatusTagWarnColor: none;', "$skinStatusTagErrorColor: none" => '$skinStatusTagErrorColor: none;', "$skinStatusTagTextColor as a length" => '$skinStatusTagTextColor: 10px;', + "$skinErrorTextColor: none" => '$skinErrorTextColor: none;', + "$skinErrorBgColorDark: none" => '$skinErrorBgColorDark: none;', }.freeze # The variables table in the README is the public contract people configure @@ -242,6 +244,41 @@ def self.status_tag_labels_are_readable problems end + # The same bar for the other text the theme paints on a fill it also owns. + # The breadcrumb is read from the compiled output, and a missing match raises + # rather than letting the guard go quiet. + def self.message_pairs + crumb = compile("")[/span\.breadcrumb span\s*\{\s*color:\s*(#\h+)/, 1] + raise "css_check: no breadcrumb colour found" if crumb.nil? + + { + "breadcrumb" => [crumb, "$skinTitleBarColor"], + "error_on_panel" => ["$skinErrorTextColor", "$skinSurfaceColor"], + "error_on_panel_dark" => ["$skinErrorTextColorDark", "$skinSurfaceColorDark"], + "error_summary" => ["$skinErrorTextColor", "$skinErrorBgColor"], + "error_summary_dark" => ["$skinErrorTextColorDark", "$skinErrorBgColorDark"], + "has_many_heading" => ["$skinTextMutedColor", "$skinPageBgColor"], + "has_many_heading_dark" => ["$skinTextMutedColorDark", "$skinPageBgColorDark"] + } + end + + def self.messages_are_readable + pairs = message_pairs + probe = pairs.flat_map { |name, (text, fill)| [["#{name}_text", text], ["#{name}_fill", fill]] }.map do |name, colour| + ".css-check-#{name} { r: red(#{colour}); g: green(#{colour}); b: blue(#{colour}); }" + end + colours = compile("", probe.join("\n")) + .scan(/\.css-check-(\w+)\s*\{\s*r:\s*([\d.]+);\s*g:\s*([\d.]+);\s*b:\s*([\d.]+);?\s*\}/) + .to_h { |name, *rgb| [name, rgb.map { |v| v.to_f.round }] } + + pairs.keys.filter_map do |name| + text, fill = colours.fetch("#{name}_text"), colours.fetch("#{name}_fill") + ratio = contrast(text, fill) + next if ratio >= LABEL_MINIMUM + "#{name}: #{hex(text)} on #{hex(fill)} is #{format("%.3f", ratio)}:1, under #{LABEL_MINIMUM}" + end + end + def self.hex(rgb) format("#%02x%02x%02x", *rgb) end @@ -317,6 +354,7 @@ def self.run # before the second ran — so a run could report one problem while holding # three, and each fix revealed the next. failures.concat(status_tag_labels_are_readable) + failures.concat(messages_are_readable) failures.concat(readme_table_matches_declarations) unless failures.empty? From 0cf25a40d89c79babdfd55ac45be1421e338454e Mon Sep 17 00:00:00 2001 From: Alex Varvarchuk Date: Thu, 5 Nov 2026 10:22:57 +0100 Subject: [PATCH 2/2] fix: restore the breadcrumb colour and read alpha in the message guard Mixing the breadcrumb from $skinTitleBarColor only toward white made it white on a white title bar. Go back to the colour master ships, and drop the breadcrumb from the contrast guard, which that colour does not pass. messages_are_readable dropped alpha, so a transparent colour scored as black and could pass. Read it and report translucent pairs, as status_tag_labels_are_readable does. Claude-Session: https://claude.ai/code/session_01TXyspr287VnxtQEFUec2t3 --- .../stylesheets/wigu/active_admin_theme.scss | 2 +- test/css_check.rb | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 47ebe30..7d1984f 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -715,7 +715,7 @@ body.active_admin { text-shadow: none; margin-bottom: 0; a, span { - color: mix(#ffffff, $skinTitleBarColor, 60%)!important; + color: lighten( $skinMainFirstColor, 30% )!important; } } // Own a sensible title size so consumer apps don't each shrink AA's large diff --git a/test/css_check.rb b/test/css_check.rb index 2a031b3..c1f0f84 100644 --- a/test/css_check.rb +++ b/test/css_check.rb @@ -245,14 +245,8 @@ def self.status_tag_labels_are_readable end # The same bar for the other text the theme paints on a fill it also owns. - # The breadcrumb is read from the compiled output, and a missing match raises - # rather than letting the guard go quiet. def self.message_pairs - crumb = compile("")[/span\.breadcrumb span\s*\{\s*color:\s*(#\h+)/, 1] - raise "css_check: no breadcrumb colour found" if crumb.nil? - { - "breadcrumb" => [crumb, "$skinTitleBarColor"], "error_on_panel" => ["$skinErrorTextColor", "$skinSurfaceColor"], "error_on_panel_dark" => ["$skinErrorTextColorDark", "$skinSurfaceColorDark"], "error_summary" => ["$skinErrorTextColor", "$skinErrorBgColor"], @@ -265,17 +259,19 @@ def self.message_pairs def self.messages_are_readable pairs = message_pairs probe = pairs.flat_map { |name, (text, fill)| [["#{name}_text", text], ["#{name}_fill", fill]] }.map do |name, colour| - ".css-check-#{name} { r: red(#{colour}); g: green(#{colour}); b: blue(#{colour}); }" + ".css-check-#{name} { r: red(#{colour}); g: green(#{colour}); b: blue(#{colour}); a: alpha(#{colour}); }" end colours = compile("", probe.join("\n")) - .scan(/\.css-check-(\w+)\s*\{\s*r:\s*([\d.]+);\s*g:\s*([\d.]+);\s*b:\s*([\d.]+);?\s*\}/) - .to_h { |name, *rgb| [name, rgb.map { |v| v.to_f.round }] } + .scan(/\.css-check-(\w+)\s*\{\s*r:\s*([\d.]+);\s*g:\s*([\d.]+);\s*b:\s*([\d.]+);\s*a:\s*([\d.]+);?\s*\}/) + .to_h { |name, r, g, b, a| [name, { rgb: [r, g, b].map { |v| v.to_f.round }, alpha: a.to_f }] } pairs.keys.filter_map do |name| text, fill = colours.fetch("#{name}_text"), colours.fetch("#{name}_fill") - ratio = contrast(text, fill) + next "#{name}: translucent, so the ratio cannot be measured" if text[:alpha] < 1 || fill[:alpha] < 1 + + ratio = contrast(text[:rgb], fill[:rgb]) next if ratio >= LABEL_MINIMUM - "#{name}: #{hex(text)} on #{hex(fill)} is #{format("%.3f", ratio)}:1, under #{LABEL_MINIMUM}" + "#{name}: #{hex(text[:rgb])} on #{hex(fill[:rgb])} is #{format("%.3f", ratio)}:1, under #{LABEL_MINIMUM}" end end