Fix and rewrite contrast color calculation, fix project-related bugs (#30237)

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>
This commit is contained in:
silverwind 2024-04-07 18:19:25 +02:00 committed by GitHub
parent 019857a701
commit 36887ed392
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 135 additions and 191 deletions

View File

@ -53,13 +53,13 @@ func NewFuncMap() template.FuncMap {
"JsonUtils": NewJsonUtils, "JsonUtils": NewJsonUtils,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// svg / avatar / icon // svg / avatar / icon / color
"svg": svg.RenderHTML, "svg": svg.RenderHTML,
"EntryIcon": base.EntryIcon, "EntryIcon": base.EntryIcon,
"MigrationIcon": MigrationIcon, "MigrationIcon": MigrationIcon,
"ActionIcon": ActionIcon, "ActionIcon": ActionIcon,
"SortArrow": SortArrow,
"SortArrow": SortArrow, "ContrastColor": util.ContrastColor,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// time / number / format // time / number / format

View 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 { func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
var ( var (
archivedCSSClass string archivedCSSClass string
textColor = "#111" textColor = util.ContrastColor(label.Color)
labelScope = label.ExclusiveScope() 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)) description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
if label.IsArchived() { 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. // Make scope and item background colors slightly darker and lighter respectively.
// More contrast needed with higher luminance, empirically tweaked. // 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 contrast := 0.01 + luminance*0.03
// Ensure we add the same amount of contrast also near 0 and 1. // Ensure we add the same amount of contrast also near 0 and 1.
darken := contrast + math.Max(luminance+contrast-1.0, 0.0) 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) 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) lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
r, g, b := util.HexToRBGColor(label.Color)
scopeBytes := []byte{ scopeBytes := []byte{
uint8(math.Min(math.Round(r*darkenFactor), 255)), uint8(math.Min(math.Round(r*darkenFactor), 255)),
uint8(math.Min(math.Round(g*darkenFactor), 255)), uint8(math.Min(math.Round(g*darkenFactor), 255)),

View File

@ -4,22 +4,10 @@ package util
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "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 #) // Get color as RGB values in 0..255 range from the hex color string (with or without #)
func HexToRBGColor(colorString string) (float64, float64, float64) { func HexToRBGColor(colorString string) (float64, float64, float64) {
hexString := colorString hexString := colorString
@ -47,19 +35,23 @@ func HexToRBGColor(colorString string) (float64, float64, float64) {
return r, g, b return r, g, b
} }
// return luminance given RGB channels // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
// Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance // Keep this in sync with web_src/js/utils/color.js
func GetLuminance(r, g, b float64) float64 { func GetRelativeLuminance(color string) float64 {
R := getLuminanceRGB(r) r, g, b := HexToRBGColor(color)
G := getLuminanceRGB(g) return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255
B := getLuminanceRGB(b)
luminance := 0.2126*R + 0.7152*G + 0.0722*B
return luminance
} }
// Reference from: https://firsching.ch/github_labels.html func UseLightText(backgroundColor string) bool {
// In the future WCAG 3 APCA may be a better solution. return GetRelativeLuminance(backgroundColor) < 0.453
// 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 // 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"
} }

View 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 { cases := []struct {
r float64 color string
g float64 expected string
b float64
expected bool
}{ }{
{215, 58, 74, true}, {"#d73a4a", "#fff"},
{0, 117, 202, true}, {"#0075ca", "#fff"},
{207, 211, 215, false}, {"#cfd3d7", "#000"},
{162, 238, 239, false}, {"#a2eeef", "#000"},
{112, 87, 255, true}, {"#7057ff", "#fff"},
{0, 134, 114, true}, {"#008672", "#fff"},
{228, 230, 105, false}, {"#e4e669", "#000"},
{216, 118, 227, true}, {"#d876e3", "#000"},
{255, 255, 255, false}, {"#ffffff", "#000"},
{43, 134, 133, true}, {"#2b8684", "#fff"},
{43, 135, 134, true}, {"#2b8786", "#fff"},
{44, 135, 134, true}, {"#2c8786", "#000"},
{59, 182, 179, true}, {"#3bb6b3", "#000"},
{124, 114, 104, true}, {"#7c7268", "#fff"},
{126, 113, 108, true}, {"#7e716c", "#fff"},
{129, 112, 109, true}, {"#81706d", "#fff"},
{128, 112, 112, true}, {"#807070", "#fff"},
{"#84b6eb", "#000"},
} }
for n, c := range cases { for n, c := range cases {
result := UseLightTextOnBackground(c.r, c.g, c.b) assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n)
assert.Equal(t, c.expected, result, "case %d: error should match", n)
} }
} }

View File

@ -66,13 +66,13 @@
<div id="project-board"> <div id="project-board">
<div class="board {{if .CanWriteProjects}}sortable{{end}}"> <div class="board {{if .CanWriteProjects}}sortable{{end}}">
{{range .Columns}} {{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="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
<div class="ui large label project-column-title tw-py-1"> <div class="ui large label project-column-title tw-py-1">
<div class="ui small circular grey label project-column-issue-count"> <div class="ui small circular grey label project-column-issue-count">
{{.NumIssues ctx}} {{.NumIssues ctx}}
</div> </div>
{{.Title}} <span class="project-column-title-label">{{.Title}}</span>
</div> </div>
{{if $canWriteProject}} {{if $canWriteProject}}
<div class="ui dropdown jump item"> <div class="ui dropdown jump item">
@ -153,9 +153,7 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div>
<div class="divider"></div>
<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> <div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
{{range (index $.IssuesMap .ID)}} {{range (index $.IssuesMap .ID)}}
<div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"> <div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}">

View File

@ -22,34 +22,27 @@
cursor: default; cursor: default;
} }
.project-column .issue-card {
color: var(--color-text);
}
.project-column-header { .project-column-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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 { .project-column-title {
background: none !important; background: none !important;
line-height: 1.25 !important; line-height: 1.25 !important;
cursor: inherit; cursor: inherit;
} }
.project-column-title,
.project-column-issue-count {
color: inherit !important;
}
.project-column > .cards { .project-column > .cards {
flex: 1; flex: 1;
display: flex; display: flex;
@ -64,6 +57,8 @@
.project-column > .divider { .project-column > .divider {
margin: 5px 0; margin: 5px 0;
border-color: currentcolor;
opacity: .5;
} }
.project-column:first-child { .project-column:first-child {

View File

@ -2273,8 +2273,21 @@
height: 0.5em; height: 0.5em;
} }
.labels-list {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
}
.labels-list a {
display: flex;
text-decoration: none;
}
.labels-list .label { .labels-list .label {
margin: 2px 0; padding: 0 6px;
margin: 0 !important;
min-height: 20px;
display: inline-flex !important; 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 */ line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */
} }

View File

@ -34,23 +34,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 { #issue-list .flex-item-body .branches {
display: inline-flex; display: inline-flex;
} }

View File

@ -215,8 +215,6 @@
--color-placeholder-text: var(--color-text-light-3); --color-placeholder-text: var(--color-text-light-3);
--color-editor-line-highlight: var(--color-primary-light-5); --color-editor-line-highlight: var(--color-primary-light-5);
--color-project-board-bg: var(--color-secondary-light-2); --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-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */
--color-reaction-bg: #e8e8ff12; --color-reaction-bg: #e8e8ff12;
--color-reaction-hover-bg: var(--color-primary-light-4); --color-reaction-hover-bg: var(--color-primary-light-4);

View File

@ -215,8 +215,6 @@
--color-placeholder-text: var(--color-text-light-3); --color-placeholder-text: var(--color-text-light-3);
--color-editor-line-highlight: var(--color-primary-light-6); --color-editor-line-highlight: var(--color-primary-light-6);
--color-project-board-bg: var(--color-secondary-light-4); --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-caret: var(--color-text-dark);
--color-reaction-bg: #0000170a; --color-reaction-bg: #0000170a;
--color-reaction-hover-bg: var(--color-primary-light-5); --color-reaction-hover-bg: var(--color-primary-light-5);

View File

@ -1,7 +1,6 @@
<script> <script>
import {SvgIcon} from '../svg.js'; import {SvgIcon} from '../svg.js';
import {useLightTextOnBackground} from '../utils/color.js'; import {contrastColor} from '../utils/color.js';
import tinycolor from 'tinycolor2';
import {GET} from '../modules/fetch.js'; import {GET} from '../modules/fetch.js';
const {appSubUrl, i18n} = window.config; const {appSubUrl, i18n} = window.config;
@ -59,16 +58,11 @@ export default {
}, },
labels() { labels() {
return this.issue.labels.map((label) => { return this.issue.labels.map((label) => ({
let textColor; name: label.name,
const {r, g, b} = tinycolor(label.color).toRgb(); color: `#${label.color}`,
if (useLightTextOnBackground(r, g, b)) { textColor: contrastColor(`#${label.color}`),
textColor = '#eeeeee'; }));
} else {
textColor = '#111111';
}
return {name: label.name, color: `#${label.color}`, textColor};
});
}, },
}, },
mounted() { mounted() {
@ -108,7 +102,7 @@ export default {
<p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p> <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><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
<p>{{ body }}</p> <p>{{ body }}</p>
<div> <div class="labels-list">
<div <div
v-for="label in labels" v-for="label in labels"
:key="label.name" :key="label.name"

View File

@ -1,8 +1,8 @@
import $ from 'jquery'; import $ from 'jquery';
import {useLightTextOnBackground} from '../utils/color.js'; import {contrastColor} from '../utils/color.js';
import tinycolor from 'tinycolor2';
import {createSortable} from '../modules/sortable.js'; import {createSortable} from '../modules/sortable.js';
import {POST, DELETE, PUT} from '../modules/fetch.js'; import {POST, DELETE, PUT} from '../modules/fetch.js';
import tinycolor from 'tinycolor2';
function updateIssueCount(cards) { function updateIssueCount(cards) {
const parent = cards.parentElement; const parent = cards.parentElement;
@ -65,14 +65,11 @@ async function initRepoProjectSortable() {
boardColumns = mainBoard.getElementsByClassName('project-column'); boardColumns = mainBoard.getElementsByClassName('project-column');
for (let i = 0; i < boardColumns.length; i++) { for (let i = 0; i < boardColumns.length; i++) {
const column = boardColumns[i]; const column = boardColumns[i];
if (parseInt($(column).data('sorting')) !== i) { if (parseInt(column.getAttribute('data-sorting')) !== i) {
try { try {
await PUT($(column).data('url'), { const bgColor = column.style.backgroundColor; // will be rgb() string
data: { const color = bgColor ? tinycolor(bgColor).toHexString() : '';
sorting: i, await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}});
color: rgbToHex(window.getComputedStyle($(column)[0]).backgroundColor),
},
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -102,16 +99,10 @@ export function initRepoProject() {
for (const modal of document.getElementsByClassName('edit-project-column-modal')) { for (const modal of document.getElementsByClassName('edit-project-column-modal')) {
const projectHeader = modal.closest('.project-column-header'); 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 projectTitleInput = modal.querySelector('.project-column-title-input');
const projectColorInput = modal.querySelector('#new_project_column_color'); const projectColorInput = modal.querySelector('#new_project_column_color');
const boardColumn = modal.closest('.project-column'); 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) { modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) {
e.preventDefault(); e.preventDefault();
try { try {
@ -126,10 +117,21 @@ export function initRepoProject() {
} finally { } finally {
projectTitleLabel.textContent = projectTitleInput?.value; projectTitleLabel.textContent = projectTitleInput?.value;
projectTitleInput.closest('form')?.classList.remove('dirty'); projectTitleInput.closest('form')?.classList.remove('dirty');
if (projectColorInput?.value) { const dividers = boardColumn.querySelectorAll(':scope > .divider');
setLabelColor(projectHeader, projectColorInput.value); 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'); $('.ui.modal').modal('hide');
} }
}); });
@ -182,24 +184,3 @@ export function initRepoProject() {
createNewColumn(url, $columnTitle, $projectColorInput); 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];
}

View File

@ -1,23 +1,21 @@
// Check similar implementation in modules/util/color.go and keep synchronization import tinycolor from 'tinycolor2';
// Return R, G, B values defined in reletive luminance
function getLuminanceRGB(channel) { // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
const sRGB = channel / 255; // Keep this in sync with modules/util/color.go
return (sRGB <= 0.03928) ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4; 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 useLightText(backgroundColor) {
function getLuminance(r, g, b) { return getRelativeLuminance(backgroundColor) < 0.453;
const R = getLuminanceRGB(r);
const G = getLuminanceRGB(g);
const B = getLuminanceRGB(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
} }
// Reference from: https://firsching.ch/github_labels.html // Given a background color, returns a black or white foreground color that the highest
// In the future WCAG 3 APCA may be a better solution. // contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// Check if text should use light color based on RGB of background // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
export function useLightTextOnBackground(r, g, b) { export function contrastColor(backgroundColor) {
return getLuminance(r, g, b) < 0.453; return useLightText(backgroundColor) ? '#fff' : '#000';
} }
function resolveColors(obj) { function resolveColors(obj) {

View File

@ -1,21 +1,22 @@
import {useLightTextOnBackground} from './color.js'; import {contrastColor} from './color.js';
test('useLightTextOnBackground', () => { test('contrastColor', () => {
expect(useLightTextOnBackground(215, 58, 74)).toBe(true); expect(contrastColor('#d73a4a')).toBe('#fff');
expect(useLightTextOnBackground(0, 117, 202)).toBe(true); expect(contrastColor('#0075ca')).toBe('#fff');
expect(useLightTextOnBackground(207, 211, 215)).toBe(false); expect(contrastColor('#cfd3d7')).toBe('#000');
expect(useLightTextOnBackground(162, 238, 239)).toBe(false); expect(contrastColor('#a2eeef')).toBe('#000');
expect(useLightTextOnBackground(112, 87, 255)).toBe(true); expect(contrastColor('#7057ff')).toBe('#fff');
expect(useLightTextOnBackground(0, 134, 114)).toBe(true); expect(contrastColor('#008672')).toBe('#fff');
expect(useLightTextOnBackground(228, 230, 105)).toBe(false); expect(contrastColor('#e4e669')).toBe('#000');
expect(useLightTextOnBackground(216, 118, 227)).toBe(true); expect(contrastColor('#d876e3')).toBe('#000');
expect(useLightTextOnBackground(255, 255, 255)).toBe(false); expect(contrastColor('#ffffff')).toBe('#000');
expect(useLightTextOnBackground(43, 134, 133)).toBe(true); expect(contrastColor('#2b8684')).toBe('#fff');
expect(useLightTextOnBackground(43, 135, 134)).toBe(true); expect(contrastColor('#2b8786')).toBe('#fff');
expect(useLightTextOnBackground(44, 135, 134)).toBe(true); expect(contrastColor('#2c8786')).toBe('#000');
expect(useLightTextOnBackground(59, 182, 179)).toBe(true); expect(contrastColor('#3bb6b3')).toBe('#000');
expect(useLightTextOnBackground(124, 114, 104)).toBe(true); expect(contrastColor('#7c7268')).toBe('#fff');
expect(useLightTextOnBackground(126, 113, 108)).toBe(true); expect(contrastColor('#7e716c')).toBe('#fff');
expect(useLightTextOnBackground(129, 112, 109)).toBe(true); expect(contrastColor('#81706d')).toBe('#fff');
expect(useLightTextOnBackground(128, 112, 112)).toBe(true); expect(contrastColor('#807070')).toBe('#fff');
expect(contrastColor('#84b6eb')).toBe('#000');
}); });