Skip to content

Commit 13e54b9

Browse files
Automatically detect low contrast messages and reverse display
Examine the contrast between text and the background in messages in the history and automatically switch to the alternate display mode if the contrast is too low. Users can still use the button to view the original version. This feature can be disabled globally or per user.
1 parent f78413c commit 13e54b9

9 files changed

Lines changed: 237 additions & 27 deletions

File tree

etc/RT_Config.pm.in

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4699,6 +4699,21 @@ user sent on reply or comment.
46994699

47004700
Set($ShowBccHeader, 0);
47014701

4702+
=item C<$TransactionAutoContrast>
4703+
4704+
When set, RT inspects email message bodies in the transaction history
4705+
client-side. If the text color and effective background color fall below
4706+
a WCAG contrast ratio of 3.0, the body is rendered with the opposite
4707+
theme's colors for readability. Each message keeps a manual button to
4708+
revert to the sender's original colors.
4709+
4710+
Set to 0 to disable this behavior. Users can override this preference in
4711+
their Preferences > Settings page.
4712+
4713+
=cut
4714+
4715+
Set($TransactionAutoContrast, 1);
4716+
47024717
=item C<$TrustHTMLAttachments>
47034718

47044719
If C<TrustHTMLAttachments> is not defined, we will display them as

lib/RT/Config.pm

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,16 @@ our %META;
532532
},
533533

534534
},
535+
TransactionAutoContrast => {
536+
Section => 'Ticket display',
537+
Overridable => 1,
538+
SortOrder => 4.5,
539+
Widget => '/Widgets/Form/Boolean',
540+
WidgetArguments => {
541+
Description => 'Auto-adjust contrast on low-contrast transaction history content', # loc
542+
Hints => 'When a message has poor contrast against the current theme, swap to the opposite theme colors for that message.', # loc
543+
},
544+
},
535545
PlainTextMono => {
536546
Section => 'Ticket display',
537547
Overridable => 1,

share/html/Admin/Tools/ConfigHistory.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
<div class="configuration history">
7474
<& /Admin/Elements/ConfigHelp &>
7575
<&|/Widgets/TitleBox, title => loc('History') &>
76-
<div class="history-container">
76+
<div class="history-container" data-auto-contrast="<% RT->Config->Get('TransactionAutoContrast', $session{CurrentUser}) ? 1 : 0 %>">
7777
% my $i = 1;
7878
% while (my $tx = $Transactions->Next()) {
7979
<& /Elements/ShowTransaction,

share/html/Asset/Elements/PagedShowHistory

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ my $url = RT->Config->Get('WebPath') . "/Helpers/AssetHistoryPage?" .
6565
ShowDisplayModes => 0,
6666
&>
6767

68-
<div class="history-container">
68+
<div class="history-container" data-auto-contrast="<% RT->Config->Get('TransactionAutoContrast', $session{CurrentUser}) ? 1 : 0 %>">
6969
<div hx-get="<% $url | n %>" hx-trigger="load" hx-swap="innerHTML" class="loading-history">
7070
<div class="spinner-border text-secondary" role="status">
7171
</div>

share/html/Elements/JavascriptConfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ my $Catalog = {
9999
clip => "Show less", #loc
100100
show_details => "Show Details", #loc
101101
hide_details => "Hide Details", #loc
102+
show_original_colors => "Show original colors", #loc
103+
improve_contrast => "Improve contrast", #loc
102104
http_message_500 => 'Server Error: Unable to load content for this request.', # loc
103105
http_message_400 => 'Client Request Error: Unable to process this request.', # loc
104106
http_message_401 => 'Client Request Error: Unauthorized.', # loc

share/html/Elements/ShowHistoryHeader

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,4 +416,4 @@ jQuery(function(){
416416
</script>
417417
% }
418418

419-
<div class="history-container" data-url="<% $url %>" id="<% $container_id %>">
419+
<div class="history-container" data-url="<% $url %>" id="<% $container_id %>" data-auto-contrast="<% RT->Config->Get('TransactionAutoContrast', $session{CurrentUser}) ? 1 : 0 %>">

share/static/css/elevator/history.css

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -416,37 +416,44 @@ div.history-container {
416416
}
417417
}
418418

419-
/* Toggle contrast: swap to the opposite theme's colors for readable email content */
419+
/* Toggle contrast: swap to the opposite theme's colors for readable email content.
420+
Applies to both manual (.toggle-contrast) and auto-detected (.auto-contrast). */
420421

421422
/* In dark mode, toggle to light theme colors */
422-
[data-bs-theme=dark] .history .transaction .messagebody.toggle-contrast {
423+
[data-bs-theme=dark] .history .transaction .messagebody.toggle-contrast,
424+
[data-bs-theme=dark] .history .transaction .messagebody.auto-contrast {
423425
background-color: var(--bs-light);
424426
color: var(--bs-dark);
425427
border-radius: 0.25em;
426428
border-top-color: transparent;
427429
}
428430

429-
[data-bs-theme=dark] .history .transaction .messagebody.toggle-contrast a {
431+
[data-bs-theme=dark] .history .transaction .messagebody.toggle-contrast a,
432+
[data-bs-theme=dark] .history .transaction .messagebody.auto-contrast a {
430433
color: var(--bs-primary);
431434
}
432435

433-
[data-bs-theme=dark] .history .transaction .messagebody.toggle-contrast a:visited {
436+
[data-bs-theme=dark] .history .transaction .messagebody.toggle-contrast a:visited,
437+
[data-bs-theme=dark] .history .transaction .messagebody.auto-contrast a:visited {
434438
color: var(--bs-indigo);
435439
}
436440

437441
/* In light mode, toggle to dark theme colors */
438-
[data-bs-theme=light] .history .transaction .messagebody.toggle-contrast {
442+
[data-bs-theme=light] .history .transaction .messagebody.toggle-contrast,
443+
[data-bs-theme=light] .history .transaction .messagebody.auto-contrast {
439444
background-color: var(--bs-dark);
440445
color: var(--bs-light);
441446
border-radius: 0.25em;
442447
border-top-color: transparent;
443448
}
444449

445-
[data-bs-theme=light] .history .transaction .messagebody.toggle-contrast a {
450+
[data-bs-theme=light] .history .transaction .messagebody.toggle-contrast a,
451+
[data-bs-theme=light] .history .transaction .messagebody.auto-contrast a {
446452
color: var(--bs-info);
447453
}
448454

449-
[data-bs-theme=light] .history .transaction .messagebody.toggle-contrast a:visited {
455+
[data-bs-theme=light] .history .transaction .messagebody.toggle-contrast a:visited,
456+
[data-bs-theme=light] .history .transaction .messagebody.auto-contrast a:visited {
450457
color: var(--bs-purple);
451458
}
452459

share/static/js/init.js

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,11 +1137,149 @@ jQuery(document).on('click', '.toggle-txn-details', function (e) {
11371137
return toggleTransactionDetails.apply(this);
11381138
});
11391139

1140+
/* Low-contrast scanning for email messagebodies.
1141+
Adds .auto-contrast to messagebodies whose computed text colors fall
1142+
below a WCAG contrast ratio threshold against their painted background. */
1143+
1144+
// Below this WCAG contrast ratio, a messagebody is flagged as low-contrast.
1145+
const autoContrastThreshold = 3.0;
1146+
1147+
// At most this many text-bearing descendants are examined per messagebody,
1148+
// to keep long threads cheap.
1149+
const autoContrastSampleCap = 50;
1150+
1151+
// Walks up from the given element and returns the first non-transparent
1152+
// background color it finds. Falls back to white when nothing opaque is
1153+
// encountered (e.g. detached node).
1154+
function effectiveBackgroundColor(el) {
1155+
let node = el;
1156+
while (node && node.nodeType === 1) {
1157+
const bg = window.getComputedStyle(node).backgroundColor;
1158+
const parsed = parseCssColor(bg);
1159+
if (parsed && parsed[3] > 0) return bg;
1160+
node = node.parentNode;
1161+
}
1162+
return 'rgb(255, 255, 255)';
1163+
}
1164+
1165+
// Returns true if any text-bearing descendant of the messagebody has a
1166+
// contrast ratio below autoContrastThreshold against its painted background.
1167+
// Short-circuits on the first low-contrast hit and caps sampling.
1168+
function scanMessageBody(el) {
1169+
if (!el || !el.querySelectorAll) return false;
1170+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
1171+
acceptNode: function (node) {
1172+
if (!/\S/.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
1173+
return NodeFilter.FILTER_ACCEPT;
1174+
}
1175+
});
1176+
let seen = 0;
1177+
let node;
1178+
while ((node = walker.nextNode())) {
1179+
if (seen >= autoContrastSampleCap) break;
1180+
const parent = node.parentElement;
1181+
if (!parent) continue;
1182+
const style = window.getComputedStyle(parent);
1183+
if (style.display === 'none' || style.visibility === 'hidden') continue;
1184+
const fg = style.color;
1185+
const bg = effectiveBackgroundColor(parent);
1186+
const ratio = contrastRatio(fg, bg);
1187+
if (ratio !== null && ratio < autoContrastThreshold) return true;
1188+
seen++;
1189+
}
1190+
return false;
1191+
}
1192+
1193+
// Updates the toggle-contrast icon for a messagebody to reflect whichever
1194+
// flip class (if any) is currently applied — active state, tooltip text,
1195+
// and aria-label are all kept in sync with the visible flip.
1196+
function syncContrastIcon($mb) {
1197+
const $link = $mb.closest('.transaction').find('.toggle-contrast-link');
1198+
if (!$link.length) return;
1199+
const active = $mb.hasClass('auto-contrast') || $mb.hasClass('toggle-contrast');
1200+
$link.closest('.rt-inline-icon').toggleClass('active', active);
1201+
const newTitle = loc_key(active ? 'show_original_colors' : 'improve_contrast');
1202+
const svg = $link.get(0).querySelector('svg');
1203+
if (svg) {
1204+
svg.setAttribute('aria-label', newTitle);
1205+
svg.setAttribute('title', newTitle);
1206+
if (window.bootstrap && bootstrap.Tooltip) {
1207+
bootstrap.Tooltip.getOrCreateInstance(svg).setContent({
1208+
'.tooltip-inner': newTitle
1209+
});
1210+
}
1211+
}
1212+
}
1213+
1214+
// Scans every messagebody inside the given history-container and applies
1215+
// .auto-contrast where appropriate. Respects the server-side opt-out
1216+
// (data-auto-contrast="0") and leaves manually-pinned messagebodies alone.
1217+
function scanHistoryForContrast(container) {
1218+
if (!container) return;
1219+
const $container = jQuery(container);
1220+
if ($container.data('auto-contrast') != 1) return;
1221+
$container.find('.messagebody').each(function () {
1222+
const $mb = jQuery(this);
1223+
if ($mb.hasClass('contrast-user-original')) return;
1224+
if ($mb.hasClass('toggle-contrast')) return;
1225+
if ($mb.hasClass('auto-contrast')) return;
1226+
if (scanMessageBody(this)) {
1227+
$mb.addClass('auto-contrast');
1228+
syncContrastIcon($mb);
1229+
}
1230+
});
1231+
}
1232+
1233+
// Convenience wrapper: scan every history-container on the page. Used on
1234+
// initial page load and whenever the theme changes.
1235+
function rescanAllHistoryContainers() {
1236+
jQuery('.history-container').each(function () {
1237+
scanHistoryForContrast(this);
1238+
});
1239+
}
1240+
1241+
jQuery(function () {
1242+
rescanAllHistoryContainers();
1243+
1244+
jQuery(document).on('htmx:afterSettle', function (e) {
1245+
const t = e.target;
1246+
const container = t && t.closest ? t.closest('.history-container') : null;
1247+
if (container) scanHistoryForContrast(container);
1248+
else if (jQuery(t).find('.history-container').length) rescanAllHistoryContainers();
1249+
});
1250+
1251+
if (typeof MutationObserver === 'function') {
1252+
const themeObserver = new MutationObserver(function () {
1253+
jQuery('.history .messagebody.auto-contrast')
1254+
.not('.contrast-user-original')
1255+
.each(function () {
1256+
const $mb = jQuery(this).removeClass('auto-contrast');
1257+
syncContrastIcon($mb);
1258+
});
1259+
rescanAllHistoryContainers();
1260+
});
1261+
themeObserver.observe(document.documentElement, {
1262+
attributes: true,
1263+
attributeFilter: ['data-bs-theme']
1264+
});
1265+
}
1266+
});
1267+
11401268
jQuery(document).on('click', '.toggle-contrast-link', function (e) {
11411269
e.preventDefault();
1142-
jQuery(this).closest('.rt-inline-icon').toggleClass('active');
1143-
var txn = jQuery(this).closest('.transaction');
1144-
txn.find('.messagebody').toggleClass('toggle-contrast');
1270+
const $mb = jQuery(this).closest('.transaction').find('.messagebody');
1271+
1272+
if ($mb.hasClass('auto-contrast')) {
1273+
$mb.removeClass('auto-contrast').addClass('contrast-user-original');
1274+
}
1275+
else if ($mb.hasClass('toggle-contrast')) {
1276+
$mb.removeClass('toggle-contrast');
1277+
}
1278+
else {
1279+
$mb.removeClass('contrast-user-original').addClass('toggle-contrast');
1280+
}
1281+
1282+
syncContrastIcon($mb);
11451283
});
11461284

11471285
jQuery(document).on('change', '.article-basics [name="Type"]', function () {

share/static/js/util.js

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -442,24 +442,62 @@ function initDatePicker(elem) {
442442
});
443443
}
444444

445+
/**
446+
* Parses a CSS color string ('#fff', '#ffffff', 'rgb(r,g,b)', 'rgba(r,g,b,a)')
447+
* into [r, g, b, a] with channels in 0-255 and alpha in 0-1. Returns null if
448+
* the input can't be parsed.
449+
*/
450+
function parseCssColor(color) {
451+
if (!color) return null;
452+
var s = String(color).trim();
453+
var m = s.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
454+
if (m) {
455+
var hex = m[1];
456+
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
457+
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16), 1];
458+
}
459+
m = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
460+
if (m) {
461+
return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), m[4] === undefined ? 1 : parseFloat(m[4])];
462+
}
463+
return null;
464+
}
465+
466+
/**
467+
* WCAG relative luminance for an [r, g, b] triple with channels in 0-255.
468+
*/
469+
function relativeLuminance(rgb) {
470+
var conv = function (c) {
471+
c = c / 255;
472+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
473+
};
474+
return 0.2126 * conv(rgb[0]) + 0.7152 * conv(rgb[1]) + 0.0722 * conv(rgb[2]);
475+
}
476+
477+
/**
478+
* WCAG contrast ratio between two CSS colors (any format parseCssColor
479+
* accepts). Returns null if either color can't be parsed.
480+
*/
481+
function contrastRatio(fg, bg) {
482+
var a = parseCssColor(fg);
483+
var b = parseCssColor(bg);
484+
if (!a || !b) return null;
485+
var L1 = relativeLuminance(a);
486+
var L2 = relativeLuminance(b);
487+
var hi = Math.max(L1, L2);
488+
var lo = Math.min(L1, L2);
489+
return (hi + 0.05) / (lo + 0.05);
490+
}
491+
445492
/**
446493
* Returns '#fff' or '#000', whichever provides better contrast against
447494
* the given background color per WCAG relative luminance guidelines.
448495
*/
449-
function contrastTextColor(hexColor) {
450-
if (!hexColor || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hexColor)) return '#000';
451-
var hex = hexColor.replace('#', '');
452-
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
453-
var r = parseInt(hex.substr(0, 2), 16) / 255;
454-
var g = parseInt(hex.substr(2, 2), 16) / 255;
455-
var b = parseInt(hex.substr(4, 2), 16) / 255;
456-
r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
457-
g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
458-
b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
459-
var L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
460-
var whiteContrast = 1.05 / (L + 0.05);
461-
var blackContrast = (L + 0.05) / 0.05;
462-
return whiteContrast >= blackContrast ? '#fff' : '#000';
496+
function contrastTextColor(bgColor) {
497+
var white = contrastRatio('#fff', bgColor);
498+
var black = contrastRatio('#000', bgColor);
499+
if (white === null || black === null) return '#000';
500+
return white >= black ? '#fff' : '#000';
463501
}
464502

465503
function textToHTML(value) {

0 commit comments

Comments
 (0)