diff options
author | Chris Swithinbank | 2024-09-04 23:51:56 +0200 |
---|---|---|
committer | GitHub | 2024-09-04 23:51:56 +0200 |
commit | 782def032190875cf65cf3b46ba813ed2474e3f8 (patch) | |
tree | 10fa07fc04ff1937942ad95d238f45643cae7476 | |
parent | 4014fd44e11b9e87af32391dc525a04ba9e8373c (diff) | |
download | IT.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.json | 4 | ||||
-rw-r--r-- | docs/src/components/theme-designer.astro | 17 | ||||
-rw-r--r-- | docs/src/components/theme-designer/atom.ts | 4 | ||||
-rw-r--r-- | docs/src/components/theme-designer/color-lib.ts | 120 | ||||
-rw-r--r-- | docs/src/components/theme-designer/contrast-level.astro | 86 | ||||
-rw-r--r-- | docs/src/components/theme-designer/presets.astro | 4 | ||||
-rw-r--r-- | docs/src/components/theme-designer/store.ts | 3 | ||||
-rw-r--r-- | docs/src/content/docs/guides/css-and-tailwind.mdx | 5 | ||||
-rw-r--r-- | pnpm-lock.yaml | 16 |
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 |