Skip to content

Commit

Permalink
Add WCAG AAA colour contrast option to theme editor (#2282)
Browse files Browse the repository at this point in the history
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
  • Loading branch information
delucis and HiDeoo authored Sep 4, 2024
1 parent 4014fd4 commit 782def0
Show file tree
Hide file tree
Showing 9 changed files with 217 additions and 42 deletions.
4 changes: 2 additions & 2 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
"@astro-community/astro-embed-youtube": "^0.5.2",
"@astrojs/starlight": "workspace:*",
"@lunariajs/core": "^0.1.1",
"@types/culori": "^2.0.0",
"@types/culori": "^2.1.1",
"astro": "^4.10.2",
"culori": "^3.2.0",
"culori": "^4.0.1",
"sharp": "^0.32.5"
},
"devDependencies": {
Expand Down
17 changes: 14 additions & 3 deletions docs/src/components/theme-designer.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
---
import { TabItem, Tabs } from '@astrojs/starlight/components';
import ColorEditor, { type Props as EditorProps } from './theme-designer/color-editor.astro';
import ContrastLevel, {
type Props as ContrastLevelProps,
} from './theme-designer/contrast-level.astro';
import Presets, { type Props as PresetsProps } from './theme-designer/presets.astro';
import Preview from './theme-designer/preview.astro';
interface Props {
labels: {
presets: PresetsProps['labels'];
contrast: ContrastLevelProps['labels'];
editor: EditorProps['labels'] & { accentColor: string; grayColor: string };
preview: Record<
'darkMode' | 'lightMode' | 'bodyText' | 'linkText' | 'dimText' | 'inlineCode',
Expand All @@ -15,12 +19,14 @@ interface Props {
};
}
const {
labels: { presets, editor, preview },
labels: { presets, contrast, editor, preview },
} = Astro.props;
---

<Presets labels={presets} />

<ContrastLevel labels={contrast} />

<div>
<theme-designer>
<div class="sl-flex controls not-content">
Expand Down Expand Up @@ -52,7 +58,7 @@ const {

<script>
import { getPalettes } from './theme-designer/color-lib';
import { store } from './theme-designer/store';
import { store, minimumContrast } from './theme-designer/store';

class ThemeDesigner extends HTMLElement {
#stylesheet = new CSSStyleSheet();
Expand All @@ -65,10 +71,15 @@ const {
const onInput = () => this.#update();
store.accent.subscribe(onInput);
store.gray.subscribe(onInput);
minimumContrast.subscribe(onInput);
}

#update() {
const palettes = getPalettes({ accent: store.accent.get(), gray: store.gray.get() });
const palettes = getPalettes({
accent: store.accent.get(),
gray: store.gray.get(),
minimumContrast: minimumContrast.get(),
});
this.#updatePreview(palettes);
this.#updateStylesheet(palettes);
this.#updateTailwindConfig(palettes);
Expand Down
4 changes: 4 additions & 0 deletions docs/src/components/theme-designer/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ export function map<T extends Record<string, unknown>>(value: T): MapStore<T> {
};
return atom;
}

export function atom<T extends unknown>(value: T): Atom<T> {
return new Atom(value);
}
120 changes: 92 additions & 28 deletions docs/src/components/theme-designer/color-lib.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,121 @@
import { useMode, modeOklch, modeRgb, formatHex, clampChroma } from 'culori/fn';
import {
clampChroma,
formatHex,
modeLrgb,
modeOklch,
modeRgb,
useMode,
wcagContrast,
type Oklch,
} from 'culori/fn';

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

/** Convert an OKLCH color to an RGB hex code. */
export const oklchToHex = (l: number, c: number, h: number) => {
const okLchColor = oklch(`oklch(${l}% ${c} ${h})`)!;
/** Convert a culori OKLCH color object to an RGB hex code. */
const oklchColorToHex = (okLchColor: Oklch) => {
const rgbColor = rgb(clampChroma(okLchColor, 'oklch'));
return formatHex(rgbColor);
};
/** Construct a culori OKLCH color object from LCH parameters. */
const oklchColorFromParts = (l: number, c: number, h: number) => oklch(`oklch(${l}% ${c} ${h})`)!;
/** Convert OKLCH parameters to an RGB hex code. */
export const oklchToHex = (l: number, c: number, h: number) =>
oklchColorToHex(oklchColorFromParts(l, c, h));

/**
* Ensure a text colour passes a contrast threshold against a specific background colour.
* If necessary, colours will be darkened/lightened to increase contrast until the threshold is passed.
*
* @param text The text colour to adjust if necessary.
* @param bg The background colour to test contrast against.
* @param threshold The minimum contrast ratio required. Defaults to `4.5` to meet WCAG AA standards.
* @returns The adjusted text colour as a culori OKLCH color object.
*/
const contrastColor = (text: Oklch, bg: Oklch, threshold = 4.5): Oklch => {
/** Clone of the input foreground colour to mutate. */
const fgColor = { ...text };
// Brighten text in dark mode, darken text in light mode.
const increment = fgColor.l > bg.l ? 0.005 : -0.005;
while (wcagContrast(fgColor, bg) < threshold && fgColor.l < 100 && fgColor.l > 0) {
fgColor.l += increment;
}
return fgColor;
};

/** Generate dark and light palettes based on user-selected hue and chroma values. */
export function getPalettes(config: {
accent: { hue: number; chroma: number };
gray: { hue: number; chroma: number };
minimumContrast?: number;
}) {
const {
accent: { hue: ah, chroma: ac },
gray: { hue: gh, chroma: gc },
minimumContrast: mc,
} = config;
return {

const palettes = {
dark: {
// Accents
'accent-low': oklchToHex(25.94, ac / 3, ah),
accent: oklchToHex(52.28, ac, ah),
'accent-high': oklchToHex(83.38, ac / 3, ah),
'accent-low': oklchColorFromParts(25.94, ac / 3, ah),
accent: oklchColorFromParts(52.28, ac, ah),
'accent-high': oklchColorFromParts(83.38, ac / 3, ah),
// Grays
white: oklchToHex(100, 0, 0),
'gray-1': oklchToHex(94.77, gc / 2.5, gh),
'gray-2': oklchToHex(81.34, gc / 2, gh),
'gray-3': oklchToHex(63.78, gc, gh),
'gray-4': oklchToHex(46.01, gc, gh),
'gray-5': oklchToHex(34.09, gc, gh),
'gray-6': oklchToHex(27.14, gc, gh),
black: oklchToHex(20.94, gc / 2, gh),
white: oklchColorFromParts(100, 0, 0),
'gray-1': oklchColorFromParts(94.77, gc / 2.5, gh),
'gray-2': oklchColorFromParts(81.34, gc / 2, gh),
'gray-3': oklchColorFromParts(63.78, gc, gh),
'gray-4': oklchColorFromParts(46.01, gc, gh),
'gray-5': oklchColorFromParts(34.09, gc, gh),
'gray-6': oklchColorFromParts(27.14, gc, gh),
black: oklchColorFromParts(20.94, gc / 2, gh),
},
light: {
// Accents
'accent-low': oklchToHex(87.81, ac / 4, ah),
accent: oklchToHex(52.95, ac, ah),
'accent-high': oklchToHex(31.77, ac / 2, ah),
'accent-low': oklchColorFromParts(87.81, ac / 4, ah),
accent: oklchColorFromParts(52.95, ac, ah),
'accent-high': oklchColorFromParts(31.77, ac / 2, ah),
// Grays
white: oklchToHex(20.94, gc / 2, gh),
'gray-1': oklchToHex(27.14, gc, gh),
'gray-2': oklchToHex(34.09, gc, gh),
'gray-3': oklchToHex(46.01, gc, gh),
'gray-4': oklchToHex(63.78, gc, gh),
'gray-5': oklchToHex(81.34, gc / 2, gh),
'gray-6': oklchToHex(94.77, gc / 2.5, gh),
'gray-7': oklchToHex(97.35, gc / 5, gh),
black: oklchToHex(100, 0, 0),
white: oklchColorFromParts(20.94, gc / 2, gh),
'gray-1': oklchColorFromParts(27.14, gc, gh),
'gray-2': oklchColorFromParts(34.09, gc, gh),
'gray-3': oklchColorFromParts(46.01, gc, gh),
'gray-4': oklchColorFromParts(63.78, gc, gh),
'gray-5': oklchColorFromParts(81.34, gc / 2, gh),
'gray-6': oklchColorFromParts(94.77, gc / 2.5, gh),
'gray-7': oklchColorFromParts(97.35, gc / 5, gh),
black: oklchColorFromParts(100, 0, 0),
},
};

// Ensure text shades have sufficient contrast against common background colours.

// Dark mode:
// `gray-2` is used against `gray-5` in inline code snippets.
palettes.dark['gray-2'] = contrastColor(palettes.dark['gray-2'], palettes.dark['gray-5'], mc);
// `gray-3` is used in the table of contents.
palettes.dark['gray-3'] = contrastColor(palettes.dark['gray-3'], palettes.dark.black, mc);

// Light mode:
// `accent` is used for links and link buttons and can be slightly under 7:1 for some hues.
palettes.light.accent = contrastColor(palettes.light.accent, palettes.light['gray-6'], mc);
// `gray-2` is used against `gray-6` in inline code snippets.
palettes.light['gray-2'] = contrastColor(palettes.light['gray-2'], palettes.light['gray-6'], mc);
// `gray-3` is used in the table of contents.
palettes.light['gray-3'] = contrastColor(palettes.light['gray-3'], palettes.light.black, mc);

// Convert the palette from OKLCH to RGB hex codes.
return {
dark: Object.fromEntries(
Object.entries(palettes.dark).map(([key, color]) => [key, oklchColorToHex(color)])
) as Record<keyof typeof palettes.dark, string>,
light: Object.fromEntries(
Object.entries(palettes.light).map(([key, color]) => [key, oklchColorToHex(color)])
) as Record<keyof typeof palettes.light, string>,
};
}

/*
Expand Down
86 changes: 86 additions & 0 deletions docs/src/components/theme-designer/contrast-level.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
export interface Props {
labels: {
label: string;
};
}
const { labels = { label: 'WCAG Contrast Level' } } = Astro.props;
---

<contrast-level-toggle class="sl-flex">
<fieldset class="not-content">
<legend>{labels.label}</legend>
<div class="sl-flex">
<label class="sl-flex">
<input type="radio" name="contrast-level" value="4.5" checked />
AA
</label>
<label class="sl-flex">
<input type="radio" name="contrast-level" value="7" />
AAA
</label>
</div>
</fieldset>
</contrast-level-toggle>

<script>
import { minimumContrast } from './store';

class ContrastLevelToggle extends HTMLElement {
#fieldset = this.querySelector('fieldset')!;
constructor() {
super();
this.#fieldset.addEventListener('input', (e) => {
if (e.target instanceof HTMLInputElement) {
const contrast = parseFloat(e.target.value);
minimumContrast.set(contrast);
}
});
}
}

customElements.define('contrast-level-toggle', ContrastLevelToggle);
</script>

<style>
fieldset {
border: 0;
padding: 0;
}
fieldset > * {
float: left;
float: inline-start;
vertical-align: middle;
}
legend {
color: var(--sl-color-white);
margin-inline-end: 0.75rem;
}
label {
align-items: center;
padding: 0.25rem 0.75rem;
gap: 0.375rem;
background-color: var(--sl-color-gray-6);
font-size: var(--sl-text-xs);
cursor: pointer;
}
label:has(:focus-visible) {
outline: 2px solid;
outline-offset: -4px;
}
label:first-child {
border-start-start-radius: 99rem;
border-end-start-radius: 99rem;
}
label:last-child {
border-start-end-radius: 99rem;
border-end-end-radius: 99rem;
}
label:has(:checked) {
color: var(--sl-color-black);
background-color: var(--sl-color-text-accent);
}
input:focus-visible {
outline: none;
}
</style>
4 changes: 4 additions & 0 deletions docs/src/components/theme-designer/presets.astro
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const resolvedPresets = Object.entries(presets).map(([key, preset]) => {
font-size: var(--sl-text-xs);
cursor: pointer;
}
button:focus-visible {
outline: 2px solid;
outline-offset: -4px;
}
:global([data-theme='light']) [data-preset] {
background-color: var(--light-bg);
color: var(--light-text);
Expand Down
3 changes: 2 additions & 1 deletion docs/src/components/theme-designer/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { map } from './atom';
import { atom, map } from './atom';

export const presets = {
ocean: {
Expand Down Expand Up @@ -27,6 +27,7 @@ export const store = {
accent: map(presets.default.accent),
gray: map(presets.default.gray),
};
export const minimumContrast = atom(4.5);

export const usePreset = (name: string) => {
if (name in presets) {
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/guides/css-and-tailwind.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ These variables are used throughout the UI with a range of gray shades used for
Use the sliders below to modify Starlight’s accent and gray color palettes.
The dark and light preview areas will show the resulting colors, and the whole page will also update to preview your changes.

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.

When you’re happy with your changes, copy the CSS or Tailwind code below and use it in your project.

import ThemeDesigner from '~/components/theme-designer.astro';
Expand All @@ -266,6 +268,9 @@ import ThemeDesigner from '~/components/theme-designer.astro';
default: 'Default',
random: 'Random',
},
contrast: {
label: 'Contrast Level',
},
editor: {
accentColor: 'Accent',
grayColor: 'Gray',
Expand Down
Loading

0 comments on commit 782def0

Please sign in to comment.