summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2024-09-04 23:51:56 +0200
committerGitHub2024-09-04 23:51:56 +0200
commit782def032190875cf65cf3b46ba813ed2474e3f8 (patch)
tree10fa07fc04ff1937942ad95d238f45643cae7476
parent4014fd44e11b9e87af32391dc525a04ba9e8373c (diff)
downloadIT.starlight-782def032190875cf65cf3b46ba813ed2474e3f8.tar.gz
IT.starlight-782def032190875cf65cf3b46ba813ed2474e3f8.tar.bz2
IT.starlight-782def032190875cf65cf3b46ba813ed2474e3f8.zip
Add WCAG AAA colour contrast option to theme editor (#2282)
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
-rw-r--r--docs/package.json4
-rw-r--r--docs/src/components/theme-designer.astro17
-rw-r--r--docs/src/components/theme-designer/atom.ts4
-rw-r--r--docs/src/components/theme-designer/color-lib.ts120
-rw-r--r--docs/src/components/theme-designer/contrast-level.astro86
-rw-r--r--docs/src/components/theme-designer/presets.astro4
-rw-r--r--docs/src/components/theme-designer/store.ts3
-rw-r--r--docs/src/content/docs/guides/css-and-tailwind.mdx5
-rw-r--r--pnpm-lock.yaml16
9 files changed, 217 insertions, 42 deletions
diff --git a/docs/package.json b/docs/package.json
index 7eafbf4a..acd93203 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -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": {
diff --git a/docs/src/components/theme-designer.astro b/docs/src/components/theme-designer.astro
index 4138dcaa..286b2075 100644
--- a/docs/src/components/theme-designer.astro
+++ b/docs/src/components/theme-designer.astro
@@ -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',
@@ -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">
@@ -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();
@@ -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);
diff --git a/docs/src/components/theme-designer/atom.ts b/docs/src/components/theme-designer/atom.ts
index e1f2518c..542e015c 100644
--- a/docs/src/components/theme-designer/atom.ts
+++ b/docs/src/components/theme-designer/atom.ts
@@ -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);
+}
diff --git a/docs/src/components/theme-designer/color-lib.ts b/docs/src/components/theme-designer/color-lib.ts
index 27b52b52..3ad21985 100644
--- a/docs/src/components/theme-designer/color-lib.ts
+++ b/docs/src/components/theme-designer/color-lib.ts
@@ -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>,
+ };
}
/*
diff --git a/docs/src/components/theme-designer/contrast-level.astro b/docs/src/components/theme-designer/contrast-level.astro
new file mode 100644
index 00000000..6d89eb50
--- /dev/null
+++ b/docs/src/components/theme-designer/contrast-level.astro
@@ -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>
diff --git a/docs/src/components/theme-designer/presets.astro b/docs/src/components/theme-designer/presets.astro
index 140cf1ae..b0c8d73d 100644
--- a/docs/src/components/theme-designer/presets.astro
+++ b/docs/src/components/theme-designer/presets.astro
@@ -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);
diff --git a/docs/src/components/theme-designer/store.ts b/docs/src/components/theme-designer/store.ts
index 6eb1aa82..6466a845 100644
--- a/docs/src/components/theme-designer/store.ts
+++ b/docs/src/components/theme-designer/store.ts
@@ -1,4 +1,4 @@
-import { map } from './atom';
+import { atom, map } from './atom';
export const presets = {
ocean: {
@@ -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) {
diff --git a/docs/src/content/docs/guides/css-and-tailwind.mdx b/docs/src/content/docs/guides/css-and-tailwind.mdx
index 45c88e65..405b8e27 100644
--- a/docs/src/content/docs/guides/css-and-tailwind.mdx
+++ b/docs/src/content/docs/guides/css-and-tailwind.mdx
@@ -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';
@@ -266,6 +268,9 @@ import ThemeDesigner from '~/components/theme-designer.astro';
default: 'Default',
random: 'Random',
},
+ contrast: {
+ label: 'Contrast Level',
+ },
editor: {
accentColor: 'Accent',
grayColor: 'Gray',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4253d88d..4fb4f8fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,14 +48,14 @@ importers:
specifier: ^0.1.1
version: 0.1.1
'@types/culori':
- specifier: ^2.0.0
- version: 2.0.0
+ specifier: ^2.1.1
+ version: 2.1.1
astro:
specifier: ^4.10.2
version: 4.10.2(@types/node@18.16.19)(typescript@5.4.5)
culori:
- specifier: ^3.2.0
- version: 3.2.0
+ specifier: ^4.0.1
+ version: 4.0.1
sharp:
specifier: ^0.32.5
version: 0.32.6
@@ -2088,8 +2088,8 @@ packages:
/@types/cookie@0.6.0:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
- /@types/culori@2.0.0:
- resolution: {integrity: sha512-bKpEra39sQS9UZ+1JoWhuGJEzwKS0dUkNCohVYmn6CAEBkqyIXimKiPDRZWtiOB7sKgkWMaTUpHFimygRoGIlg==}
+ /@types/culori@2.1.1:
+ resolution: {integrity: sha512-NzLYD0vNHLxTdPp8+RlvGbR2NfOZkwxcYGFwxNtm+WH2NuUNV8785zv1h0sulFQ5aFQ9n/jNDUuJeo3Bh7+oFA==}
dev: false
/@types/debug@4.1.12:
@@ -3093,8 +3093,8 @@ packages:
stream-transform: 2.1.3
dev: true
- /culori@3.2.0:
- resolution: {integrity: sha512-HIEbTSP7vs1mPq/2P9In6QyFE0Tkpevh0k9a+FkjhD+cwsYm9WRSbn4uMdW9O0yXlNYC3ppxL3gWWPOcvEl57w==}
+ /culori@4.0.1:
+ resolution: {integrity: sha512-LSnjA6HuIUOlkfKVbzi2OlToZE8OjFi667JWN9qNymXVXzGDmvuP60SSgC+e92sd7B7158f7Fy3Mb6rXS5EDPw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false