]> source.dussan.org Git - gitea.git/commitdiff
Fix and rewrite contrast color calculation, fix project-related bugs (#30237)
authorsilverwind <me@silverwind.io>
Sun, 7 Apr 2024 16:19:25 +0000 (18:19 +0200)
committerGitHub <noreply@github.com>
Sun, 7 Apr 2024 16:19:25 +0000 (16:19 +0000)
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.

@HesterG fyi

---------

Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: Giteabot <teabot@gitea.io>
14 files changed:
modules/templates/helper.go
modules/templates/util_render.go
modules/util/color.go
modules/util/color_test.go
templates/projects/view.tmpl
web_src/css/features/projects.css
web_src/css/repo.css
web_src/css/repo/issue-list.css
web_src/css/themes/theme-gitea-dark.css
web_src/css/themes/theme-gitea-light.css
web_src/js/components/ContextPopup.vue
web_src/js/features/repo-projects.js
web_src/js/utils/color.js
web_src/js/utils/color.test.js

index 9e770a26065b916f6f22dc92cb3e048086b06439..5d2fa79bc524ccd16b9a87df9e2964ff2ebd728f 100644 (file)
@@ -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
index d1c9b082fab00f47f15e01f8d969992865989503..0b53965f258050da21c3b9b43a71f6093a21abab 100644 (file)
@@ -123,16 +123,10 @@ 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
-       if util.UseLightTextOnBackground(r, g, b) {
-               textColor = "#eee"
-       }
-
        description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
 
        if label.IsArchived() {
@@ -153,7 +147,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)
@@ -162,6 +156,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
        darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
        lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
 
+       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)),
index 240b045c287a989d2878ae93d5e37d0c4857cbaf..9c520dce786e2cede2eda5d71524db6c6ddbcdc9 100644 (file)
@@ -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"
 }
index d96ac36730ab6fba70b9b301b685c1f9367872b2..be6e6b122a51587b22519685f537a6b643b345f4 100644 (file)
@@ -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)
        }
 }
index 33dd758c79ee11a43539f5a12dd105e40810aa46..f9b85360e0e3a8a459fc5dfe9ac0a6cb045c5309 100644 (file)
 <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">
                                                </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}}">
index cec5e6fc6433e504b69ac51697dad1ff7ea6c91f..e23c14674897bc9f68318512e0977dbfc57e408b 100644 (file)
   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 {
index 653af379d5f0da97a83a8126fbdc5e86d65b4807..c50d13a1742b4ea98cab87ac48891fafd2e5bd20 100644 (file)
   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 */
 }
index fe8231d7182f405e08c6b7acdf4307113e81513d..77905956f0e574335785dfc7a1f41320f14d34d8 100644 (file)
   }
 }
 
-#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;
 }
index ed6718e40c9538f2e86e7f98188b8b4dabda87ba..c74f334c2dfc3bee2191ee16b6a1ddbebb831622 100644 (file)
   --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);
index b10ad7d840d85f86e7a36f6f2bf9564ed6c98b66..01dd8ba4f7b7dae477af1d31ada51153b9a495bd 100644 (file)
   --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);
index d87eb1a180ea71c53f97288bee16f0ebc240dde7..65a6089522184c2f9e6147801ee4f273598bc339 100644 (file)
@@ -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';
 
 const {appSubUrl, i18n} = window.config;
@@ -59,16 +58,11 @@ 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';
-        }
-        return {name: label.name, color: `#${label.color}`, textColor};
-      });
+      return this.issue.labels.map((label) => ({
+        name: label.name,
+        color: `#${label.color}`,
+        textColor: contrastColor(`#${label.color}`),
+      }));
     },
   },
   mounted() {
@@ -108,7 +102,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">
         <div
           v-for="label in labels"
           :key="label.name"
index 80e945a0f2fa7543cb00c1a3eeaee89f63ddf624..a869c24c823adb4879ab8656c2a019eb278b86ee 100644 (file)
@@ -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];
-}
index 0ba6af49ee6e45678a85061bc8f9b0497ca8f746..198f97c454e98d880307118111e72922468bcdd4 100644 (file)
@@ -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) {
index e129109ef042946b76a78bc299fe686c8638c3dd..fee9afc776cbd0894adf719722058754bc78417b 100644 (file)
@@ -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');
 });