mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-30 19:08:37 +00: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:
		| @@ -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'); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user