Skip to content

Commit 782def0

Browse files
delucisHiDeoo
andauthored
Add WCAG AAA colour contrast option to theme editor (#2282)
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
1 parent 4014fd4 commit 782def0

9 files changed

Lines changed: 217 additions & 42 deletions

File tree

docs/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
"@astro-community/astro-embed-youtube": "^0.5.2",
1818
"@astrojs/starlight": "workspace:*",
1919
"@lunariajs/core": "^0.1.1",
20-
"@types/culori": "^2.0.0",
20+
"@types/culori": "^2.1.1",
2121
"astro": "^4.10.2",
22-
"culori": "^3.2.0",
22+
"culori": "^4.0.1",
2323
"sharp": "^0.32.5"
2424
},
2525
"devDependencies": {

docs/src/components/theme-designer.astro

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
---
22
import { TabItem, Tabs } from '@astrojs/starlight/components';
33
import ColorEditor, { type Props as EditorProps } from './theme-designer/color-editor.astro';
4+
import ContrastLevel, {
5+
type Props as ContrastLevelProps,
6+
} from './theme-designer/contrast-level.astro';
47
import Presets, { type Props as PresetsProps } from './theme-designer/presets.astro';
58
import Preview from './theme-designer/preview.astro';
69
710
interface Props {
811
labels: {
912
presets: PresetsProps['labels'];
13+
contrast: ContrastLevelProps['labels'];
1014
editor: EditorProps['labels'] & { accentColor: string; grayColor: string };
1115
preview: Record<
1216
'darkMode' | 'lightMode' | 'bodyText' | 'linkText' | 'dimText' | 'inlineCode',
@@ -15,12 +19,14 @@ interface Props {
1519
};
1620
}
1721
const {
18-
labels: { presets, editor, preview },
22+
labels: { presets, contrast, editor, preview },
1923
} = Astro.props;
2024
---
2125

2226
<Presets labels={presets} />
2327

28+
<ContrastLevel labels={contrast} />
29+
2430
<div>
2531
<theme-designer>
2632
<div class="sl-flex controls not-content">
@@ -52,7 +58,7 @@ const {
5258

5359
<script>
5460
import { getPalettes } from './theme-designer/color-lib';
55-
import { store } from './theme-designer/store';
61+
import { store, minimumContrast } from './theme-designer/store';
5662

5763
class ThemeDesigner extends HTMLElement {
5864
#stylesheet = new CSSStyleSheet();
@@ -65,10 +71,15 @@ const {
6571
const onInput = () => this.#update();
6672
store.accent.subscribe(onInput);
6773
store.gray.subscribe(onInput);
74+
minimumContrast.subscribe(onInput);
6875
}
6976

7077
#update() {
71-
const palettes = getPalettes({ accent: store.accent.get(), gray: store.gray.get() });
78+
const palettes = getPalettes({
79+
accent: store.accent.get(),
80+
gray: store.gray.get(),
81+
minimumContrast: minimumContrast.get(),
82+
});
7283
this.#updatePreview(palettes);
7384
this.#updateStylesheet(palettes);
7485
this.#updateTailwindConfig(palettes);

docs/src/components/theme-designer/atom.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ export function map<T extends Record<string, unknown>>(value: T): MapStore<T> {
2929
};
3030
return atom;
3131
}
32+
33+
export function atom<T extends unknown>(value: T): Atom<T> {
34+
return new Atom(value);
35+
}

docs/src/components/theme-designer/color-lib.ts

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,121 @@
1-
import { useMode, modeOklch, modeRgb, formatHex, clampChroma } from 'culori/fn';
1+
import {
2+
clampChroma,
3+
formatHex,
4+
modeLrgb,
5+
modeOklch,
6+
modeRgb,
7+
useMode,
8+
wcagContrast,
9+
type Oklch,
10+
} from 'culori/fn';
211

312
const rgb = useMode(modeRgb);
413
export const oklch = useMode(modeOklch);
14+
// We need to initialise LRGB support for culori’s `wcagContrast()` method.
15+
useMode(modeLrgb);
516

6-
/** Convert an OKLCH color to an RGB hex code. */
7-
export const oklchToHex = (l: number, c: number, h: number) => {
8-
const okLchColor = oklch(`oklch(${l}% ${c} ${h})`)!;
17+
/** Convert a culori OKLCH color object to an RGB hex code. */
18+
const oklchColorToHex = (okLchColor: Oklch) => {
919
const rgbColor = rgb(clampChroma(okLchColor, 'oklch'));
1020
return formatHex(rgbColor);
1121
};
22+
/** Construct a culori OKLCH color object from LCH parameters. */
23+
const oklchColorFromParts = (l: number, c: number, h: number) => oklch(`oklch(${l}% ${c} ${h})`)!;
24+
/** Convert OKLCH parameters to an RGB hex code. */
25+
export const oklchToHex = (l: number, c: number, h: number) =>
26+
oklchColorToHex(oklchColorFromParts(l, c, h));
27+
28+
/**
29+
* Ensure a text colour passes a contrast threshold against a specific background colour.
30+
* If necessary, colours will be darkened/lightened to increase contrast until the threshold is passed.
31+
*
32+
* @param text The text colour to adjust if necessary.
33+
* @param bg The background colour to test contrast against.
34+
* @param threshold The minimum contrast ratio required. Defaults to `4.5` to meet WCAG AA standards.
35+
* @returns The adjusted text colour as a culori OKLCH color object.
36+
*/
37+
const contrastColor = (text: Oklch, bg: Oklch, threshold = 4.5): Oklch => {
38+
/** Clone of the input foreground colour to mutate. */
39+
const fgColor = { ...text };
40+
// Brighten text in dark mode, darken text in light mode.
41+
const increment = fgColor.l > bg.l ? 0.005 : -0.005;
42+
while (wcagContrast(fgColor, bg) < threshold && fgColor.l < 100 && fgColor.l > 0) {
43+
fgColor.l += increment;
44+
}
45+
return fgColor;
46+
};
1247

1348
/** Generate dark and light palettes based on user-selected hue and chroma values. */
1449
export function getPalettes(config: {
1550
accent: { hue: number; chroma: number };
1651
gray: { hue: number; chroma: number };
52+
minimumContrast?: number;
1753
}) {
1854
const {
1955
accent: { hue: ah, chroma: ac },
2056
gray: { hue: gh, chroma: gc },
57+
minimumContrast: mc,
2158
} = config;
22-
return {
59+
60+
const palettes = {
2361
dark: {
2462
// Accents
25-
'accent-low': oklchToHex(25.94, ac / 3, ah),
26-
accent: oklchToHex(52.28, ac, ah),
27-
'accent-high': oklchToHex(83.38, ac / 3, ah),
63+
'accent-low': oklchColorFromParts(25.94, ac / 3, ah),
64+
accent: oklchColorFromParts(52.28, ac, ah),
65+
'accent-high': oklchColorFromParts(83.38, ac / 3, ah),
2866
// Grays
29-
white: oklchToHex(100, 0, 0),
30-
'gray-1': oklchToHex(94.77, gc / 2.5, gh),
31-
'gray-2': oklchToHex(81.34, gc / 2, gh),
32-
'gray-3': oklchToHex(63.78, gc, gh),
33-
'gray-4': oklchToHex(46.01, gc, gh),
34-
'gray-5': oklchToHex(34.09, gc, gh),
35-
'gray-6': oklchToHex(27.14, gc, gh),
36-
black: oklchToHex(20.94, gc / 2, gh),
67+
white: oklchColorFromParts(100, 0, 0),
68+
'gray-1': oklchColorFromParts(94.77, gc / 2.5, gh),
69+
'gray-2': oklchColorFromParts(81.34, gc / 2, gh),
70+
'gray-3': oklchColorFromParts(63.78, gc, gh),
71+
'gray-4': oklchColorFromParts(46.01, gc, gh),
72+
'gray-5': oklchColorFromParts(34.09, gc, gh),
73+
'gray-6': oklchColorFromParts(27.14, gc, gh),
74+
black: oklchColorFromParts(20.94, gc / 2, gh),
3775
},
3876
light: {
3977
// Accents
40-
'accent-low': oklchToHex(87.81, ac / 4, ah),
41-
accent: oklchToHex(52.95, ac, ah),
42-
'accent-high': oklchToHex(31.77, ac / 2, ah),
78+
'accent-low': oklchColorFromParts(87.81, ac / 4, ah),
79+
accent: oklchColorFromParts(52.95, ac, ah),
80+
'accent-high': oklchColorFromParts(31.77, ac / 2, ah),
4381
// Grays
44-
white: oklchToHex(20.94, gc / 2, gh),
45-
'gray-1': oklchToHex(27.14, gc, gh),
46-
'gray-2': oklchToHex(34.09, gc, gh),
47-
'gray-3': oklchToHex(46.01, gc, gh),
48-
'gray-4': oklchToHex(63.78, gc, gh),
49-
'gray-5': oklchToHex(81.34, gc / 2, gh),
50-
'gray-6': oklchToHex(94.77, gc / 2.5, gh),
51-
'gray-7': oklchToHex(97.35, gc / 5, gh),
52-
black: oklchToHex(100, 0, 0),
82+
white: oklchColorFromParts(20.94, gc / 2, gh),
83+
'gray-1': oklchColorFromParts(27.14, gc, gh),
84+
'gray-2': oklchColorFromParts(34.09, gc, gh),
85+
'gray-3': oklchColorFromParts(46.01, gc, gh),
86+
'gray-4': oklchColorFromParts(63.78, gc, gh),
87+
'gray-5': oklchColorFromParts(81.34, gc / 2, gh),
88+
'gray-6': oklchColorFromParts(94.77, gc / 2.5, gh),
89+
'gray-7': oklchColorFromParts(97.35, gc / 5, gh),
90+
black: oklchColorFromParts(100, 0, 0),
5391
},
5492
};
93+
94+
// Ensure text shades have sufficient contrast against common background colours.
95+
96+
// Dark mode:
97+
// `gray-2` is used against `gray-5` in inline code snippets.
98+
palettes.dark['gray-2'] = contrastColor(palettes.dark['gray-2'], palettes.dark['gray-5'], mc);
99+
// `gray-3` is used in the table of contents.
100+
palettes.dark['gray-3'] = contrastColor(palettes.dark['gray-3'], palettes.dark.black, mc);
101+
102+
// Light mode:
103+
// `accent` is used for links and link buttons and can be slightly under 7:1 for some hues.
104+
palettes.light.accent = contrastColor(palettes.light.accent, palettes.light['gray-6'], mc);
105+
// `gray-2` is used against `gray-6` in inline code snippets.
106+
palettes.light['gray-2'] = contrastColor(palettes.light['gray-2'], palettes.light['gray-6'], mc);
107+
// `gray-3` is used in the table of contents.
108+
palettes.light['gray-3'] = contrastColor(palettes.light['gray-3'], palettes.light.black, mc);
109+
110+
// Convert the palette from OKLCH to RGB hex codes.
111+
return {
112+
dark: Object.fromEntries(
113+
Object.entries(palettes.dark).map(([key, color]) => [key, oklchColorToHex(color)])
114+
) as Record<keyof typeof palettes.dark, string>,
115+
light: Object.fromEntries(
116+
Object.entries(palettes.light).map(([key, color]) => [key, oklchColorToHex(color)])
117+
) as Record<keyof typeof palettes.light, string>,
118+
};
55119
}
56120

57121
/*
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
export interface Props {
3+
labels: {
4+
label: string;
5+
};
6+
}
7+
const { labels = { label: 'WCAG Contrast Level' } } = Astro.props;
8+
---
9+
10+
<contrast-level-toggle class="sl-flex">
11+
<fieldset class="not-content">
12+
<legend>{labels.label}</legend>
13+
<div class="sl-flex">
14+
<label class="sl-flex">
15+
<input type="radio" name="contrast-level" value="4.5" checked />
16+
AA
17+
</label>
18+
<label class="sl-flex">
19+
<input type="radio" name="contrast-level" value="7" />
20+
AAA
21+
</label>
22+
</div>
23+
</fieldset>
24+
</contrast-level-toggle>
25+
26+
<script>
27+
import { minimumContrast } from './store';
28+
29+
class ContrastLevelToggle extends HTMLElement {
30+
#fieldset = this.querySelector('fieldset')!;
31+
constructor() {
32+
super();
33+
this.#fieldset.addEventListener('input', (e) => {
34+
if (e.target instanceof HTMLInputElement) {
35+
const contrast = parseFloat(e.target.value);
36+
minimumContrast.set(contrast);
37+
}
38+
});
39+
}
40+
}
41+
42+
customElements.define('contrast-level-toggle', ContrastLevelToggle);
43+
</script>
44+
45+
<style>
46+
fieldset {
47+
border: 0;
48+
padding: 0;
49+
}
50+
fieldset > * {
51+
float: left;
52+
float: inline-start;
53+
vertical-align: middle;
54+
}
55+
legend {
56+
color: var(--sl-color-white);
57+
margin-inline-end: 0.75rem;
58+
}
59+
label {
60+
align-items: center;
61+
padding: 0.25rem 0.75rem;
62+
gap: 0.375rem;
63+
background-color: var(--sl-color-gray-6);
64+
font-size: var(--sl-text-xs);
65+
cursor: pointer;
66+
}
67+
label:has(:focus-visible) {
68+
outline: 2px solid;
69+
outline-offset: -4px;
70+
}
71+
label:first-child {
72+
border-start-start-radius: 99rem;
73+
border-end-start-radius: 99rem;
74+
}
75+
label:last-child {
76+
border-start-end-radius: 99rem;
77+
border-end-end-radius: 99rem;
78+
}
79+
label:has(:checked) {
80+
color: var(--sl-color-black);
81+
background-color: var(--sl-color-text-accent);
82+
}
83+
input:focus-visible {
84+
outline: none;
85+
}
86+
</style>

docs/src/components/theme-designer/presets.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ const resolvedPresets = Object.entries(presets).map(([key, preset]) => {
6464
font-size: var(--sl-text-xs);
6565
cursor: pointer;
6666
}
67+
button:focus-visible {
68+
outline: 2px solid;
69+
outline-offset: -4px;
70+
}
6771
:global([data-theme='light']) [data-preset] {
6872
background-color: var(--light-bg);
6973
color: var(--light-text);

docs/src/components/theme-designer/store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { map } from './atom';
1+
import { atom, map } from './atom';
22

33
export const presets = {
44
ocean: {
@@ -27,6 +27,7 @@ export const store = {
2727
accent: map(presets.default.accent),
2828
gray: map(presets.default.gray),
2929
};
30+
export const minimumContrast = atom(4.5);
3031

3132
export const usePreset = (name: string) => {
3233
if (name in presets) {

docs/src/content/docs/guides/css-and-tailwind.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ These variables are used throughout the UI with a range of gray shades used for
251251
Use the sliders below to modify Starlight’s accent and gray color palettes.
252252
The dark and light preview areas will show the resulting colors, and the whole page will also update to preview your changes.
253253

254+
Use the Contrast Level option to specify which of the Web Content Accessibility Guideline [colour contrast standards](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Perceivable/Color_contrast) to meet.
255+
254256
When you’re happy with your changes, copy the CSS or Tailwind code below and use it in your project.
255257

256258
import ThemeDesigner from '~/components/theme-designer.astro';
@@ -266,6 +268,9 @@ import ThemeDesigner from '~/components/theme-designer.astro';
266268
default: 'Default',
267269
random: 'Random',
268270
},
271+
contrast: {
272+
label: 'Contrast Level',
273+
},
269274
editor: {
270275
accentColor: 'Accent',
271276
grayColor: 'Gray',

0 commit comments

Comments
 (0)