"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
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() {
// 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)
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)),
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
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"
}
}
}
-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)
}
}
<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}}">
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;
.project-column > .divider {
margin: 5px 0;
+ border-color: currentcolor;
+ opacity: .5;
}
.project-column:first-child {
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 */
}
}
}
-#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;
}
--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);
--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);
<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;
},
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() {
<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"
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;
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);
}
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 {
} 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');
}
});
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];
-}
-// 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) {
-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');
});