mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 00:54:43 +01:00 
			
		
		
		
	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:
		
							parent
							
								
									019857a701
								
							
						
					
					
						commit
						36887ed392
					
				| @ -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, | ||||
| 		"ContrastColor": util.ContrastColor, | ||||
| 
 | ||||
| 		// ----------------------------------------------------------------- | ||||
| 		// time / number / format | ||||
|  | ||||
| @ -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)), | ||||
|  | ||||
| @ -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" | ||||
| } | ||||
|  | ||||
| @ -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) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -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}}"> | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -2273,8 +2273,21 @@ | ||||
|   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 */ | ||||
| } | ||||
|  | ||||
| @ -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 { | ||||
|   display: inline-flex; | ||||
| } | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -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]; | ||||
| } | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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'); | ||||
| }); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user