@adrienlcp/styles 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/color-tokens.d.ts +15 -0
- package/dist/color-tokens.js +64 -0
- package/dist/color.d.ts +16 -0
- package/dist/color.js +73 -0
- package/dist/contrast.d.ts +40 -0
- package/dist/contrast.js +57 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -94,3 +94,40 @@ into; `font-face` declares one self-hosted `woff2` file.
|
|
|
94
94
|
|
|
95
95
|
`$weight`, `$style` (`normal`), `$stretch` (left out) and `$display` (`swap`)
|
|
96
96
|
are optional.
|
|
97
|
+
|
|
98
|
+
## TypeScript
|
|
99
|
+
|
|
100
|
+
### `contrast`
|
|
101
|
+
|
|
102
|
+
A palette is checked where it is written, not by eye: a test lists the token
|
|
103
|
+
pairs that meet on screen, and fails when one falls under its WCAG minimum in
|
|
104
|
+
the light or the dark scheme.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { readFileSync } from 'node:fs'
|
|
108
|
+
|
|
109
|
+
import { findContrastFailures, WCAG_AA } from '@adrienlcp/styles/contrast'
|
|
110
|
+
import { expect, it } from 'vitest'
|
|
111
|
+
|
|
112
|
+
const TOKENS = readFileSync(new URL('_tokens.sass', import.meta.url), 'utf8')
|
|
113
|
+
|
|
114
|
+
it('every ink reads on every surface, in both themes', () => {
|
|
115
|
+
expect(
|
|
116
|
+
findContrastFailures(TOKENS, [
|
|
117
|
+
{ foreground: '--ink-soft', background: '--ground', minimum: WCAG_AA.text },
|
|
118
|
+
{ foreground: '--focus', background: '--ground', minimum: WCAG_AA.nonText }
|
|
119
|
+
])
|
|
120
|
+
).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- `WCAG_AA` is `text` (4.5), `largeText` and `nonText` (3) — controls, icons,
|
|
125
|
+
focus rings.
|
|
126
|
+
- The stylesheet is `.css` or indented `.sass`. A token is `oklch()` or hex,
|
|
127
|
+
`light-dark()` of those, or `var()` of another token.
|
|
128
|
+
- A failure is `too-low`, with the ratio and the schemes it fails in, or
|
|
129
|
+
`unreadable`: a token undeclared, declared twice with different values, a
|
|
130
|
+
reference loop, a colour it cannot read (computed, or another notation), or a
|
|
131
|
+
translucent background — what shows through decides that contrast.
|
|
132
|
+
- A translucent foreground is measured over its background. An `oklch()`
|
|
133
|
+
outside sRGB is clipped, as an sRGB screen draws it.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Result } from '@adrienlcp/result';
|
|
2
|
+
import { type Color } from './color.ts';
|
|
3
|
+
export type ColorScheme = 'dark' | 'light';
|
|
4
|
+
export type TokenError = 'circular-reference' | 'declared-twice' | 'undeclared' | 'unsupported-color';
|
|
5
|
+
/** Every custom property a stylesheet declares, with each value it is given. */
|
|
6
|
+
export type TokenDeclarations = ReadonlyMap<string, readonly string[]>;
|
|
7
|
+
/** Reads the custom properties of a `.css` or indented `.sass` source. */
|
|
8
|
+
export declare const readTokenDeclarations: (stylesheet: string) => TokenDeclarations;
|
|
9
|
+
type Resolution = Result<Color, {
|
|
10
|
+
error: TokenError;
|
|
11
|
+
token: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** The colour a token paints in one scheme, following `var()` and `light-dark()`. */
|
|
14
|
+
export declare const resolveToken: (declarations: TokenDeclarations, token: string, scheme: ColorScheme, visited?: ReadonlySet<string>) => Resolution;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Result } from '@adrienlcp/result';
|
|
2
|
+
import { parseColor } from './color.js';
|
|
3
|
+
const DECLARATION_PATTERN = /(--[\w-]+)\s*:\s*([^;\n}]+)/g;
|
|
4
|
+
const COMMENT_PATTERN = /\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm;
|
|
5
|
+
const VAR_PATTERN = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/;
|
|
6
|
+
const LIGHT_DARK_PATTERN = /^light-dark\((.+)\)$/;
|
|
7
|
+
/** Reads the custom properties of a `.css` or indented `.sass` source. */
|
|
8
|
+
export const readTokenDeclarations = (stylesheet) => {
|
|
9
|
+
const declarations = new Map();
|
|
10
|
+
for (const [, name = '', value = ''] of stylesheet
|
|
11
|
+
.replace(COMMENT_PATTERN, '')
|
|
12
|
+
.matchAll(DECLARATION_PATTERN)) {
|
|
13
|
+
const values = declarations.get(name) ?? [];
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
if (!values.includes(trimmed))
|
|
16
|
+
values.push(trimmed);
|
|
17
|
+
declarations.set(name, values);
|
|
18
|
+
}
|
|
19
|
+
return declarations;
|
|
20
|
+
};
|
|
21
|
+
/** Splits `a, b` on its top-level comma, the one no parenthesis encloses. */
|
|
22
|
+
const splitArguments = (list) => {
|
|
23
|
+
let depth = 0;
|
|
24
|
+
for (const [index, character] of [...list].entries()) {
|
|
25
|
+
if (character === '(')
|
|
26
|
+
depth += 1;
|
|
27
|
+
if (character === ')')
|
|
28
|
+
depth -= 1;
|
|
29
|
+
if (character === ',' && depth === 0)
|
|
30
|
+
return [list.slice(0, index).trim(), list.slice(index + 1).trim()];
|
|
31
|
+
}
|
|
32
|
+
return [list.trim()];
|
|
33
|
+
};
|
|
34
|
+
const resolveValue = (declarations, value, scheme, token, visited) => {
|
|
35
|
+
const reference = value.match(VAR_PATTERN);
|
|
36
|
+
if (reference) {
|
|
37
|
+
const [, name = '', fallback] = reference;
|
|
38
|
+
if (!declarations.has(name) && fallback)
|
|
39
|
+
return resolveValue(declarations, fallback, scheme, token, visited);
|
|
40
|
+
return resolveToken(declarations, name, scheme, visited);
|
|
41
|
+
}
|
|
42
|
+
const lightDark = value.match(LIGHT_DARK_PATTERN);
|
|
43
|
+
if (lightDark?.[1]) {
|
|
44
|
+
const [light = '', dark = light] = splitArguments(lightDark[1]);
|
|
45
|
+
const chosen = scheme === 'light' ? light : dark;
|
|
46
|
+
return resolveValue(declarations, chosen, scheme, token, visited);
|
|
47
|
+
}
|
|
48
|
+
const color = parseColor(value);
|
|
49
|
+
if (color.status === 'failure')
|
|
50
|
+
return Result.failure({ error: color.error, token });
|
|
51
|
+
return color;
|
|
52
|
+
};
|
|
53
|
+
/** The colour a token paints in one scheme, following `var()` and `light-dark()`. */
|
|
54
|
+
export const resolveToken = (declarations, token, scheme, visited = new Set()) => {
|
|
55
|
+
if (visited.has(token))
|
|
56
|
+
return Result.failure({ error: 'circular-reference', token });
|
|
57
|
+
const values = declarations.get(token);
|
|
58
|
+
if (!values)
|
|
59
|
+
return Result.failure({ error: 'undeclared', token });
|
|
60
|
+
const [value] = values;
|
|
61
|
+
if (values.length > 1 || value === undefined)
|
|
62
|
+
return Result.failure({ error: 'declared-twice', token });
|
|
63
|
+
return resolveValue(declarations, value, scheme, token, new Set([...visited, token]));
|
|
64
|
+
};
|
package/dist/color.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A colour in gamma-encoded sRGB, each channel and the alpha in `[0, 1]`. */
|
|
2
|
+
export type Color = {
|
|
3
|
+
alpha: number;
|
|
4
|
+
blue: number;
|
|
5
|
+
green: number;
|
|
6
|
+
red: number;
|
|
7
|
+
};
|
|
8
|
+
/** Reads an `oklch()` or hex colour, the two forms a token is written in. */
|
|
9
|
+
export declare const parseColor: (value: string) => import("@adrienlcp/result").FailureResult<"unsupported-color"> | {
|
|
10
|
+
data: Color;
|
|
11
|
+
status: "success";
|
|
12
|
+
};
|
|
13
|
+
/** Paints a translucent colour over an opaque one, blending in gamma-encoded sRGB as browsers do. */
|
|
14
|
+
export declare const composite: (top: Color, beneath: Color) => Color;
|
|
15
|
+
/** The WCAG 2 contrast ratio of two opaque colours, from 1 to 21. */
|
|
16
|
+
export declare const contrastRatio: (first: Color, second: Color) => number;
|
package/dist/color.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Result } from '@adrienlcp/result';
|
|
2
|
+
const OKLCH_PATTERN = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+(?:deg)?|none)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/i;
|
|
3
|
+
const HEX_PATTERN = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i;
|
|
4
|
+
const FULL_OKLCH_CHROMA = 0.4;
|
|
5
|
+
const parseAmount = (value, whole) => value.endsWith('%')
|
|
6
|
+
? (Number.parseFloat(value) / 100) * whole
|
|
7
|
+
: Number.parseFloat(value);
|
|
8
|
+
const clampUnit = (value) => Math.min(1, Math.max(0, value));
|
|
9
|
+
const encodeGamma = (linear) => linear <= 0.0031308 ? 12.92 * linear : 1.055 * linear ** (1 / 2.4) - 0.055;
|
|
10
|
+
const decodeGamma = (encoded) => encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
|
|
11
|
+
/** OKLCH to sRGB through OKLab; an out-of-gamut channel is clipped, as a browser on an sRGB screen draws it. */
|
|
12
|
+
const fromOklch = (lightness, chroma, hueDegrees, alpha) => {
|
|
13
|
+
const hue = (hueDegrees * Math.PI) / 180;
|
|
14
|
+
const a = chroma * Math.cos(hue);
|
|
15
|
+
const b = chroma * Math.sin(hue);
|
|
16
|
+
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
17
|
+
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
18
|
+
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
19
|
+
const toChannel = (linear) => encodeGamma(clampUnit(linear));
|
|
20
|
+
return {
|
|
21
|
+
alpha: clampUnit(alpha),
|
|
22
|
+
blue: toChannel(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
23
|
+
green: toChannel(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
24
|
+
red: toChannel(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const parseOklch = (match) => {
|
|
28
|
+
const [, lightness = '', chroma = '', hue = '', alpha = '1'] = match;
|
|
29
|
+
return fromOklch(parseAmount(lightness, 1), parseAmount(chroma, FULL_OKLCH_CHROMA), hue === 'none' ? 0 : Number.parseFloat(hue), parseAmount(alpha, 1));
|
|
30
|
+
};
|
|
31
|
+
const parseHex = (digits) => {
|
|
32
|
+
const full = digits.length <= 4
|
|
33
|
+
? [...digits].map((digit) => digit + digit).join('')
|
|
34
|
+
: digits;
|
|
35
|
+
const channel = (index) => Number.parseInt(full.slice(index * 2, index * 2 + 2), 16) / 255;
|
|
36
|
+
return {
|
|
37
|
+
alpha: full.length === 8 ? channel(3) : 1,
|
|
38
|
+
blue: channel(2),
|
|
39
|
+
green: channel(1),
|
|
40
|
+
red: channel(0)
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** Reads an `oklch()` or hex colour, the two forms a token is written in. */
|
|
44
|
+
export const parseColor = (value) => {
|
|
45
|
+
const oklch = value.match(OKLCH_PATTERN);
|
|
46
|
+
if (oklch)
|
|
47
|
+
return Result.success(parseOklch(oklch));
|
|
48
|
+
const hex = value.match(HEX_PATTERN);
|
|
49
|
+
if (hex?.[1])
|
|
50
|
+
return Result.success(parseHex(hex[1]));
|
|
51
|
+
return Result.failure('unsupported-color');
|
|
52
|
+
};
|
|
53
|
+
/** Paints a translucent colour over an opaque one, blending in gamma-encoded sRGB as browsers do. */
|
|
54
|
+
export const composite = (top, beneath) => {
|
|
55
|
+
const blend = (over, under) => over * top.alpha + under * (1 - top.alpha);
|
|
56
|
+
return {
|
|
57
|
+
alpha: 1,
|
|
58
|
+
blue: blend(top.blue, beneath.blue),
|
|
59
|
+
green: blend(top.green, beneath.green),
|
|
60
|
+
red: blend(top.red, beneath.red)
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const relativeLuminance = (color) => 0.2126 * decodeGamma(color.red) +
|
|
64
|
+
0.7152 * decodeGamma(color.green) +
|
|
65
|
+
0.0722 * decodeGamma(color.blue);
|
|
66
|
+
/** The WCAG 2 contrast ratio of two opaque colours, from 1 to 21. */
|
|
67
|
+
export const contrastRatio = (first, second) => {
|
|
68
|
+
const [lighter, darker] = [
|
|
69
|
+
relativeLuminance(first),
|
|
70
|
+
relativeLuminance(second)
|
|
71
|
+
].sort((left, right) => right - left);
|
|
72
|
+
return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05);
|
|
73
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ColorScheme, type TokenError } from './color-tokens.ts';
|
|
2
|
+
export type { ColorScheme, TokenError };
|
|
3
|
+
/** WCAG 2.2 level AA minimum contrast ratios. */
|
|
4
|
+
export declare const WCAG_AA: {
|
|
5
|
+
/** Text at 24px, or 18.66px bold, and larger. */
|
|
6
|
+
readonly largeText: 3;
|
|
7
|
+
/** Controls, icons, focus rings, the boundary of an input. */
|
|
8
|
+
readonly nonText: 3;
|
|
9
|
+
/** Body text, labels, placeholders. */
|
|
10
|
+
readonly text: 4.5;
|
|
11
|
+
};
|
|
12
|
+
/** Two tokens that meet on screen, named as declared: `{ foreground: '--ink', background: '--ground', minimum: WCAG_AA.text }`. */
|
|
13
|
+
export type ContrastPair = {
|
|
14
|
+
background: string;
|
|
15
|
+
foreground: string;
|
|
16
|
+
minimum: number;
|
|
17
|
+
};
|
|
18
|
+
export type ContrastFailure = {
|
|
19
|
+
kind: 'too-low';
|
|
20
|
+
pair: ContrastPair;
|
|
21
|
+
ratio: number;
|
|
22
|
+
schemes: ColorScheme[];
|
|
23
|
+
} | {
|
|
24
|
+
error: TokenError | 'translucent-background';
|
|
25
|
+
kind: 'unreadable';
|
|
26
|
+
pair: ContrastPair;
|
|
27
|
+
token: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Every pair below its minimum, in either scheme, read straight from the
|
|
31
|
+
* stylesheet that declares the tokens. A translucent foreground is measured
|
|
32
|
+
* over its background; a translucent background cannot be measured, since
|
|
33
|
+
* what shows through decides it.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* expect(findContrastFailures(tokens, [
|
|
37
|
+
* { foreground: '--ink-soft', background: '--ground', minimum: WCAG_AA.text }
|
|
38
|
+
* ])).toEqual([])
|
|
39
|
+
*/
|
|
40
|
+
export declare const findContrastFailures: (stylesheet: string, pairs: readonly ContrastPair[]) => ContrastFailure[];
|
package/dist/contrast.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { composite, contrastRatio } from './color.js';
|
|
2
|
+
import { readTokenDeclarations, resolveToken } from './color-tokens.js';
|
|
3
|
+
/** WCAG 2.2 level AA minimum contrast ratios. */
|
|
4
|
+
export const WCAG_AA = {
|
|
5
|
+
/** Text at 24px, or 18.66px bold, and larger. */
|
|
6
|
+
largeText: 3,
|
|
7
|
+
/** Controls, icons, focus rings, the boundary of an input. */
|
|
8
|
+
nonText: 3,
|
|
9
|
+
/** Body text, labels, placeholders. */
|
|
10
|
+
text: 4.5
|
|
11
|
+
};
|
|
12
|
+
const SCHEMES = ['light', 'dark'];
|
|
13
|
+
const roundRatio = (ratio) => Math.floor(ratio * 100) / 100;
|
|
14
|
+
const sameFailure = (failure, other) => JSON.stringify({ ...failure, schemes: [] }) ===
|
|
15
|
+
JSON.stringify({ ...other, schemes: [] });
|
|
16
|
+
const checkPair = (declarations, pair, scheme) => {
|
|
17
|
+
const background = resolveToken(declarations, pair.background, scheme);
|
|
18
|
+
if (background.status === 'failure')
|
|
19
|
+
return { kind: 'unreadable', pair, ...background.error };
|
|
20
|
+
if (background.data.alpha < 1)
|
|
21
|
+
return {
|
|
22
|
+
error: 'translucent-background',
|
|
23
|
+
kind: 'unreadable',
|
|
24
|
+
pair,
|
|
25
|
+
token: pair.background
|
|
26
|
+
};
|
|
27
|
+
const foreground = resolveToken(declarations, pair.foreground, scheme);
|
|
28
|
+
if (foreground.status === 'failure')
|
|
29
|
+
return { kind: 'unreadable', pair, ...foreground.error };
|
|
30
|
+
const ratio = roundRatio(contrastRatio(composite(foreground.data, background.data), background.data));
|
|
31
|
+
if (ratio >= pair.minimum)
|
|
32
|
+
return undefined;
|
|
33
|
+
return { kind: 'too-low', pair, ratio, schemes: [scheme] };
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Every pair below its minimum, in either scheme, read straight from the
|
|
37
|
+
* stylesheet that declares the tokens. A translucent foreground is measured
|
|
38
|
+
* over its background; a translucent background cannot be measured, since
|
|
39
|
+
* what shows through decides it.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* expect(findContrastFailures(tokens, [
|
|
43
|
+
* { foreground: '--ink-soft', background: '--ground', minimum: WCAG_AA.text }
|
|
44
|
+
* ])).toEqual([])
|
|
45
|
+
*/
|
|
46
|
+
export const findContrastFailures = (stylesheet, pairs) => {
|
|
47
|
+
const declarations = readTokenDeclarations(stylesheet);
|
|
48
|
+
return pairs.flatMap((pair) => {
|
|
49
|
+
const [light, dark] = SCHEMES.map((scheme) => checkPair(declarations, pair, scheme));
|
|
50
|
+
if (light && sameFailure(light, dark)) {
|
|
51
|
+
if (light.kind === 'unreadable')
|
|
52
|
+
return [light];
|
|
53
|
+
return [{ ...light, schemes: [...SCHEMES] }];
|
|
54
|
+
}
|
|
55
|
+
return [light, dark].filter((failure) => failure !== undefined);
|
|
56
|
+
});
|
|
57
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"author": "Adrien Lacourpaille",
|
|
3
3
|
"bugs": "https://github.com/AdrienLcp/packages/issues",
|
|
4
|
-
"
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@adrienlcp/result": "^0.1.0"
|
|
6
|
+
},
|
|
7
|
+
"description": "A reset, a reduced-motion switch, Sass mixins for self-hosted fonts, container queries and a breakpoint, and a WCAG contrast check for colour tokens",
|
|
5
8
|
"devDependencies": {
|
|
6
9
|
"sass": "^1.105.0"
|
|
7
10
|
},
|
|
@@ -12,6 +15,7 @@
|
|
|
12
15
|
"./containers": {
|
|
13
16
|
"sass": "./src/_containers.sass"
|
|
14
17
|
},
|
|
18
|
+
"./contrast": "./dist/contrast.js",
|
|
15
19
|
"./fonts": {
|
|
16
20
|
"sass": "./src/_fonts.sass"
|
|
17
21
|
},
|
|
@@ -20,15 +24,18 @@
|
|
|
20
24
|
"./reset.css": "./src/reset.css"
|
|
21
25
|
},
|
|
22
26
|
"files": [
|
|
27
|
+
"dist",
|
|
23
28
|
"src/*.css",
|
|
24
29
|
"src/*.sass"
|
|
25
30
|
],
|
|
26
31
|
"homepage": "https://github.com/AdrienLcp/packages/tree/main/packages/styles#readme",
|
|
27
32
|
"keywords": [
|
|
33
|
+
"contrast",
|
|
28
34
|
"css",
|
|
29
35
|
"font-face",
|
|
30
36
|
"reset",
|
|
31
|
-
"sass"
|
|
37
|
+
"sass",
|
|
38
|
+
"wcag"
|
|
32
39
|
],
|
|
33
40
|
"license": "MIT",
|
|
34
41
|
"name": "@adrienlcp/styles",
|
|
@@ -49,8 +56,9 @@
|
|
|
49
56
|
"*.css"
|
|
50
57
|
],
|
|
51
58
|
"type": "module",
|
|
52
|
-
"version": "0.
|
|
59
|
+
"version": "0.5.0",
|
|
53
60
|
"scripts": {
|
|
61
|
+
"build": "tsc -p tsconfig.build.json",
|
|
54
62
|
"test": "vitest run",
|
|
55
63
|
"test:watch": "vitest --watch"
|
|
56
64
|
}
|