@@ -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+
11401268jQuery ( 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
11471285jQuery ( document ) . on ( 'change' , '.article-basics [name="Type"]' , function ( ) {
0 commit comments