Wiktionary:Palette/numbered
The process for generating the numbered colors in Wiktionary:Palette is outlined on this page.
Algorithm
The basic process is outlined as follows:
- Start from a set of base colors
- Adjust each color to meet contrast requirements
This results in a color set of 10*N colors for N base colors, with each variant of a base color identified by an index 0 through 9. 0 is the closest to the background color and 9 the furthest from the background color.
Base colors
Color name | Hex value | Example |
---|---|---|
red | #e63949 |
|
scarlet | #e0562d |
|
orange | #d9760f |
|
amber | #cf9211 |
|
yellow | #baa72f |
|
lime | #97b53c |
|
green | #60b334 |
|
teal | #32b378 |
|
cyan | #28a0b5 |
|
blue | #3271f0 |
|
indigo | #584ee6 |
|
purple | #7f38e8 |
|
magenta | #9d2cdb |
|
rose | #a6448c |
|
grey | #666666 |
|
brown | #856142 |
Variants
Variants are derived from the base color by using a value called an offset
If not otherwise specified, colors are expressed as where each value .
To generate a variant, we use an operation (adjust
) below, which takes the base color and the offset :
The operation (lighten
) lightens a color by a certain factor :
The operation (deepen
) deepens a color by a certain factor . It is implemented by converting the color from RGB to HSV (with ; is not altered and thus its range is insignificant), applying the operation to the HSV values and the factor , and then converting back to RGB, where:
and
Contrast requirements
The contrast requirements apply with the variant used as the background color and the default text color, either black or white, as the foreground color.
In light mode, the text color is the default black text color, #202122
. In dark mode, it is the default white text color, #eaecf0
.
Both of these requirements apply, and whichever is stricter will take priority. In light mode, the contrast is against the black foreground color, and in night mode, against the white foreground color:
Index | WCAG | APCA | Notes |
---|---|---|---|
0 | 15.00 | +100 (light), −94.5 dark) | |
1 | 13.25 | +95 (light), −93 (dark) | |
2 | 11.50 | ±90 | APCA Bronze Simple: body text preferred contrast is ≥+90 or ≤−90 |
3 | 9.25 | ±85 | |
4 | 7.50 | ±75 | WCAG: AAA for body text. APCA Bronze Simple: body text minimum contrast is ≥+75 or ≤−75 |
5 | 6.00 | ±67.5 | |
6 | 4.50 | ±60 | WCAG: AA for body text. APCA Bronze Simple: minimum requirement for content text is ≥+60 or ≤−60 |
7 | 3.00 | ±52.5 | |
8 | 2.10 | ±45 | |
9 | 1.40 | ±30 | APCA Bronze Simple: absolute minimum requirement for any kind of text is ≥+30 or ≤−30 |
Note that the requirements regarding APCA contrast for indices 0 and 1 in dark mode are different from those in light mode, since the contrast of the default foreground against the default background in the default dark color scheme is only approximately −94.7.
The color variants, and therefore offsets, that are tried are all those that result in distinct colors when expressed as 6-digit hexadecimal colors. Then, in light mode, we pick the minimum offset for which the quantized color (as if converted to 6-digit hex and back) meets both contrast requirements, and in dark mode, we pick the maximum offset for which the quantized color meets both contrast requirements. This ensures that the color is dark as possible in light mode, and as light as possible in dark mode.
Implementation
The below is a Python reference implementation of this algorithm.
import colorsys
import re
IDENTIFICATION = "[[Wiktionary:Palette/numbered]]"
HEX_COLOR_RGB = re.compile(r"^#[0-9A-Fa-f]{6}$")
def _hex_to_rgb_comp(x):
return int(x, 16) / 255.0
def hex_to_rgb(hex_color):
"""
Converts a color in hex format into the internal format.
(R, G, B, with each value lying within [0, 1]).
"""
if not HEX_COLOR_RGB.match(hex_color):
raise ValueError("hex color format not supported")
r, g, b = hex_color[1:3], hex_color[3:5], hex_color[5:7]
return (_hex_to_rgb_comp(r), _hex_to_rgb_comp(g), _hex_to_rgb_comp(b))
def rgb_to_hex(c):
"""Converts a color into hex format."""
r, g, b = c
r = int(round(r * 255.0))
g = int(round(g * 255.0))
b = int(round(b * 255.0))
return "#{:02x}{:02x}{:02x}".format(r, g, b)
def quantize(color):
"""Quantizes a color so that it corresponds exactly to a 6-digit hex color."""
r, g, b = color
r = int(round(r * 255)) / 255
g = int(round(g * 255)) / 255
b = int(round(b * 255)) / 255
return r, g, b
def _wcag_to_srgb(x):
if x <= 0.03928:
return x / 12.2
else:
return ((x + 0.055) / 1.055) ** 2.4
def wcag_luminance(color):
"""Computes the sRGB luminance of a color according to WCAG."""
rs, gs, bs = color
r = _wcag_to_srgb(rs)
g = _wcag_to_srgb(gs)
b = _wcag_to_srgb(bs)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def wcag_contrast(fgcolor, bgcolor):
"""Computes the WCAG contrast ratio between two colors."""
fgluma = wcag_luminance(fgcolor)
bgluma = wcag_luminance(bgcolor)
ratio = (fgluma + 0.05) / (bgluma + 0.05)
if ratio < 1:
ratio = 1.0 / ratio
return ratio
def _simple_srgb_luma(color):
# <https://github.com/Myndex/SAPC-APCA/blob/master/documentation/APCA-W3-LaTeX.md>
# <https://en.wiktionary.org/w/index.php?title=Module:palette&oldid=82494940#L-57--L-94>
r, g, b = color
s_rco, s_gco, s_bco = 0.2126729, 0.7151522, 0.0721750
main_trc = 2.4
return clamp(0, s_rco * (r ** main_trc) + s_gco * (g ** main_trc) + s_bco * (b ** main_trc), 1)
def _apca_f_sc(y_c):
# <https://github.com/Myndex/SAPC-APCA/blob/master/documentation/APCA-W3-LaTeX.md>
# <https://en.wiktionary.org/w/index.php?title=Module:palette&oldid=82494940#L-57--L-94>
if y_c <= 0:
return 0
elif y_c <= 0.022:
return y_c + (0.022 - y_c) ** 1.414
else:
return y_c
def apca_contrast(fgcolor, bgcolor):
"""Computes the APCA contrast value between a foreground and background color."""
# <https://github.com/Myndex/SAPC-APCA/blob/master/documentation/APCA-W3-LaTeX.md>
# <https://en.wiktionary.org/w/index.php?title=Module:palette&oldid=82494940#L-57--L-94>
ys_fg = _simple_srgb_luma(fgcolor)
ys_bg = _simple_srgb_luma(bgcolor)
y_fg = _apca_f_sc(ys_fg)
y_bg = _apca_f_sc(ys_bg)
if y_bg > y_fg:
s_apc = (y_bg ** 0.56 - y_fg ** 0.57) * 1.14
else:
s_apc = (y_bg ** 0.65 - y_fg ** 0.62) * 1.14
if abs(s_apc) < 0.1:
l_c = 0
elif s_apc > 0:
l_c = (s_apc - 0.027) * 100
else:
l_c = (s_apc + 0.027) * 100
return l_c
def clamp(minimum, x, maximum):
"""Forces the value x between the endpoints minimum and maximum."""
assert minimum <= maximum
return max(minimum, min(maximum, x))
def _lighten_comp(component, power):
if not 0 <= power <= 1:
raise ValueError("power must be within [0, 1]")
return power + component * (1 - power)
def lighten(color, power):
"""Lightens a color by a certain power [0, 1]."""
if not 0 <= power <= 1:
raise ValueError("power must be within [0, 1]")
r, g, b = color
return (_lighten_comp(r, power), _lighten_comp(g, power), _lighten_comp(b, power))
def deepen(color, power):
"""Deepens a color by a certain power [0, 1]."""
if not 0 <= power <= 1:
raise ValueError("power must be within [0, 1]")
r, g, b = color
h, s, v = colorsys.rgb_to_hsv(r, g, b)
s_new = (1 - (1 - s) * (1 - power)) * (s if s < 0.5 else 1)
v_new = v * (1 - power)
return colorsys.hsv_to_rgb(h, s_new, v_new)
def adjust(color, offset):
"""
Adjusts a color according to the offset.
If the offset is positive, the color is lightened.
If the offset is negative, the color is deepened.
"""
if offset > 0:
return lighten(color, offset)
elif offset < 0:
return deepen(color, -offset)
return color
class ContrastedColorMaker:
"""
Generates variants of the base color that meets contrast requirements
as a background color against the specified reference foreground color.
"""
def __init__(self, base_color, reference, night_mode):
self._night_mode = night_mode
# generate all possible colors
div = 1024
options = {}
self._base_color = base_color
self._colors = {}
self._wcag = []
self._apca = []
for i in range(-div, div + 1):
offset = clamp(-1, i / div, 1)
color = quantize(adjust(base_color, offset))
if color not in options:
options[color] = offset
self._colors[offset] = color
self._wcag.append((wcag_contrast(reference, color), offset))
self._apca.append((abs(apca_contrast(reference, color)), offset))
self._wcag.sort()
self._apca.sort()
def for_contrast(self, contrast_wcag, contrast_apca):
"""
Returns a variant of the color that meets the WCAG and APCA
requirements (the latter is optional). If no such color exists,
returns None.
"""
wcag_meets = set(offset for contrast, offset in self._wcag if contrast >= contrast_wcag)
if contrast_apca is None:
meets = wcag_meets
else:
apca_meets = set(offset for contrast, offset in self._apca if contrast >= contrast_apca)
meets = wcag_meets & apca_meets
if meets:
return self._colors[(max if self._night_mode else min)(meets)]
return None
BASE = {
"red": hex_to_rgb("#e63949"),
"scarlet": hex_to_rgb("#e0562d"),
"orange": hex_to_rgb("#d9760f"),
"amber": hex_to_rgb("#cf9211"),
"yellow": hex_to_rgb("#baa72f"),
"lime": hex_to_rgb("#97b53c"),
"green": hex_to_rgb("#60b334"),
"teal": hex_to_rgb("#32b378"),
"cyan": hex_to_rgb("#28a0b5"),
"blue": hex_to_rgb("#3271f0"),
"indigo": hex_to_rgb("#584ee6"),
"purple": hex_to_rgb("#7f38e8"),
"magenta": hex_to_rgb("#9d2cdb"),
"rose": hex_to_rgb("#a6448c"),
"grey": hex_to_rgb("#666666"),
"brown": hex_to_rgb("#856142"),
}
WHITE_BG = "#ffffff"
BLACK_FG = "#202122"
WHITE_FG = "#eaecf0"
BLACK_BG = "#101418"
white = hex_to_rgb(WHITE_FG)
black = hex_to_rgb(BLACK_FG)
CONTRASTS = {
0: (15.00, 100, 94.5),
1: (13.25, 95, 93),
2: (11.50, 90, 90),
3: ( 9.25, 85, 85),
4: ( 7.50, 75, 75),
5: ( 6.00, 67.5, 67.5),
6: ( 4.50, 60, 60),
7: ( 3.00, 52.5, 52.5),
8: ( 2.10, 45, 45),
9: ( 1.40, 30, 30),
}
colors_light = {}
colors_night = {}
for base_color_name, base_color in BASE.items():
tints_light = colors_light[base_color_name] = {}
tints_night = colors_night[base_color_name] = {}
contrasted_color_maker_light = ContrastedColorMaker(base_color, black, False)
contrasted_color_maker_night = ContrastedColorMaker(base_color, white, True)
for tint_number, (contrast_wcag, contrast_apca, contrast_apca_night) in CONTRASTS.items():
tint_light = contrasted_color_maker_light.for_contrast(contrast_wcag, contrast_apca)
if tint_light is None:
raise ValueError(f"light mode contrast requirement cannot be met (WCAG: {contrast_wcag}, APCA: {contrast_apca}, base color: {rgb_to_hex(base_color)}, foreground color: {rgb_to_hex(black)})")
tint_night = contrasted_color_maker_night.for_contrast(contrast_wcag, contrast_apca_night)
if tint_night is None:
raise ValueError(f"night mode contrast requirement cannot be met (WCAG: {contrast_wcag}, APCA: {-contrast_apca_night}, base color: {rgb_to_hex(base_color)}, foreground color: {rgb_to_hex(white)})")
tints_light[tint_number] = tint_light
tints_night[tint_number] = tint_night
def make_swatch_bg(colors, fg_color):
lines = []
for base_color_name, tints in colors.items():
if lines:
lines.append('|-')
else:
lines.append('{|')
for tint_number, color in tints.items():
name = f"{base_color_name}-{tint_number}"
lines.append(f'| style="background:{rgb_to_hex(color)}" | <div style="color:{fg_color};padding:15px 5px">{name}</div>')
lines.append("|}")
return "\n".join(lines)
def make_swatch_fg(colors, bg_color):
lines = []
for base_color_name, tints in colors.items():
if lines:
lines.append('|-')
else:
lines.append("{| " + f'style="background:{bg_color}"')
for tint_number, color in tints.items():
name = f"{base_color_name}-{tint_number}"
lines.append(f'| <div style="color:{rgb_to_hex(color)};padding:5px">{name}</div>')
lines.append("|}")
return "\n".join(lines)
def make_unified_swatch_bg(colors):
lines = []
for base_color_name, tints in colors.items():
if lines:
lines.append('|-')
else:
lines.append("{| " + f'class="wikt-auto-contrast-exempt" style="color:var(--color-base)"')
for tint_number, color in tints.items():
name = f"{base_color_name}-{tint_number}"
lines.append(f'| style="background-color:var(--wikt-palette-{name},{rgb_to_hex(color)})" | <div style="padding:15px 5px">{name}</div>')
lines.append("|}")
return "\n".join(lines)
def make_unified_swatch_fg(colors):
lines = []
for base_color_name, tints in colors.items():
if lines:
lines.append('|-')
else:
lines.append("{| " + f'class="wikt-auto-contrast-exempt" style="background:var(--background-color-base)"')
for tint_number, color in tints.items():
name = f"{base_color_name}-{tint_number}"
lines.append(f'| <div style="color:var(--wikt-palette-{name},{rgb_to_hex(color)});padding:5px">{name}</div>')
lines.append("|}")
return "\n".join(lines)
def make_palette(colors_light, colors_night):
def wrap_frame(prefix, indent, suffix):
def _wrap(lines):
return prefix + "\n" + "\n".join(indent + line for line in lines) + "\n" + suffix
return _wrap
def gather_colors(colors):
rules = ["/* Autogenerated with " + IDENTIFICATION + " */"]
for base_color_name, tints in colors.items():
for tint_number, color in tints.items():
name = f"{base_color_name}-{tint_number}"
rules.append(f"--wikt-palette-{name}: {rgb_to_hex(color)};")
return rules
light_block = wrap_frame(":root, .skin-invert, .notheme {", "\t", "}")
night_block_1 = wrap_frame("@media screen {\n\thtml.skin-theme-clientpref-night {", "\t\t", "\t}\n}")
night_block_2 = wrap_frame("@media screen and (prefers-color-scheme: dark) {\n\thtml.skin-theme-clientpref-os {".strip(), "\t\t", "\t}\n}")
rules_light = gather_colors(colors_light)
rules_night = gather_colors(colors_night)
return light_block(rules_light) + "\n\n/* Styles need to be duplicated exactly between these two selectors. */\n" + night_block_1(rules_night) + "\n" + night_block_2(rules_night)
def print_swatches():
print("==Swatches==")
print("")
print("===Light mode===")
print("<!-- Autogenerated with " + IDENTIFICATION + " */ -->")
print(make_swatch_bg(colors_light, BLACK_FG))
print("")
print(make_swatch_fg(colors_light, WHITE_BG))
print("")
print("===Night mode===")
print("<!-- Autogenerated with " + IDENTIFICATION + " */ -->")
print(make_swatch_bg(colors_night, WHITE_FG))
print("")
print(make_swatch_fg(colors_night, BLACK_BG))
def print_unified_swatch():
print("==Swatch==")
print("Automatically adjusts for light/dark mode.")
print("<!-- Autogenerated with " + IDENTIFICATION + " */ -->")
print(make_unified_swatch_bg(colors_light))
print("")
print(make_unified_swatch_fg(colors_light))
def print_palette():
print(make_palette(colors_light, colors_night))
if __name__ == "__main__":
#print_swatches()
print_unified_swatch()
#print_palette()
Swatch
Automatically adjusts for light/dark mode.
red-0
|
red-1
|
red-2
|
red-3
|
red-4
|
red-5
|
red-6
|
red-7
|
red-8
|
red-9
|
scarlet-0
|
scarlet-1
|
scarlet-2
|
scarlet-3
|
scarlet-4
|
scarlet-5
|
scarlet-6
|
scarlet-7
|
scarlet-8
|
scarlet-9
|
orange-0
|
orange-1
|
orange-2
|
orange-3
|
orange-4
|
orange-5
|
orange-6
|
orange-7
|
orange-8
|
orange-9
|
amber-0
|
amber-1
|
amber-2
|
amber-3
|
amber-4
|
amber-5
|
amber-6
|
amber-7
|
amber-8
|
amber-9
|
yellow-0
|
yellow-1
|
yellow-2
|
yellow-3
|
yellow-4
|
yellow-5
|
yellow-6
|
yellow-7
|
yellow-8
|
yellow-9
|
lime-0
|
lime-1
|
lime-2
|
lime-3
|
lime-4
|
lime-5
|
lime-6
|
lime-7
|
lime-8
|
lime-9
|
green-0
|
green-1
|
green-2
|
green-3
|
green-4
|
green-5
|
green-6
|
green-7
|
green-8
|
green-9
|
teal-0
|
teal-1
|
teal-2
|
teal-3
|
teal-4
|
teal-5
|
teal-6
|
teal-7
|
teal-8
|
teal-9
|
cyan-0
|
cyan-1
|
cyan-2
|
cyan-3
|
cyan-4
|
cyan-5
|
cyan-6
|
cyan-7
|
cyan-8
|
cyan-9
|
blue-0
|
blue-1
|
blue-2
|
blue-3
|
blue-4
|
blue-5
|
blue-6
|
blue-7
|
blue-8
|
blue-9
|
indigo-0
|
indigo-1
|
indigo-2
|
indigo-3
|
indigo-4
|
indigo-5
|
indigo-6
|
indigo-7
|
indigo-8
|
indigo-9
|
purple-0
|
purple-1
|
purple-2
|
purple-3
|
purple-4
|
purple-5
|
purple-6
|
purple-7
|
purple-8
|
purple-9
|
magenta-0
|
magenta-1
|
magenta-2
|
magenta-3
|
magenta-4
|
magenta-5
|
magenta-6
|
magenta-7
|
magenta-8
|
magenta-9
|
rose-0
|
rose-1
|
rose-2
|
rose-3
|
rose-4
|
rose-5
|
rose-6
|
rose-7
|
rose-8
|
rose-9
|
grey-0
|
grey-1
|
grey-2
|
grey-3
|
grey-4
|
grey-5
|
grey-6
|
grey-7
|
grey-8
|
grey-9
|
brown-0
|
brown-1
|
brown-2
|
brown-3
|
brown-4
|
brown-5
|
brown-6
|
brown-7
|
brown-8
|
brown-9
|
red-0
|
red-1
|
red-2
|
red-3
|
red-4
|
red-5
|
red-6
|
red-7
|
red-8
|
red-9
|
scarlet-0
|
scarlet-1
|
scarlet-2
|
scarlet-3
|
scarlet-4
|
scarlet-5
|
scarlet-6
|
scarlet-7
|
scarlet-8
|
scarlet-9
|
orange-0
|
orange-1
|
orange-2
|
orange-3
|
orange-4
|
orange-5
|
orange-6
|
orange-7
|
orange-8
|
orange-9
|
amber-0
|
amber-1
|
amber-2
|
amber-3
|
amber-4
|
amber-5
|
amber-6
|
amber-7
|
amber-8
|
amber-9
|
yellow-0
|
yellow-1
|
yellow-2
|
yellow-3
|
yellow-4
|
yellow-5
|
yellow-6
|
yellow-7
|
yellow-8
|
yellow-9
|
lime-0
|
lime-1
|
lime-2
|
lime-3
|
lime-4
|
lime-5
|
lime-6
|
lime-7
|
lime-8
|
lime-9
|
green-0
|
green-1
|
green-2
|
green-3
|
green-4
|
green-5
|
green-6
|
green-7
|
green-8
|
green-9
|
teal-0
|
teal-1
|
teal-2
|
teal-3
|
teal-4
|
teal-5
|
teal-6
|
teal-7
|
teal-8
|
teal-9
|
cyan-0
|
cyan-1
|
cyan-2
|
cyan-3
|
cyan-4
|
cyan-5
|
cyan-6
|
cyan-7
|
cyan-8
|
cyan-9
|
blue-0
|
blue-1
|
blue-2
|
blue-3
|
blue-4
|
blue-5
|
blue-6
|
blue-7
|
blue-8
|
blue-9
|
indigo-0
|
indigo-1
|
indigo-2
|
indigo-3
|
indigo-4
|
indigo-5
|
indigo-6
|
indigo-7
|
indigo-8
|
indigo-9
|
purple-0
|
purple-1
|
purple-2
|
purple-3
|
purple-4
|
purple-5
|
purple-6
|
purple-7
|
purple-8
|
purple-9
|
magenta-0
|
magenta-1
|
magenta-2
|
magenta-3
|
magenta-4
|
magenta-5
|
magenta-6
|
magenta-7
|
magenta-8
|
magenta-9
|
rose-0
|
rose-1
|
rose-2
|
rose-3
|
rose-4
|
rose-5
|
rose-6
|
rose-7
|
rose-8
|
rose-9
|
grey-0
|
grey-1
|
grey-2
|
grey-3
|
grey-4
|
grey-5
|
grey-6
|
grey-7
|
grey-8
|
grey-9
|
brown-0
|
brown-1
|
brown-2
|
brown-3
|
brown-4
|
brown-5
|
brown-6
|
brown-7
|
brown-8
|
brown-9
|