From 9934931f1ff4093936b757186bd5d36b9a511c75 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Sun, 7 Apr 2024 18:19:25 +0200
Subject: [PATCH] [PORT] gitea##30237: Fix and rewrite contrast color
 calculation, fix project-related bugs

1. The previous color contrast calculation function was incorrect at
least for the `#84b6eb` where it output low-contrast white instead of
black. I've rewritten these functions now to accept hex colors and to
match GitHub's calculation and to output pure white/black for maximum
contrast. Before and after:
<img width="94" alt="Screenshot 2024-04-02 at 01 53 46"
src="https://github.com/go-gitea/gitea/assets/115237/00b39e15-a377-4458-95cf-ceec74b78228"><img
width="90" alt="Screenshot 2024-04-02 at 01 51 30"
src="https://github.com/go-gitea/gitea/assets/115237/1677067a-8d8f-47eb-82c0-76330deeb775">

2. Fix project-related issues:

- Expose the new `ContrastColor` function as template helper and use it
for project cards, replacing the previous JS solution which eliminates a
flash of wrong color on page load.
- Fix a bug where if editing a project title, the counter would get
lost.
- Move `rgbToHex` function to color utils.

Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: Giteabot <teabot@gitea.io>

---
Conflict resolution: Trivial.
(cherry picked from commit 36887ed3921d03f1864360c95bd2ecf853bfbe72)
(cherry picked from commit f6c0c39f1aef167bb14375a009cf463c6bf031fb)
---
 modules/templates/helper.go              |  6 +--
 modules/templates/util_render.go         | 12 ++---
 modules/util/color.go                    | 42 +++++++---------
 modules/util/color_test.go               | 46 +++++++++---------
 templates/projects/view.tmpl             |  8 ++--
 web_src/css/features/projects.css        | 27 +++++------
 web_src/css/repo.css                     | 15 +++++-
 web_src/css/repo/issue-list.css          | 17 -------
 web_src/css/themes/theme-gitea-dark.css  |  2 -
 web_src/css/themes/theme-gitea-light.css |  2 -
 web_src/js/components/ContextPopup.vue   | 24 ++++------
 web_src/js/features/repo-projects.js     | 61 ++++++++----------------
 web_src/js/utils/color.js                | 30 ++++++------
 web_src/js/utils/color.test.js           | 39 +++++++--------
 14 files changed, 136 insertions(+), 195 deletions(-)

diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index c9799a38ec..3558dcf94c 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -53,13 +53,13 @@ func NewFuncMap() template.FuncMap {
 		"JsonUtils":   NewJsonUtils,
 
 		// -----------------------------------------------------------------
-		// svg / avatar / icon
+		// svg / avatar / icon / color
 		"svg":           svg.RenderHTML,
 		"EntryIcon":     base.EntryIcon,
 		"MigrationIcon": MigrationIcon,
 		"ActionIcon":    ActionIcon,
-
-		"SortArrow": SortArrow,
+		"SortArrow":     SortArrow,
+		"ContrastColor": util.ContrastColor,
 
 		// -----------------------------------------------------------------
 		// time / number / format
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 5446741287..c4c5376afd 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -135,16 +135,9 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
 func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
 	var (
 		archivedCSSClass string
-		textColor        = "#111"
+		textColor        = util.ContrastColor(label.Color)
 		labelScope       = label.ExclusiveScope()
 	)
-	r, g, b := util.HexToRBGColor(label.Color)
-
-	// Determine if label text should be light or dark to be readable on background color
-	// this doesn't account for saturation or transparency
-	if util.UseLightTextOnBackground(r, g, b) {
-		textColor = "#eee"
-	}
 
 	description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
 
@@ -168,7 +161,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
 
 	// Make scope and item background colors slightly darker and lighter respectively.
 	// More contrast needed with higher luminance, empirically tweaked.
-	luminance := util.GetLuminance(r, g, b)
+	luminance := util.GetRelativeLuminance(label.Color)
 	contrast := 0.01 + luminance*0.03
 	// Ensure we add the same amount of contrast also near 0 and 1.
 	darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
@@ -178,6 +171,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
 	lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
 
 	opacity := GetLabelOpacityByte(label.IsArchived())
+	r, g, b := util.HexToRBGColor(label.Color)
 	scopeBytes := []byte{
 		uint8(math.Min(math.Round(r*darkenFactor), 255)),
 		uint8(math.Min(math.Round(g*darkenFactor), 255)),
diff --git a/modules/util/color.go b/modules/util/color.go
index 240b045c28..9c520dce78 100644
--- a/modules/util/color.go
+++ b/modules/util/color.go
@@ -4,22 +4,10 @@ package util
 
 import (
 	"fmt"
-	"math"
 	"strconv"
 	"strings"
 )
 
-// Check similar implementation in web_src/js/utils/color.js and keep synchronization
-
-// Return R, G, B values defined in reletive luminance
-func getLuminanceRGB(channel float64) float64 {
-	sRGB := channel / 255
-	if sRGB <= 0.03928 {
-		return sRGB / 12.92
-	}
-	return math.Pow((sRGB+0.055)/1.055, 2.4)
-}
-
 // Get color as RGB values in 0..255 range from the hex color string (with or without #)
 func HexToRBGColor(colorString string) (float64, float64, float64) {
 	hexString := colorString
@@ -47,19 +35,23 @@ func HexToRBGColor(colorString string) (float64, float64, float64) {
 	return r, g, b
 }
 
-// return luminance given RGB channels
-// Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance
-func GetLuminance(r, g, b float64) float64 {
-	R := getLuminanceRGB(r)
-	G := getLuminanceRGB(g)
-	B := getLuminanceRGB(b)
-	luminance := 0.2126*R + 0.7152*G + 0.0722*B
-	return luminance
+// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
+// Keep this in sync with web_src/js/utils/color.js
+func GetRelativeLuminance(color string) float64 {
+	r, g, b := HexToRBGColor(color)
+	return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255
 }
 
-// Reference from: https://firsching.ch/github_labels.html
-// In the future WCAG 3 APCA may be a better solution.
-// Check if text should use light color based on RGB of background
-func UseLightTextOnBackground(r, g, b float64) bool {
-	return GetLuminance(r, g, b) < 0.453
+func UseLightText(backgroundColor string) bool {
+	return GetRelativeLuminance(backgroundColor) < 0.453
+}
+
+// Given a background color, returns a black or white foreground color that the highest
+// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
+// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
+func ContrastColor(backgroundColor string) string {
+	if UseLightText(backgroundColor) {
+		return "#fff"
+	}
+	return "#000"
 }
diff --git a/modules/util/color_test.go b/modules/util/color_test.go
index d96ac36730..be6e6b122a 100644
--- a/modules/util/color_test.go
+++ b/modules/util/color_test.go
@@ -33,33 +33,31 @@ func Test_HexToRBGColor(t *testing.T) {
 	}
 }
 
-func Test_UseLightTextOnBackground(t *testing.T) {
+func Test_UseLightText(t *testing.T) {
 	cases := []struct {
-		r        float64
-		g        float64
-		b        float64
-		expected bool
+		color    string
+		expected string
 	}{
-		{215, 58, 74, true},
-		{0, 117, 202, true},
-		{207, 211, 215, false},
-		{162, 238, 239, false},
-		{112, 87, 255, true},
-		{0, 134, 114, true},
-		{228, 230, 105, false},
-		{216, 118, 227, true},
-		{255, 255, 255, false},
-		{43, 134, 133, true},
-		{43, 135, 134, true},
-		{44, 135, 134, true},
-		{59, 182, 179, true},
-		{124, 114, 104, true},
-		{126, 113, 108, true},
-		{129, 112, 109, true},
-		{128, 112, 112, true},
+		{"#d73a4a", "#fff"},
+		{"#0075ca", "#fff"},
+		{"#cfd3d7", "#000"},
+		{"#a2eeef", "#000"},
+		{"#7057ff", "#fff"},
+		{"#008672", "#fff"},
+		{"#e4e669", "#000"},
+		{"#d876e3", "#000"},
+		{"#ffffff", "#000"},
+		{"#2b8684", "#fff"},
+		{"#2b8786", "#fff"},
+		{"#2c8786", "#000"},
+		{"#3bb6b3", "#000"},
+		{"#7c7268", "#fff"},
+		{"#7e716c", "#fff"},
+		{"#81706d", "#fff"},
+		{"#807070", "#fff"},
+		{"#84b6eb", "#000"},
 	}
 	for n, c := range cases {
-		result := UseLightTextOnBackground(c.r, c.g, c.b)
-		assert.Equal(t, c.expected, result, "case %d: error should match", n)
+		assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n)
 	}
 }
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index d89750862e..861c7ef5a9 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -66,13 +66,13 @@
 <div id="project-board">
 	<div class="board {{if .CanWriteProjects}}sortable{{end}}">
 		{{range .Columns}}
-			<div class="ui segment project-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
+			<div class="ui segment project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
 				<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
 					<div class="ui large label project-column-title tw-py-1">
 						<div class="ui small circular grey label project-column-issue-count">
 							{{.NumIssues ctx}}
 						</div>
-						{{.Title}}
+						<span class="project-column-title-label">{{.Title}}</span>
 					</div>
 					{{if $canWriteProject}}
 						<div class="ui dropdown jump item">
@@ -153,9 +153,7 @@
 						</div>
 					{{end}}
 				</div>
-
-				<div class="divider"></div>
-
+				<div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div>
 				<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
 					{{range (index $.IssuesMap .ID)}}
 						<div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}">
diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css
index 30df994c38..ff2a65e03a 100644
--- a/web_src/css/features/projects.css
+++ b/web_src/css/features/projects.css
@@ -22,34 +22,27 @@
   cursor: default;
 }
 
+.project-column .issue-card {
+  color: var(--color-text);
+}
+
 .project-column-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
 }
 
-.project-column-header.dark-label {
-  color: var(--color-project-board-dark-label) !important;
-}
-
-.project-column-header.dark-label .project-column-title {
-  color: var(--color-project-board-dark-label) !important;
-}
-
-.project-column-header.light-label {
-  color: var(--color-project-board-light-label) !important;
-}
-
-.project-column-header.light-label .project-column-title {
-  color: var(--color-project-board-light-label) !important;
-}
-
 .project-column-title {
   background: none !important;
   line-height: 1.25 !important;
   cursor: inherit;
 }
 
+.project-column-title,
+.project-column-issue-count {
+  color: inherit !important;
+}
+
 .project-column > .cards {
   flex: 1;
   display: flex;
@@ -64,6 +57,8 @@
 
 .project-column > .divider {
   margin: 5px 0;
+  border-color: currentcolor;
+  opacity: .5;
 }
 
 .project-column:first-child {
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 3c43c0b079..3c65a0500e 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -2461,8 +2461,21 @@ td .commit-summary {
   height: 0.5em;
 }
 
+.labels-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.25em;
+}
+
+.labels-list a {
+  display: flex;
+  text-decoration: none;
+}
+
 .labels-list .label {
-  margin: 2px 0;
+  padding: 0 6px;
+  margin: 0 !important;
+  min-height: 20px;
   display: inline-flex !important;
   line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */
 }
diff --git a/web_src/css/repo/issue-list.css b/web_src/css/repo/issue-list.css
index d19421fcbc..37090f71b4 100644
--- a/web_src/css/repo/issue-list.css
+++ b/web_src/css/repo/issue-list.css
@@ -69,23 +69,6 @@
   }
 }
 
-#issue-list .flex-item-title .labels-list {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 0.25em;
-}
-
-#issue-list .flex-item-title .labels-list a {
-  display: flex;
-  text-decoration: none;
-}
-
-#issue-list .flex-item-title .labels-list .label {
-  padding: 0 6px;
-  margin: 0;
-  min-height: 20px;
-}
-
 #issue-list .flex-item-body .branches {
   display: inline-flex;
 }
diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css
index ed6718e40c..c74f334c2d 100644
--- a/web_src/css/themes/theme-gitea-dark.css
+++ b/web_src/css/themes/theme-gitea-dark.css
@@ -215,8 +215,6 @@
   --color-placeholder-text: var(--color-text-light-3);
   --color-editor-line-highlight: var(--color-primary-light-5);
   --color-project-board-bg: var(--color-secondary-light-2);
-  --color-project-board-dark-label: #0e1011;
-  --color-project-board-light-label: #dde0e2;
   --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */
   --color-reaction-bg: #e8e8ff12;
   --color-reaction-hover-bg: var(--color-primary-light-4);
diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css
index b10ad7d840..01dd8ba4f7 100644
--- a/web_src/css/themes/theme-gitea-light.css
+++ b/web_src/css/themes/theme-gitea-light.css
@@ -215,8 +215,6 @@
   --color-placeholder-text: var(--color-text-light-3);
   --color-editor-line-highlight: var(--color-primary-light-6);
   --color-project-board-bg: var(--color-secondary-light-4);
-  --color-project-board-dark-label: #0e1114;
-  --color-project-board-light-label: #eaeef2;
   --color-caret: var(--color-text-dark);
   --color-reaction-bg: #0000170a;
   --color-reaction-hover-bg: var(--color-primary-light-5);
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index ac6a8f3bb6..70b12dcb28 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -1,7 +1,6 @@
 <script>
 import {SvgIcon} from '../svg.js';
-import {useLightTextOnBackground} from '../utils/color.js';
-import tinycolor from 'tinycolor2';
+import {contrastColor} from '../utils/color.js';
 import {GET} from '../modules/fetch.js';
 import {emojiHTML} from '../features/emoji.js';
 import {htmlEscape} from 'escape-goat';
@@ -61,20 +60,13 @@ export default {
     },
 
     labels() {
-      return this.issue.labels.map((label) => {
-        let textColor;
-        const {r, g, b} = tinycolor(label.color).toRgb();
-        if (useLightTextOnBackground(r, g, b)) {
-          textColor = '#eeeeee';
-        } else {
-          textColor = '#111111';
-        }
-        label.name = htmlEscape(label.name);
-        label.name = label.name.replaceAll(/:[-+\w]+:/g, (emoji) => {
+      return this.issue.labels.map((label) => ({
+        name: htmlEscape(label.name).replaceAll(/:[-+\w]+:/g, (emoji) => {
           return emojiHTML(emoji.substring(1, emoji.length - 1));
-        });
-        return {name: label.name, color: `#${label.color}`, textColor};
-      });
+        }),
+        color: `#${label.color}`,
+        textColor: contrastColor(`#${label.color}`),
+      }));
     },
   },
   mounted() {
@@ -114,7 +106,7 @@ export default {
       <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
       <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
       <p>{{ body }}</p>
-      <div>
+      <div class="labels-list">
         <!-- eslint-disable-next-line vue/no-v-html -->
         <div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/>
       </div>
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 80e945a0f2..a869c24c82 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -1,8 +1,8 @@
 import $ from 'jquery';
-import {useLightTextOnBackground} from '../utils/color.js';
-import tinycolor from 'tinycolor2';
+import {contrastColor} from '../utils/color.js';
 import {createSortable} from '../modules/sortable.js';
 import {POST, DELETE, PUT} from '../modules/fetch.js';
+import tinycolor from 'tinycolor2';
 
 function updateIssueCount(cards) {
   const parent = cards.parentElement;
@@ -65,14 +65,11 @@ async function initRepoProjectSortable() {
       boardColumns = mainBoard.getElementsByClassName('project-column');
       for (let i = 0; i < boardColumns.length; i++) {
         const column = boardColumns[i];
-        if (parseInt($(column).data('sorting')) !== i) {
+        if (parseInt(column.getAttribute('data-sorting')) !== i) {
           try {
-            await PUT($(column).data('url'), {
-              data: {
-                sorting: i,
-                color: rgbToHex(window.getComputedStyle($(column)[0]).backgroundColor),
-              },
-            });
+            const bgColor = column.style.backgroundColor; // will be rgb() string
+            const color = bgColor ? tinycolor(bgColor).toHexString() : '';
+            await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}});
           } catch (error) {
             console.error(error);
           }
@@ -102,16 +99,10 @@ export function initRepoProject() {
 
   for (const modal of document.getElementsByClassName('edit-project-column-modal')) {
     const projectHeader = modal.closest('.project-column-header');
-    const projectTitleLabel = projectHeader?.querySelector('.project-column-title');
+    const projectTitleLabel = projectHeader?.querySelector('.project-column-title-label');
     const projectTitleInput = modal.querySelector('.project-column-title-input');
     const projectColorInput = modal.querySelector('#new_project_column_color');
     const boardColumn = modal.closest('.project-column');
-    const bgColor = boardColumn?.style.backgroundColor;
-
-    if (bgColor) {
-      setLabelColor(projectHeader, rgbToHex(bgColor));
-    }
-
     modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) {
       e.preventDefault();
       try {
@@ -126,10 +117,21 @@ export function initRepoProject() {
       } finally {
         projectTitleLabel.textContent = projectTitleInput?.value;
         projectTitleInput.closest('form')?.classList.remove('dirty');
-        if (projectColorInput?.value) {
-          setLabelColor(projectHeader, projectColorInput.value);
+        const dividers = boardColumn.querySelectorAll(':scope > .divider');
+        if (projectColorInput.value) {
+          const color = contrastColor(projectColorInput.value);
+          boardColumn.style.setProperty('background', projectColorInput.value, 'important');
+          boardColumn.style.setProperty('color', color, 'important');
+          for (const divider of dividers) {
+            divider.style.setProperty('color', color);
+          }
+        } else {
+          boardColumn.style.removeProperty('background');
+          boardColumn.style.removeProperty('color');
+          for (const divider of dividers) {
+            divider.style.removeProperty('color');
+          }
         }
-        boardColumn.style = `background: ${projectColorInput.value} !important`;
         $('.ui.modal').modal('hide');
       }
     });
@@ -182,24 +184,3 @@ export function initRepoProject() {
     createNewColumn(url, $columnTitle, $projectColorInput);
   });
 }
-
-function setLabelColor(label, color) {
-  const {r, g, b} = tinycolor(color).toRgb();
-  if (useLightTextOnBackground(r, g, b)) {
-    label.classList.remove('dark-label');
-    label.classList.add('light-label');
-  } else {
-    label.classList.remove('light-label');
-    label.classList.add('dark-label');
-  }
-}
-
-function rgbToHex(rgb) {
-  rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+).*\)$/);
-  return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
-}
-
-function hex(x) {
-  const hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
-  return Number.isNaN(x) ? '00' : hexDigits[(x - x % 16) / 16] + hexDigits[x % 16];
-}
diff --git a/web_src/js/utils/color.js b/web_src/js/utils/color.js
index 0ba6af49ee..198f97c454 100644
--- a/web_src/js/utils/color.js
+++ b/web_src/js/utils/color.js
@@ -1,23 +1,21 @@
-// Check similar implementation in modules/util/color.go and keep synchronization
-// Return R, G, B values defined in reletive luminance
-function getLuminanceRGB(channel) {
-  const sRGB = channel / 255;
-  return (sRGB <= 0.03928) ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4;
+import tinycolor from 'tinycolor2';
+
+// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
+// Keep this in sync with modules/util/color.go
+function getRelativeLuminance(color) {
+  const {r, g, b} = tinycolor(color).toRgb();
+  return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
 }
 
-// Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance
-function getLuminance(r, g, b) {
-  const R = getLuminanceRGB(r);
-  const G = getLuminanceRGB(g);
-  const B = getLuminanceRGB(b);
-  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
+function useLightText(backgroundColor) {
+  return getRelativeLuminance(backgroundColor) < 0.453;
 }
 
-// Reference from: https://firsching.ch/github_labels.html
-// In the future WCAG 3 APCA may be a better solution.
-// Check if text should use light color based on RGB of background
-export function useLightTextOnBackground(r, g, b) {
-  return getLuminance(r, g, b) < 0.453;
+// Given a background color, returns a black or white foreground color that the highest
+// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
+// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
+export function contrastColor(backgroundColor) {
+  return useLightText(backgroundColor) ? '#fff' : '#000';
 }
 
 function resolveColors(obj) {
diff --git a/web_src/js/utils/color.test.js b/web_src/js/utils/color.test.js
index e129109ef0..fee9afc776 100644
--- a/web_src/js/utils/color.test.js
+++ b/web_src/js/utils/color.test.js
@@ -1,21 +1,22 @@
-import {useLightTextOnBackground} from './color.js';
+import {contrastColor} from './color.js';
 
-test('useLightTextOnBackground', () => {
-  expect(useLightTextOnBackground(215, 58, 74)).toBe(true);
-  expect(useLightTextOnBackground(0, 117, 202)).toBe(true);
-  expect(useLightTextOnBackground(207, 211, 215)).toBe(false);
-  expect(useLightTextOnBackground(162, 238, 239)).toBe(false);
-  expect(useLightTextOnBackground(112, 87, 255)).toBe(true);
-  expect(useLightTextOnBackground(0, 134, 114)).toBe(true);
-  expect(useLightTextOnBackground(228, 230, 105)).toBe(false);
-  expect(useLightTextOnBackground(216, 118, 227)).toBe(true);
-  expect(useLightTextOnBackground(255, 255, 255)).toBe(false);
-  expect(useLightTextOnBackground(43, 134, 133)).toBe(true);
-  expect(useLightTextOnBackground(43, 135, 134)).toBe(true);
-  expect(useLightTextOnBackground(44, 135, 134)).toBe(true);
-  expect(useLightTextOnBackground(59, 182, 179)).toBe(true);
-  expect(useLightTextOnBackground(124, 114, 104)).toBe(true);
-  expect(useLightTextOnBackground(126, 113, 108)).toBe(true);
-  expect(useLightTextOnBackground(129, 112, 109)).toBe(true);
-  expect(useLightTextOnBackground(128, 112, 112)).toBe(true);
+test('contrastColor', () => {
+  expect(contrastColor('#d73a4a')).toBe('#fff');
+  expect(contrastColor('#0075ca')).toBe('#fff');
+  expect(contrastColor('#cfd3d7')).toBe('#000');
+  expect(contrastColor('#a2eeef')).toBe('#000');
+  expect(contrastColor('#7057ff')).toBe('#fff');
+  expect(contrastColor('#008672')).toBe('#fff');
+  expect(contrastColor('#e4e669')).toBe('#000');
+  expect(contrastColor('#d876e3')).toBe('#000');
+  expect(contrastColor('#ffffff')).toBe('#000');
+  expect(contrastColor('#2b8684')).toBe('#fff');
+  expect(contrastColor('#2b8786')).toBe('#fff');
+  expect(contrastColor('#2c8786')).toBe('#000');
+  expect(contrastColor('#3bb6b3')).toBe('#000');
+  expect(contrastColor('#7c7268')).toBe('#fff');
+  expect(contrastColor('#7e716c')).toBe('#fff');
+  expect(contrastColor('#81706d')).toBe('#fff');
+  expect(contrastColor('#807070')).toBe('#fff');
+  expect(contrastColor('#84b6eb')).toBe('#000');
 });