loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request '[PORT] gitea##30237: Fix and rewrite contrast color calculation, fix project-related bugs' (#3234) from gusted/forgejo-bp-2 into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3234
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>

+136 -195
+3 -3
modules/templates/helper.go
··· 53 53 "JsonUtils": NewJsonUtils, 54 54 55 55 // ----------------------------------------------------------------- 56 - // svg / avatar / icon 56 + // svg / avatar / icon / color 57 57 "svg": svg.RenderHTML, 58 58 "EntryIcon": base.EntryIcon, 59 59 "MigrationIcon": MigrationIcon, 60 60 "ActionIcon": ActionIcon, 61 - 62 - "SortArrow": SortArrow, 61 + "SortArrow": SortArrow, 62 + "ContrastColor": util.ContrastColor, 63 63 64 64 // ----------------------------------------------------------------- 65 65 // time / number / format
+3 -9
modules/templates/util_render.go
··· 135 135 func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { 136 136 var ( 137 137 archivedCSSClass string 138 - textColor = "#111" 138 + textColor = util.ContrastColor(label.Color) 139 139 labelScope = label.ExclusiveScope() 140 140 ) 141 - r, g, b := util.HexToRBGColor(label.Color) 142 - 143 - // Determine if label text should be light or dark to be readable on background color 144 - // this doesn't account for saturation or transparency 145 - if util.UseLightTextOnBackground(r, g, b) { 146 - textColor = "#eee" 147 - } 148 141 149 142 description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) 150 143 ··· 168 161 169 162 // Make scope and item background colors slightly darker and lighter respectively. 170 163 // More contrast needed with higher luminance, empirically tweaked. 171 - luminance := util.GetLuminance(r, g, b) 164 + luminance := util.GetRelativeLuminance(label.Color) 172 165 contrast := 0.01 + luminance*0.03 173 166 // Ensure we add the same amount of contrast also near 0 and 1. 174 167 darken := contrast + math.Max(luminance+contrast-1.0, 0.0) ··· 178 171 lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) 179 172 180 173 opacity := GetLabelOpacityByte(label.IsArchived()) 174 + r, g, b := util.HexToRBGColor(label.Color) 181 175 scopeBytes := []byte{ 182 176 uint8(math.Min(math.Round(r*darkenFactor), 255)), 183 177 uint8(math.Min(math.Round(g*darkenFactor), 255)),
+17 -25
modules/util/color.go
··· 4 4 5 5 import ( 6 6 "fmt" 7 - "math" 8 7 "strconv" 9 8 "strings" 10 9 ) 11 - 12 - // Check similar implementation in web_src/js/utils/color.js and keep synchronization 13 - 14 - // Return R, G, B values defined in reletive luminance 15 - func getLuminanceRGB(channel float64) float64 { 16 - sRGB := channel / 255 17 - if sRGB <= 0.03928 { 18 - return sRGB / 12.92 19 - } 20 - return math.Pow((sRGB+0.055)/1.055, 2.4) 21 - } 22 10 23 11 // Get color as RGB values in 0..255 range from the hex color string (with or without #) 24 12 func HexToRBGColor(colorString string) (float64, float64, float64) { ··· 47 35 return r, g, b 48 36 } 49 37 50 - // return luminance given RGB channels 51 - // Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance 52 - func GetLuminance(r, g, b float64) float64 { 53 - R := getLuminanceRGB(r) 54 - G := getLuminanceRGB(g) 55 - B := getLuminanceRGB(b) 56 - luminance := 0.2126*R + 0.7152*G + 0.0722*B 57 - return luminance 38 + // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance 39 + // Keep this in sync with web_src/js/utils/color.js 40 + func GetRelativeLuminance(color string) float64 { 41 + r, g, b := HexToRBGColor(color) 42 + return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255 58 43 } 59 44 60 - // Reference from: https://firsching.ch/github_labels.html 61 - // In the future WCAG 3 APCA may be a better solution. 62 - // Check if text should use light color based on RGB of background 63 - func UseLightTextOnBackground(r, g, b float64) bool { 64 - return GetLuminance(r, g, b) < 0.453 45 + func UseLightText(backgroundColor string) bool { 46 + return GetRelativeLuminance(backgroundColor) < 0.453 47 + } 48 + 49 + // Given a background color, returns a black or white foreground color that the highest 50 + // contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better. 51 + // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 52 + func ContrastColor(backgroundColor string) string { 53 + if UseLightText(backgroundColor) { 54 + return "#fff" 55 + } 56 + return "#000" 65 57 }
+22 -24
modules/util/color_test.go
··· 33 33 } 34 34 } 35 35 36 - func Test_UseLightTextOnBackground(t *testing.T) { 36 + func Test_UseLightText(t *testing.T) { 37 37 cases := []struct { 38 - r float64 39 - g float64 40 - b float64 41 - expected bool 38 + color string 39 + expected string 42 40 }{ 43 - {215, 58, 74, true}, 44 - {0, 117, 202, true}, 45 - {207, 211, 215, false}, 46 - {162, 238, 239, false}, 47 - {112, 87, 255, true}, 48 - {0, 134, 114, true}, 49 - {228, 230, 105, false}, 50 - {216, 118, 227, true}, 51 - {255, 255, 255, false}, 52 - {43, 134, 133, true}, 53 - {43, 135, 134, true}, 54 - {44, 135, 134, true}, 55 - {59, 182, 179, true}, 56 - {124, 114, 104, true}, 57 - {126, 113, 108, true}, 58 - {129, 112, 109, true}, 59 - {128, 112, 112, true}, 41 + {"#d73a4a", "#fff"}, 42 + {"#0075ca", "#fff"}, 43 + {"#cfd3d7", "#000"}, 44 + {"#a2eeef", "#000"}, 45 + {"#7057ff", "#fff"}, 46 + {"#008672", "#fff"}, 47 + {"#e4e669", "#000"}, 48 + {"#d876e3", "#000"}, 49 + {"#ffffff", "#000"}, 50 + {"#2b8684", "#fff"}, 51 + {"#2b8786", "#fff"}, 52 + {"#2c8786", "#000"}, 53 + {"#3bb6b3", "#000"}, 54 + {"#7c7268", "#fff"}, 55 + {"#7e716c", "#fff"}, 56 + {"#81706d", "#fff"}, 57 + {"#807070", "#fff"}, 58 + {"#84b6eb", "#000"}, 60 59 } 61 60 for n, c := range cases { 62 - result := UseLightTextOnBackground(c.r, c.g, c.b) 63 - assert.Equal(t, c.expected, result, "case %d: error should match", n) 61 + assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n) 64 62 } 65 63 }
+3 -5
templates/projects/view.tmpl
··· 66 66 <div id="project-board"> 67 67 <div class="board {{if .CanWriteProjects}}sortable{{end}}"> 68 68 {{range .Columns}} 69 - <div class="ui segment project-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}"> 69 + <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}}"> 70 70 <div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}"> 71 71 <div class="ui large label project-column-title tw-py-1"> 72 72 <div class="ui small circular grey label project-column-issue-count"> 73 73 {{.NumIssues ctx}} 74 74 </div> 75 - {{.Title}} 75 + <span class="project-column-title-label">{{.Title}}</span> 76 76 </div> 77 77 {{if $canWriteProject}} 78 78 <div class="ui dropdown jump item"> ··· 153 153 </div> 154 154 {{end}} 155 155 </div> 156 - 157 - <div class="divider"></div> 158 - 156 + <div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div> 159 157 <div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> 160 158 {{range (index $.IssuesMap .ID)}} 161 159 <div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}">
+11 -16
web_src/css/features/projects.css
··· 22 22 cursor: default; 23 23 } 24 24 25 + .project-column .issue-card { 26 + color: var(--color-text); 27 + } 28 + 25 29 .project-column-header { 26 30 display: flex; 27 31 align-items: center; 28 32 justify-content: space-between; 29 33 } 30 34 31 - .project-column-header.dark-label { 32 - color: var(--color-project-board-dark-label) !important; 33 - } 34 - 35 - .project-column-header.dark-label .project-column-title { 36 - color: var(--color-project-board-dark-label) !important; 37 - } 38 - 39 - .project-column-header.light-label { 40 - color: var(--color-project-board-light-label) !important; 41 - } 42 - 43 - .project-column-header.light-label .project-column-title { 44 - color: var(--color-project-board-light-label) !important; 45 - } 46 - 47 35 .project-column-title { 48 36 background: none !important; 49 37 line-height: 1.25 !important; 50 38 cursor: inherit; 39 + } 40 + 41 + .project-column-title, 42 + .project-column-issue-count { 43 + color: inherit !important; 51 44 } 52 45 53 46 .project-column > .cards { ··· 64 57 65 58 .project-column > .divider { 66 59 margin: 5px 0; 60 + border-color: currentcolor; 61 + opacity: .5; 67 62 } 68 63 69 64 .project-column:first-child {
+14 -1
web_src/css/repo.css
··· 2312 2312 height: 0.5em; 2313 2313 } 2314 2314 2315 + .labels-list { 2316 + display: flex; 2317 + flex-wrap: wrap; 2318 + gap: 0.25em; 2319 + } 2320 + 2321 + .labels-list a { 2322 + display: flex; 2323 + text-decoration: none; 2324 + } 2325 + 2315 2326 .labels-list .label { 2316 - margin: 2px 0; 2327 + padding: 0 6px; 2328 + margin: 0 !important; 2329 + min-height: 20px; 2317 2330 display: inline-flex !important; 2318 2331 line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */ 2319 2332 }
-17
web_src/css/repo/issue-list.css
··· 69 69 } 70 70 } 71 71 72 - #issue-list .flex-item-title .labels-list { 73 - display: flex; 74 - flex-wrap: wrap; 75 - gap: 0.25em; 76 - } 77 - 78 - #issue-list .flex-item-title .labels-list a { 79 - display: flex; 80 - text-decoration: none; 81 - } 82 - 83 - #issue-list .flex-item-title .labels-list .label { 84 - padding: 0 6px; 85 - margin: 0; 86 - min-height: 20px; 87 - } 88 - 89 72 #issue-list .flex-item-body .branches { 90 73 display: inline-flex; 91 74 }
-2
web_src/css/themes/theme-gitea-dark.css
··· 215 215 --color-placeholder-text: var(--color-text-light-3); 216 216 --color-editor-line-highlight: var(--color-primary-light-5); 217 217 --color-project-board-bg: var(--color-secondary-light-2); 218 - --color-project-board-dark-label: #0e1011; 219 - --color-project-board-light-label: #dde0e2; 220 218 --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */ 221 219 --color-reaction-bg: #e8e8ff12; 222 220 --color-reaction-hover-bg: var(--color-primary-light-4);
-2
web_src/css/themes/theme-gitea-light.css
··· 215 215 --color-placeholder-text: var(--color-text-light-3); 216 216 --color-editor-line-highlight: var(--color-primary-light-6); 217 217 --color-project-board-bg: var(--color-secondary-light-4); 218 - --color-project-board-dark-label: #0e1114; 219 - --color-project-board-light-label: #eaeef2; 220 218 --color-caret: var(--color-text-dark); 221 219 --color-reaction-bg: #0000170a; 222 220 --color-reaction-hover-bg: var(--color-primary-light-5);
+8 -16
web_src/js/components/ContextPopup.vue
··· 1 1 <script> 2 2 import {SvgIcon} from '../svg.js'; 3 - import {useLightTextOnBackground} from '../utils/color.js'; 4 - import tinycolor from 'tinycolor2'; 3 + import {contrastColor} from '../utils/color.js'; 5 4 import {GET} from '../modules/fetch.js'; 6 5 import {emojiHTML} from '../features/emoji.js'; 7 6 import {htmlEscape} from 'escape-goat'; ··· 61 60 }, 62 61 63 62 labels() { 64 - return this.issue.labels.map((label) => { 65 - let textColor; 66 - const {r, g, b} = tinycolor(label.color).toRgb(); 67 - if (useLightTextOnBackground(r, g, b)) { 68 - textColor = '#eeeeee'; 69 - } else { 70 - textColor = '#111111'; 71 - } 72 - label.name = htmlEscape(label.name); 73 - label.name = label.name.replaceAll(/:[-+\w]+:/g, (emoji) => { 63 + return this.issue.labels.map((label) => ({ 64 + name: htmlEscape(label.name).replaceAll(/:[-+\w]+:/g, (emoji) => { 74 65 return emojiHTML(emoji.substring(1, emoji.length - 1)); 75 - }); 76 - return {name: label.name, color: `#${label.color}`, textColor}; 77 - }); 66 + }), 67 + color: `#${label.color}`, 68 + textColor: contrastColor(`#${label.color}`), 69 + })); 78 70 }, 79 71 }, 80 72 mounted() { ··· 114 106 <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p> 115 107 <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p> 116 108 <p>{{ body }}</p> 117 - <div> 109 + <div class="labels-list"> 118 110 <!-- eslint-disable-next-line vue/no-v-html --> 119 111 <div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/> 120 112 </div>
+21 -40
web_src/js/features/repo-projects.js
··· 1 1 import $ from 'jquery'; 2 - import {useLightTextOnBackground} from '../utils/color.js'; 3 - import tinycolor from 'tinycolor2'; 2 + import {contrastColor} from '../utils/color.js'; 4 3 import {createSortable} from '../modules/sortable.js'; 5 4 import {POST, DELETE, PUT} from '../modules/fetch.js'; 5 + import tinycolor from 'tinycolor2'; 6 6 7 7 function updateIssueCount(cards) { 8 8 const parent = cards.parentElement; ··· 65 65 boardColumns = mainBoard.getElementsByClassName('project-column'); 66 66 for (let i = 0; i < boardColumns.length; i++) { 67 67 const column = boardColumns[i]; 68 - if (parseInt($(column).data('sorting')) !== i) { 68 + if (parseInt(column.getAttribute('data-sorting')) !== i) { 69 69 try { 70 - await PUT($(column).data('url'), { 71 - data: { 72 - sorting: i, 73 - color: rgbToHex(window.getComputedStyle($(column)[0]).backgroundColor), 74 - }, 75 - }); 70 + const bgColor = column.style.backgroundColor; // will be rgb() string 71 + const color = bgColor ? tinycolor(bgColor).toHexString() : ''; 72 + await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}}); 76 73 } catch (error) { 77 74 console.error(error); 78 75 } ··· 102 99 103 100 for (const modal of document.getElementsByClassName('edit-project-column-modal')) { 104 101 const projectHeader = modal.closest('.project-column-header'); 105 - const projectTitleLabel = projectHeader?.querySelector('.project-column-title'); 102 + const projectTitleLabel = projectHeader?.querySelector('.project-column-title-label'); 106 103 const projectTitleInput = modal.querySelector('.project-column-title-input'); 107 104 const projectColorInput = modal.querySelector('#new_project_column_color'); 108 105 const boardColumn = modal.closest('.project-column'); 109 - const bgColor = boardColumn?.style.backgroundColor; 110 - 111 - if (bgColor) { 112 - setLabelColor(projectHeader, rgbToHex(bgColor)); 113 - } 114 - 115 106 modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) { 116 107 e.preventDefault(); 117 108 try { ··· 126 117 } finally { 127 118 projectTitleLabel.textContent = projectTitleInput?.value; 128 119 projectTitleInput.closest('form')?.classList.remove('dirty'); 129 - if (projectColorInput?.value) { 130 - setLabelColor(projectHeader, projectColorInput.value); 120 + const dividers = boardColumn.querySelectorAll(':scope > .divider'); 121 + if (projectColorInput.value) { 122 + const color = contrastColor(projectColorInput.value); 123 + boardColumn.style.setProperty('background', projectColorInput.value, 'important'); 124 + boardColumn.style.setProperty('color', color, 'important'); 125 + for (const divider of dividers) { 126 + divider.style.setProperty('color', color); 127 + } 128 + } else { 129 + boardColumn.style.removeProperty('background'); 130 + boardColumn.style.removeProperty('color'); 131 + for (const divider of dividers) { 132 + divider.style.removeProperty('color'); 133 + } 131 134 } 132 - boardColumn.style = `background: ${projectColorInput.value} !important`; 133 135 $('.ui.modal').modal('hide'); 134 136 } 135 137 }); ··· 182 184 createNewColumn(url, $columnTitle, $projectColorInput); 183 185 }); 184 186 } 185 - 186 - function setLabelColor(label, color) { 187 - const {r, g, b} = tinycolor(color).toRgb(); 188 - if (useLightTextOnBackground(r, g, b)) { 189 - label.classList.remove('dark-label'); 190 - label.classList.add('light-label'); 191 - } else { 192 - label.classList.remove('light-label'); 193 - label.classList.add('dark-label'); 194 - } 195 - } 196 - 197 - function rgbToHex(rgb) { 198 - rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+).*\)$/); 199 - return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`; 200 - } 201 - 202 - function hex(x) { 203 - const hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; 204 - return Number.isNaN(x) ? '00' : hexDigits[(x - x % 16) / 16] + hexDigits[x % 16]; 205 - }
+14 -16
web_src/js/utils/color.js
··· 1 - // Check similar implementation in modules/util/color.go and keep synchronization 2 - // Return R, G, B values defined in reletive luminance 3 - function getLuminanceRGB(channel) { 4 - const sRGB = channel / 255; 5 - return (sRGB <= 0.03928) ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4; 1 + import tinycolor from 'tinycolor2'; 2 + 3 + // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance 4 + // Keep this in sync with modules/util/color.go 5 + function getRelativeLuminance(color) { 6 + const {r, g, b} = tinycolor(color).toRgb(); 7 + return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255; 6 8 } 7 9 8 - // Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance 9 - function getLuminance(r, g, b) { 10 - const R = getLuminanceRGB(r); 11 - const G = getLuminanceRGB(g); 12 - const B = getLuminanceRGB(b); 13 - return 0.2126 * R + 0.7152 * G + 0.0722 * B; 10 + function useLightText(backgroundColor) { 11 + return getRelativeLuminance(backgroundColor) < 0.453; 14 12 } 15 13 16 - // Reference from: https://firsching.ch/github_labels.html 17 - // In the future WCAG 3 APCA may be a better solution. 18 - // Check if text should use light color based on RGB of background 19 - export function useLightTextOnBackground(r, g, b) { 20 - return getLuminance(r, g, b) < 0.453; 14 + // Given a background color, returns a black or white foreground color that the highest 15 + // contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better. 16 + // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 17 + export function contrastColor(backgroundColor) { 18 + return useLightText(backgroundColor) ? '#fff' : '#000'; 21 19 } 22 20 23 21 function resolveColors(obj) {
+20 -19
web_src/js/utils/color.test.js
··· 1 - import {useLightTextOnBackground} from './color.js'; 1 + import {contrastColor} from './color.js'; 2 2 3 - test('useLightTextOnBackground', () => { 4 - expect(useLightTextOnBackground(215, 58, 74)).toBe(true); 5 - expect(useLightTextOnBackground(0, 117, 202)).toBe(true); 6 - expect(useLightTextOnBackground(207, 211, 215)).toBe(false); 7 - expect(useLightTextOnBackground(162, 238, 239)).toBe(false); 8 - expect(useLightTextOnBackground(112, 87, 255)).toBe(true); 9 - expect(useLightTextOnBackground(0, 134, 114)).toBe(true); 10 - expect(useLightTextOnBackground(228, 230, 105)).toBe(false); 11 - expect(useLightTextOnBackground(216, 118, 227)).toBe(true); 12 - expect(useLightTextOnBackground(255, 255, 255)).toBe(false); 13 - expect(useLightTextOnBackground(43, 134, 133)).toBe(true); 14 - expect(useLightTextOnBackground(43, 135, 134)).toBe(true); 15 - expect(useLightTextOnBackground(44, 135, 134)).toBe(true); 16 - expect(useLightTextOnBackground(59, 182, 179)).toBe(true); 17 - expect(useLightTextOnBackground(124, 114, 104)).toBe(true); 18 - expect(useLightTextOnBackground(126, 113, 108)).toBe(true); 19 - expect(useLightTextOnBackground(129, 112, 109)).toBe(true); 20 - expect(useLightTextOnBackground(128, 112, 112)).toBe(true); 3 + test('contrastColor', () => { 4 + expect(contrastColor('#d73a4a')).toBe('#fff'); 5 + expect(contrastColor('#0075ca')).toBe('#fff'); 6 + expect(contrastColor('#cfd3d7')).toBe('#000'); 7 + expect(contrastColor('#a2eeef')).toBe('#000'); 8 + expect(contrastColor('#7057ff')).toBe('#fff'); 9 + expect(contrastColor('#008672')).toBe('#fff'); 10 + expect(contrastColor('#e4e669')).toBe('#000'); 11 + expect(contrastColor('#d876e3')).toBe('#000'); 12 + expect(contrastColor('#ffffff')).toBe('#000'); 13 + expect(contrastColor('#2b8684')).toBe('#fff'); 14 + expect(contrastColor('#2b8786')).toBe('#fff'); 15 + expect(contrastColor('#2c8786')).toBe('#000'); 16 + expect(contrastColor('#3bb6b3')).toBe('#000'); 17 + expect(contrastColor('#7c7268')).toBe('#fff'); 18 + expect(contrastColor('#7e716c')).toBe('#fff'); 19 + expect(contrastColor('#81706d')).toBe('#fff'); 20 + expect(contrastColor('#807070')).toBe('#fff'); 21 + expect(contrastColor('#84b6eb')).toBe('#000'); 21 22 });