Skip to content

Commit 70fe1a5

Browse files
committed
Web Inspector: Adding color contrast information within Color Picker
https://bugs.webkit.org/show_bug.cgi?id=260101 rdar://113887185 Reviewed by Devin Rousso (OOPS\!). Add contrast ratio threshold lines to the color picker overlay when editing text color properties. Two lines indicate AA and AAA WCAG compliance thresholds, helping developers pick accessible colors without leaving the picker. All contrast calculations happen in the frontend using the WCAG 2.0 relative luminance formula (https://www.w3.org/TR/WCAG20/#relativeluminancedef) and contrast ratio formula (https://www.w3.org/TR/WCAG20/#contrast-ratiodef). This avoids backend round-trips and provides instant feedback as users drag within the color square. The implementation uses binary search to find brightness values that achieve target luminance at each saturation level, rendering results as SVG polylines. Semi-transparent foreground colors are blended over the background before calculating contrast, ensuring accurate ratios for colors with alpha < 1. WCAG defines relaxed thresholds for "large text" (≥18pt or ≥14pt bold). Normal text requires 4.5:1 for AA and 7:1 for AAA. Large text only requires 3:1 for AA and 4.5:1 for AAA. The implementation reads computed font-size and font-weight to detect large text and adjusts the threshold lines accordingly. A contrast info section below the picker displays the current ratio and compliance badge (AAA/AA/Fail) alongside the background color swatch. * Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js: * Source/WebInspectorUI/UserInterface/Models/Color.js: (WI.Color.prototype.contrastComplianceForRatio): (WI.Color.prototype.relativeLuminance): (WI.Color.prototype.contrastRatio): (WI.Color.prototype.contrastCompliance): (WI.Color.prototype.blendOverBackground): (WI.Color): * Source/WebInspectorUI/UserInterface/Views/ColorPicker.css: (.color-picker > .contrast-info + .variable-color-swatches): (.color-picker > .variable-color-swatches > h2): (.color-picker > .contrast-info): (.color-picker > .contrast-info > .contrast-label): (.color-picker > .contrast-info > .contrast-ratio): (.color-picker > .contrast-info > .compliance-badge): (.color-picker > .contrast-info > .compliance-badge.contrast-aaa): (@media (prefers-color-scheme: dark) .color-picker > .contrast-info > .compliance-badge.contrast-aaa): (.color-picker > .contrast-info > .compliance-badge.contrast-aa): (@media (prefers-color-scheme: dark) .color-picker > .contrast-info > .compliance-badge.contrast-aa): (.color-picker > .contrast-info > .compliance-badge.contrast-fail): (@media (prefers-color-scheme: dark) .color-picker > .contrast-info > .compliance-badge.contrast-fail): (.color-picker > .contrast-info > .contrast-separator): (.color-picker > .contrast-info > .inline-swatch): * Source/WebInspectorUI/UserInterface/Views/ColorPicker.js: (WI.ColorPicker.prototype.async colorInputsWrapperElement): (WI.ColorPicker.prototype.set opacity): (WI.ColorPicker.prototype.set color): (WI.ColorPicker.prototype._updateColor): (WI.ColorPicker.prototype._createContrastInfoSection): (WI.ColorPicker.prototype._updateContrastInfo): (WI.ColorPicker): * Source/WebInspectorUI/UserInterface/Views/ColorSquare.css: (.color-square > .contrast-lines-svg): (.color-square > .contrast-lines-svg > .contrast-line): (.color-square > .contrast-lines-svg > .contrast-line.contrast-aa-threshold): (.color-square > .contrast-lines-svg > .contrast-line.contrast-aaa-threshold): (.color-square > .contrast-label): (.color-square > .contrast-label.contrast-aa-label): (.color-square > .contrast-label.contrast-aaa-label): (@media (prefers-color-scheme: dark) .color-square > .contrast-label): * Source/WebInspectorUI/UserInterface/Views/ColorSquare.js: (WI.ColorSquare): (WI.ColorSquare.prototype.get contrastBackgroundColor): (WI.ColorSquare.prototype.set contrastBackgroundColor): (WI.ColorSquare.prototype.get opacity): (WI.ColorSquare.prototype.set opacity): (WI.ColorSquare.prototype.get isLargeText): (WI.ColorSquare.prototype.set isLargeText): (WI.ColorSquare.prototype._updateBaseColor): (WI.ColorSquare.prototype._drawSRGBOutline): (WI.ColorSquare.prototype._drawContrastLines): (WI.ColorSquare.prototype._calculateContrastLinePoints): (WI.ColorSquare.prototype._findBrightnessForLuminance): (WI.ColorSquare.prototype._updatePolylinePoints): (WI.ColorSquare.prototype._updateContrastLabel): * Source/WebInspectorUI/UserInterface/Views/InlineSwatch.js: * Source/WebInspectorUI/UserInterface/Views/SpreadsheetStyleProperty.js: (WI.SpreadsheetStyleProperty.prototype.inlineSwatchGetContrastInfo): Canonical link: https://commits.webkit.org/308318@main
1 parent 7ff8905 commit 70fe1a5

File tree

8 files changed

+532
-14
lines changed

8 files changed

+532
-14
lines changed

Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,8 @@ localizedStrings["Connection Close Frame"] = "Connection Close Frame";
443443
localizedStrings["Connection Closed"] = "Connection Closed";
444444
localizedStrings["Connection ID"] = "Connection ID";
445445
localizedStrings["Connection:"] = "Connection:";
446+
/* Label for contrast ratio section in Color Picker */
447+
localizedStrings["Contrast @ Color Picker"] = "Contrast";
446448
localizedStrings["Console"] = "Console";
447449
localizedStrings["Console Evaluation"] = "Console Evaluation";
448450
localizedStrings["Console Evaluation %d"] = "Console Evaluation %d";
@@ -1943,6 +1945,14 @@ localizedStrings["Warning: "] = "Warning: ";
19431945
localizedStrings["Warnings"] = "Warnings";
19441946
localizedStrings["Watch Expressions"] = "Watch Expressions";
19451947
localizedStrings["Waterfall"] = "Waterfall";
1948+
/* Tooltip for AA contrast line in color picker */
1949+
localizedStrings["WCAG AA minimum contrast (4.5:1) @ Tooltip for AA contrast line in color picker"] = "WCAG AA minimum contrast (4.5:1)";
1950+
/* Tooltip for AA contrast line in color picker for large text */
1951+
localizedStrings["WCAG AA minimum contrast for large text (3:1) @ Tooltip for AA contrast line in color picker"] = "WCAG AA minimum contrast for large text (3:1)";
1952+
/* Tooltip for AAA contrast line in color picker */
1953+
localizedStrings["WCAG AAA enhanced contrast (7:1) @ Tooltip for AAA contrast line in color picker"] = "WCAG AAA enhanced contrast (7:1)";
1954+
/* Tooltip for AAA contrast line in color picker for large text */
1955+
localizedStrings["WCAG AAA enhanced contrast for large text (4.5:1) @ Tooltip for AAA contrast line in color picker"] = "WCAG AAA enhanced contrast for large text (4.5:1)";
19461956
localizedStrings["Web Animation"] = "Web Animation";
19471957
/* Section title for the JavaScript backtrace of the creation of a web animation */
19481958
localizedStrings["Web Animation Backtrace Title"] = "Backtrace";
@@ -2140,3 +2150,5 @@ localizedStrings["unsupported version"] = "unsupported version";
21402150
localizedStrings["value"] = "value";
21412151
/* Placeholder text in an editable field for the value of a HTTP header */
21422152
localizedStrings["value @ Local Override Popover New Headers Data Grid Item"] = "value";
2153+
/* Separator between foreground and background colors in contrast info */
2154+
localizedStrings["vs @ Color Picker Contrast"] = "vs";

Source/WebInspectorUI/UserInterface/Models/Color.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,19 @@ WI.Color = class Color
536536
return Number.constrain(Math.round(value), 0, 255);
537537
}
538538

539+
static contrastComplianceForRatio(ratio, {isLargeText} = {})
540+
{
541+
let aaaThreshold = isLargeText ? WI.Color.ContrastThreshold.AA : WI.Color.ContrastThreshold.AAA;
542+
if (ratio >= aaaThreshold)
543+
return WI.Color.ContrastCompliance.AAA;
544+
545+
let aaThreshold = isLargeText ? WI.Color.ContrastThreshold.AALargeText : WI.Color.ContrastThreshold.AA;
546+
if (ratio >= aaThreshold)
547+
return WI.Color.ContrastCompliance.AA;
548+
549+
return WI.Color.ContrastCompliance.Fail;
550+
}
551+
539552
// Public
540553

541554
nextFormat(format)
@@ -919,6 +932,53 @@ WI.Color = class Color
919932
hex = "0" + hex;
920933
return hex;
921934
}
935+
936+
// https://www.w3.org/TR/WCAG20/#relativeluminancedef
937+
relativeLuminance()
938+
{
939+
let [r, g, b] = WI.Color._toLinearLight(this.normalizedRGB);
940+
return (0.2126729 * r) + (0.7151522 * g) + (0.0721750 * b);
941+
}
942+
943+
contrastRatio(other)
944+
{
945+
console.assert(other instanceof WI.Color, other);
946+
947+
let l1 = this.relativeLuminance();
948+
let l2 = other.relativeLuminance();
949+
950+
if (l1 < l2)
951+
[l1, l2] = [l2, l1];
952+
953+
return (l1 + 0.05) / (l2 + 0.05);
954+
}
955+
956+
contrastCompliance(other, {isLargeText} = {})
957+
{
958+
let ratio = this.contrastRatio(other);
959+
return WI.Color.contrastComplianceForRatio(ratio, {isLargeText});
960+
}
961+
962+
blendOverBackground(backgroundColor)
963+
{
964+
console.assert(backgroundColor instanceof WI.Color, backgroundColor);
965+
966+
if (this.alpha === 1)
967+
return this.copy();
968+
969+
let foregroundRGB = this.normalizedRGB;
970+
let backgroundRGB = backgroundColor.normalizedRGB;
971+
let alpha = this.alpha;
972+
973+
let blendedRGB = [
974+
(foregroundRGB[0] * alpha) + (backgroundRGB[0] * (1 - alpha)),
975+
(foregroundRGB[1] * alpha) + (backgroundRGB[1] * (1 - alpha)),
976+
(foregroundRGB[2] * alpha) + (backgroundRGB[2] * (1 - alpha)),
977+
];
978+
979+
return new WI.Color(WI.Color.Format.ColorFunction, blendedRGB, this.gamut);
980+
}
981+
922982
};
923983

924984
WI.Color.Format = {
@@ -949,6 +1009,18 @@ WI.Color.FunctionNames = new Set([
9491009
"color-mix",
9501010
]);
9511011

1012+
WI.Color.ContrastThreshold = {
1013+
AAA: 7,
1014+
AA: 4.5,
1015+
AALargeText: 3,
1016+
};
1017+
1018+
WI.Color.ContrastCompliance = {
1019+
AAA: "AAA",
1020+
AA: "AA",
1021+
Fail: "Fail",
1022+
};
1023+
9521024
WI.Color.Keywords = {
9531025
"aliceblue": [240, 248, 255, 1],
9541026
"antiquewhite": [250, 235, 215, 1],

Source/WebInspectorUI/UserInterface/Views/ColorPicker.css

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@
113113
padding: 8px 0 0;
114114
}
115115

116+
.color-picker > .contrast-info + .variable-color-swatches {
117+
margin-top: 8px;
118+
padding-top: 8px;
119+
border-top: 1px solid var(--border-color);
120+
}
121+
116122
.color-picker > .variable-color-swatches > ul{
117123
list-style: none;
118124
display: flex;
@@ -137,4 +143,72 @@
137143
.color-picker > .variable-color-swatches > h2 {
138144
margin: 0;
139145
font-size: 1.15em;
146+
}
147+
148+
.color-picker > .contrast-info {
149+
display: flex;
150+
align-items: center;
151+
gap: 6px;
152+
padding: 8px 0 0;
153+
margin-top: 8px;
154+
border-top: 1px solid var(--border-color);
155+
font-size: 11px;
156+
}
157+
158+
.color-picker > .contrast-info > .contrast-label {
159+
color: var(--text-color-secondary);
160+
}
161+
162+
.color-picker > .contrast-info > .contrast-ratio {
163+
font-variant-numeric: tabular-nums;
164+
}
165+
166+
.color-picker > .contrast-info > .compliance-badge {
167+
padding: 2px 6px;
168+
border-radius: 3px;
169+
font-size: 10px;
170+
font-weight: bold;
171+
text-transform: uppercase;
172+
}
173+
174+
.color-picker > .contrast-info > .compliance-badge.contrast-aaa {
175+
background-color: hsl(120, 50%, 35%);
176+
color: white;
177+
}
178+
179+
@media (prefers-color-scheme: dark) {
180+
.color-picker > .contrast-info > .compliance-badge.contrast-aaa {
181+
background-color: hsl(120, 50%, 45%);
182+
}
183+
}
184+
185+
.color-picker > .contrast-info > .compliance-badge.contrast-aa {
186+
background-color: hsl(90, 50%, 40%);
187+
color: white;
188+
}
189+
190+
@media (prefers-color-scheme: dark) {
191+
.color-picker > .contrast-info > .compliance-badge.contrast-aa {
192+
background-color: hsl(90, 50%, 50%);
193+
}
194+
}
195+
196+
.color-picker > .contrast-info > .compliance-badge.contrast-fail {
197+
background-color: hsl(0, 60%, 50%);
198+
color: white;
199+
}
200+
201+
@media (prefers-color-scheme: dark) {
202+
.color-picker > .contrast-info > .compliance-badge.contrast-fail {
203+
background-color: hsl(0, 60%, 60%);
204+
}
205+
}
206+
207+
.color-picker > .contrast-info > .contrast-separator {
208+
color: var(--text-color-tertiary);
209+
margin: 0 2px;
210+
}
211+
212+
.color-picker > .contrast-info > .inline-swatch {
213+
--inline-swatch-margin-right-override: 0;
140214
}

Source/WebInspectorUI/UserInterface/Views/ColorPicker.js

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,18 @@
2525

2626
WI.ColorPicker = class ColorPicker extends WI.Object
2727
{
28-
constructor({preventChangingColorFormats, colorVariables} = {})
28+
constructor({preventChangingColorFormats, colorVariables, contrastInfo} = {})
2929
{
3030
super();
3131

3232
this._preventChangingColorFormats = !!preventChangingColorFormats;
33+
this._contrastInfo = contrastInfo || null;
3334

3435
this._colorSquare = new WI.ColorSquare(this, 200);
36+
if (this._contrastInfo?.backgroundColor) {
37+
this._colorSquare.contrastBackgroundColor = this._contrastInfo.backgroundColor;
38+
this._colorSquare.isLargeText = !!this._contrastInfo.isLargeText;
39+
}
3540

3641
this._hueSlider = new WI.Slider;
3742
this._hueSlider.delegate = this;
@@ -92,6 +97,10 @@ WI.ColorPicker = class ColorPicker extends WI.Object
9297
colorInputsWrapperElement.appendChild(pickColorElement);
9398
}
9499

100+
this._contrastInfoElement = null;
101+
if (this._contrastInfo?.backgroundColor)
102+
this._createContrastInfoSection();
103+
95104
this._opacity = 0;
96105
this._opacityPattern = "url(Images/Checkers.svg)";
97106

@@ -103,7 +112,7 @@ WI.ColorPicker = class ColorPicker extends WI.Object
103112

104113
let swatchesTitle = variableColorSwatchesContainer.appendChild(document.createElement("h2"));
105114
swatchesTitle.textContent = WI.UIString("Variables", "Variables @ Color Picker", "Title of swatches section in Color Picker");
106-
115+
107116
let variableColorSwatchesListElement = variableColorSwatchesContainer.appendChild(document.createElement("ul"));
108117
let sortedColorVariables = WI.ColorPicker.sortColorVariables(colorVariables);
109118

@@ -236,6 +245,7 @@ WI.ColorPicker = class ColorPicker extends WI.Object
236245
return;
237246

238247
this._opacity = opacity;
248+
this._colorSquare.opacity = opacity;
239249
this._updateColor();
240250
}
241251

@@ -253,6 +263,7 @@ WI.ColorPicker = class ColorPicker extends WI.Object
253263
this._color = color;
254264

255265
this._colorSquare.tintedColor = this._color;
266+
this._colorSquare.opacity = this._color.alpha;
256267

257268
this._hueSlider.value = this._color.hsl[0] / 360;
258269

@@ -261,6 +272,7 @@ WI.ColorPicker = class ColorPicker extends WI.Object
261272

262273
this._showColorComponentInputs();
263274
this._updateColorGamut();
275+
this._updateContrastInfo();
264276

265277
this._dontUpdateColor = false;
266278
}
@@ -318,6 +330,7 @@ WI.ColorPicker = class ColorPicker extends WI.Object
318330
this.dispatchEventToListeners(WI.ColorPicker.Event.ColorChanged, {color: this._color});
319331

320332
this._updateColorGamut();
333+
this._updateContrastInfo();
321334
}
322335

323336
_updateOpacitySlider()
@@ -494,6 +507,57 @@ WI.ColorPicker = class ColorPicker extends WI.Object
494507
});
495508
}
496509
}
510+
511+
_createContrastInfoSection()
512+
{
513+
this._contrastInfoElement = this._element.appendChild(document.createElement("div"));
514+
this._contrastInfoElement.classList.add("contrast-info");
515+
516+
let labelElement = this._contrastInfoElement.appendChild(document.createElement("span"));
517+
labelElement.classList.add("contrast-label");
518+
labelElement.textContent = WI.UIString("Contrast", "Contrast @ Color Picker", "Label for contrast ratio section in Color Picker");
519+
520+
this._contrastRatioElement = this._contrastInfoElement.appendChild(document.createElement("span"));
521+
this._contrastRatioElement.classList.add("contrast-ratio");
522+
523+
this._complianceBadgeElement = this._contrastInfoElement.appendChild(document.createElement("span"));
524+
this._complianceBadgeElement.classList.add("compliance-badge");
525+
526+
let separatorElement = this._contrastInfoElement.appendChild(document.createElement("span"));
527+
separatorElement.classList.add("contrast-separator");
528+
separatorElement.textContent = WI.UIString("vs", "vs @ Color Picker Contrast", "Separator between foreground and background colors in contrast info");
529+
530+
let backgroundSwatch = new WI.InlineSwatch(WI.InlineSwatch.Type.Color, this._contrastInfo.backgroundColor, {readOnly: true, tooltip: WI.UIString("Background Color")});
531+
this._contrastInfoElement.appendChild(backgroundSwatch.element);
532+
}
533+
534+
_updateContrastInfo()
535+
{
536+
if (!this._contrastInfo?.backgroundColor || !this._contrastInfoElement)
537+
return;
538+
539+
let effectiveForeground = this._color.blendOverBackground(this._contrastInfo.backgroundColor);
540+
541+
let ratio = effectiveForeground.contrastRatio(this._contrastInfo.backgroundColor);
542+
let isLargeText = !!this._contrastInfo.isLargeText;
543+
let compliance = WI.Color.contrastComplianceForRatio(ratio, {isLargeText});
544+
545+
this._contrastRatioElement.textContent = ratio.toFixed(2) + ":1";
546+
547+
this._complianceBadgeElement.textContent = compliance;
548+
this._complianceBadgeElement.className = "compliance-badge";
549+
switch (compliance) {
550+
case WI.Color.ContrastCompliance.AAA:
551+
this._complianceBadgeElement.classList.add("contrast-aaa");
552+
break;
553+
case WI.Color.ContrastCompliance.AA:
554+
this._complianceBadgeElement.classList.add("contrast-aa");
555+
break;
556+
case WI.Color.ContrastCompliance.Fail:
557+
this._complianceBadgeElement.classList.add("contrast-fail");
558+
break;
559+
}
560+
}
497561
};
498562

499563
WI.ColorPicker.Event = {

Source/WebInspectorUI/UserInterface/Views/ColorSquare.css

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,56 @@
9999
stroke-opacity: var(--stroke-opacity) / 2;
100100
}
101101
}
102+
103+
.color-square > .contrast-lines-svg {
104+
position: absolute;
105+
top: 0;
106+
left: 0;
107+
width: 100%;
108+
height: 100%;
109+
pointer-events: none;
110+
z-index: 1;
111+
}
112+
113+
.color-square > .contrast-lines-svg > .contrast-line {
114+
fill: none;
115+
stroke-width: 1px;
116+
stroke-linecap: round;
117+
stroke-linejoin: round;
118+
}
119+
120+
.color-square > .contrast-lines-svg > .contrast-line.contrast-aa-threshold {
121+
stroke: white;
122+
stroke-opacity: 0.8;
123+
}
124+
125+
.color-square > .contrast-lines-svg > .contrast-line.contrast-aaa-threshold {
126+
stroke: white;
127+
stroke-opacity: 0.6;
128+
}
129+
130+
.color-square > .contrast-label {
131+
position: absolute;
132+
padding: 1px 3px;
133+
font-size: 9px;
134+
font-weight: bold;
135+
border-radius: 2px;
136+
pointer-events: none;
137+
z-index: 2;
138+
color: white;
139+
text-shadow: 0 0 2px black;
140+
}
141+
142+
.color-square > .contrast-label.contrast-aa-label {
143+
opacity: 0.9;
144+
}
145+
146+
.color-square > .contrast-label.contrast-aaa-label {
147+
opacity: 0.7;
148+
}
149+
150+
@media (prefers-color-scheme: dark) {
151+
.color-square > .contrast-label {
152+
text-shadow: 0 0 2px white;
153+
}
154+
}

0 commit comments

Comments
 (0)