@rolemodel/optics 2.5.1 → 2.6.1
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/.github/workflows/linting.yml +2 -0
- package/.prettierignore +3 -0
- package/.yarn/install-state.gz +0 -0
- package/CHANGELOG.md +41 -0
- package/README.md +16 -0
- package/build_token_json.js +2 -1
- package/dist/README.md +16 -0
- package/dist/css/core/tokens/base_color_tokens.css +74 -0
- package/dist/css/core/tokens/base_tokens.css +3 -345
- package/dist/css/core/tokens/index.css +2 -1
- package/dist/css/core/tokens/oklch/base_color_tokens.css +74 -0
- package/dist/css/core/tokens/oklch/index.css +3 -0
- package/dist/css/core/tokens/oklch/scale_color_tokens.css +1498 -0
- package/dist/css/core/tokens/shared_tokens.css +272 -0
- package/dist/css/optics+feather_icons.css +74 -73
- package/dist/css/optics+feather_icons.min.css +1 -1
- package/dist/css/optics+lucide_icons.css +74 -73
- package/dist/css/optics+lucide_icons.min.css +1 -1
- package/dist/css/optics+material_outlined_variable.css +74 -73
- package/dist/css/optics+material_outlined_variable.min.css +1 -1
- package/dist/css/optics+no_icons.css +74 -73
- package/dist/css/optics+no_icons.min.css +1 -1
- package/dist/css/optics+phosphor_icons.css +74 -73
- package/dist/css/optics+phosphor_icons.min.css +1 -1
- package/dist/css/optics+tabler_icons.css +74 -73
- package/dist/css/optics+tabler_icons.min.css +1 -1
- package/dist/css/optics-oklch+feather_icons.css +6566 -0
- package/dist/css/optics-oklch+feather_icons.min.css +1 -0
- package/dist/css/optics-oklch+lucide_icons.css +13598 -0
- package/dist/css/optics-oklch+lucide_icons.min.css +1 -0
- package/dist/css/optics-oklch+material_outlined_variable.css +5636 -0
- package/dist/css/optics-oklch+material_outlined_variable.min.css +1 -0
- package/dist/css/optics-oklch+no_icons.css +5627 -0
- package/dist/css/optics-oklch+no_icons.min.css +1 -0
- package/dist/css/optics-oklch+phosphor_icons.css +5671 -0
- package/dist/css/optics-oklch+phosphor_icons.min.css +1 -0
- package/dist/css/optics-oklch+tabler_icons.css +5638 -0
- package/dist/css/optics-oklch+tabler_icons.min.css +1 -0
- package/dist/css/optics-oklch.css +5640 -0
- package/dist/css/optics-oklch.min.css +1 -0
- package/dist/css/optics.css +74 -73
- package/dist/css/optics.min.css +1 -1
- package/dist/package.json +10 -6
- package/dist/tokens/tokens.json +16 -16
- package/dist/tools/check-contrast.cjs +47 -0
- package/dist/tools/contrast/builds/build.cjs +44 -0
- package/dist/tools/contrast/builds/hsl-build.cjs +11 -0
- package/dist/tools/contrast/builds/oklch-build.cjs +11 -0
- package/dist/tools/contrast/calc-expression.cjs +68 -0
- package/dist/tools/contrast/check-error.cjs +4 -0
- package/dist/tools/contrast/color-pair.cjs +35 -0
- package/dist/tools/contrast/colors/color.cjs +41 -0
- package/dist/tools/contrast/colors/function-color.cjs +46 -0
- package/dist/tools/contrast/colors/hex-color.cjs +19 -0
- package/dist/tools/contrast/colors/hsl-color.cjs +25 -0
- package/dist/tools/contrast/colors/oklch-color.cjs +36 -0
- package/dist/tools/contrast/colors/themed-color.cjs +37 -0
- package/dist/tools/contrast/contrast-check.cjs +37 -0
- package/dist/tools/contrast/contrast-reporter.cjs +44 -0
- package/dist/tools/contrast/contrast-result.cjs +15 -0
- package/dist/tools/contrast/css-syntax.cjs +23 -0
- package/dist/tools/contrast/stylesheet.cjs +174 -0
- package/dist/tools/contrast/theme.cjs +84 -0
- package/dist/tools/contrast/tool-options.cjs +50 -0
- package/package.json +10 -6
package/dist/package.json
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolemodel/optics",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"packageManager": "yarn@4.8.1",
|
|
5
5
|
"description": "Optics is a css package that provides base styles and components that can be integrated and customized in a variety of projects.",
|
|
6
6
|
"main": "dist/css/optics.css",
|
|
7
|
+
"bin": {
|
|
8
|
+
"optics-check-contrast": "dist/tools/check-contrast.cjs"
|
|
9
|
+
},
|
|
7
10
|
"scripts": {
|
|
8
|
-
"test": "
|
|
11
|
+
"test": "node --test 'test/**/*.test.cjs'",
|
|
9
12
|
"build": "yarn build:css && yarn build:css-min && yarn build:tokens && yarn build:files",
|
|
10
13
|
"build:css": "postcss 'src/optics*.css' -d dist/css",
|
|
11
14
|
"build:css-min": "postcss 'src/optics*.css' -d dist/css --ext .min.css --env=minify",
|
|
12
15
|
"build:tokens": "node build_token_json --source=src/core/tokens --output=dist/tokens/tokens.json",
|
|
13
|
-
"build:files": "mkdir -p dist/css/addons; cp LICENSE README.md package.json dist/; cp -rL src/addons src/core src/components dist/css",
|
|
16
|
+
"build:files": "mkdir -p dist/css/addons; cp LICENSE README.md package.json dist/; cp -rL src/addons src/core src/components dist/css; cp -r src/tools dist",
|
|
14
17
|
"build-docs": "yarn build-docs:llms && yarn build-docs:storybook",
|
|
15
18
|
"build-docs:llms": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tools/build-llms-docs.mjs",
|
|
16
19
|
"build-docs:storybook": "storybook build --docs",
|
|
17
|
-
"storybook": "storybook dev -p 6006 --docs",
|
|
20
|
+
"storybook": "yarn build-docs:llms && storybook dev -p 6006 --docs",
|
|
18
21
|
"lint": "yarn lint:js && yarn lint:css",
|
|
19
22
|
"lint-fix": "yarn lint:js --fix && yarn lint:css --fix",
|
|
20
23
|
"lint:js": "eslint 'src/stories/**/*.js'",
|
|
21
24
|
"lint:css": "stylelint 'src/**/*.css'",
|
|
22
25
|
"prettier": "prettier -w .",
|
|
23
26
|
"prettier-check": "prettier -c .",
|
|
24
|
-
"sanity-check": "yarn lint && yarn prettier && yarn build && yarn build-docs && rm -rf ./dist && rm -rf ./storybook-static && rm -rf ./llms-docs",
|
|
25
|
-
"generate": "node ./tools/generate.js"
|
|
27
|
+
"sanity-check": "yarn lint && yarn prettier && yarn test && yarn build && yarn check-contrast && yarn build-docs && rm -rf ./dist && rm -rf ./storybook-static && rm -rf ./llms-docs",
|
|
28
|
+
"generate": "node ./tools/generate.js",
|
|
29
|
+
"check-contrast": "node dist/tools/check-contrast.cjs"
|
|
26
30
|
},
|
|
27
31
|
"repository": {
|
|
28
32
|
"type": "git",
|
package/dist/tokens/tokens.json
CHANGED
|
@@ -41,9 +41,23 @@
|
|
|
41
41
|
"original": "hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) var(--op-color-alerts-notice-l))"
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
|
-
"border": "var(--op-color-neutral-plus-five)",
|
|
45
44
|
"background": "var(--op-color-neutral-plus-eight)",
|
|
46
|
-
"on-background": "var(--op-color-neutral-on-plus-eight)"
|
|
45
|
+
"on-background": "var(--op-color-neutral-on-plus-eight)",
|
|
46
|
+
"border": "var(--op-color-neutral-plus-five)"
|
|
47
|
+
},
|
|
48
|
+
"shadow": {
|
|
49
|
+
"x-small": "0 1px 3px hsl(0deg 0% 0% / 15%), 0 1px 2px hsl(0deg 0% 0% / 30%)",
|
|
50
|
+
"small": "0 2px 6px hsl(0deg 0% 0% / 15%), 0 1px 2px hsl(0deg 0% 0% / 30%)",
|
|
51
|
+
"medium": "0 4px 8px hsl(0deg 0% 0% / 15%), 0 1px 3px hsl(0deg 0% 0% / 30%)",
|
|
52
|
+
"large": "0 6px 10px hsl(0deg 0% 0% / 15%), 0 2px 3px hsl(0deg 0% 0% / 30%)",
|
|
53
|
+
"x-large": "0 8px 12px hsl(0deg 0% 0% / 15%), 0 4px 4px hsl(0deg 0% 0% / 30%)"
|
|
54
|
+
},
|
|
55
|
+
"encoded": {
|
|
56
|
+
"images": {
|
|
57
|
+
"dropdown": {
|
|
58
|
+
"arrow": "url('data"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
47
61
|
},
|
|
48
62
|
"opacity": {
|
|
49
63
|
"none": "0",
|
|
@@ -134,13 +148,6 @@
|
|
|
134
148
|
"animation": {
|
|
135
149
|
"flash": "rm-slide-in-out-flash 5s normal forwards"
|
|
136
150
|
},
|
|
137
|
-
"encoded": {
|
|
138
|
-
"images": {
|
|
139
|
-
"dropdown": {
|
|
140
|
-
"arrow": "url('data"
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
},
|
|
144
151
|
"size": {
|
|
145
152
|
"unit": "0.4rem"
|
|
146
153
|
},
|
|
@@ -157,13 +164,6 @@
|
|
|
157
164
|
"3x-large": "calc(var(--op-space-scale-unit) * 4)",
|
|
158
165
|
"4x-large": "calc(var(--op-space-scale-unit) * 8)"
|
|
159
166
|
},
|
|
160
|
-
"shadow": {
|
|
161
|
-
"x-small": "0 1px 3px hsl(0deg 0% 0% / 15%), 0 1px 2px hsl(0deg 0% 0% / 30%)",
|
|
162
|
-
"small": "0 2px 6px hsl(0deg 0% 0% / 15%), 0 1px 2px hsl(0deg 0% 0% / 30%)",
|
|
163
|
-
"medium": "0 4px 8px hsl(0deg 0% 0% / 15%), 0 1px 3px hsl(0deg 0% 0% / 30%)",
|
|
164
|
-
"large": "0 6px 10px hsl(0deg 0% 0% / 15%), 0 2px 3px hsl(0deg 0% 0% / 30%)",
|
|
165
|
-
"x-large": "0 8px 12px hsl(0deg 0% 0% / 15%), 0 4px 4px hsl(0deg 0% 0% / 30%)"
|
|
166
|
-
},
|
|
167
167
|
"z-index": {
|
|
168
168
|
"header": "500",
|
|
169
169
|
"footer": "500",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Checks WCAG 2.1 contrast between every Optics scale step and its `on` text
|
|
4
|
+
* colors, in light and dark mode (or just one, with --mode), for your theme:
|
|
5
|
+
*
|
|
6
|
+
* --op-color-primary-plus-five vs --op-color-primary-on-plus-five (needs --min)
|
|
7
|
+
* --op-color-primary-plus-five vs --op-color-primary-on-plus-five-alt (needs --alt-min)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* optics-check-contrast [files...] [--hsl | --oklch] [--mode=light | --mode=dark] [--min=4.5] [--alt-min=4.5] [--report]
|
|
11
|
+
*
|
|
12
|
+
* files CSS with your --op-* overrides: theme files, or your compiled CSS bundle.
|
|
13
|
+
* They are applied on top of the Optics tokens, the last value of each token wins.
|
|
14
|
+
* --hsl Check against the HSL tokens (optics.css). Detected from -s / hsl() in your files.
|
|
15
|
+
* --oklch Check against the OKLCH tokens (optics-oklch.css). Detected from -c / oklch().
|
|
16
|
+
* --mode Check only light or dark mode. Both are checked by default.
|
|
17
|
+
* --report List every pair, and don't fail.
|
|
18
|
+
*
|
|
19
|
+
* Tokens are read without their selectors, so overrides scoped to a class or
|
|
20
|
+
* attribute are treated as if they were on :root. Colors outside sRGB are
|
|
21
|
+
* clipped, as browsers do. Exits with 1 if any pair fails, and 2 if the theme can't be read
|
|
22
|
+
* or a color can't be measured (translucent colors, or calc() that isn't plain numbers).
|
|
23
|
+
*/
|
|
24
|
+
// CommonJS, because Yarn Plug'n'Play can't load an ES module bin from inside its zip cache.
|
|
25
|
+
const Build = require('./contrast/builds/build.cjs')
|
|
26
|
+
const CheckError = require('./contrast/check-error.cjs')
|
|
27
|
+
const ContrastCheck = require('./contrast/contrast-check.cjs')
|
|
28
|
+
const ContrastReporter = require('./contrast/contrast-reporter.cjs')
|
|
29
|
+
const Stylesheet = require('./contrast/stylesheet.cjs')
|
|
30
|
+
const Theme = require('./contrast/theme.cjs')
|
|
31
|
+
const ToolOptions = require('./contrast/tool-options.cjs')
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const options = ToolOptions.parse(process.argv.slice(2))
|
|
35
|
+
const userCss = Stylesheet.read(options.files)
|
|
36
|
+
const build = Build.for(options, userCss)
|
|
37
|
+
const theme = Theme.cascade(build.stylesheet(), userCss)
|
|
38
|
+
const check = new ContrastCheck(theme, options)
|
|
39
|
+
const reporter = new ContrastReporter(process.stdout, build, options)
|
|
40
|
+
|
|
41
|
+
for (const result of check.results()) reporter.record(result)
|
|
42
|
+
process.exitCode = reporter.finish()
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!(error instanceof CheckError)) throw error
|
|
45
|
+
console.error(`optics-check-contrast: ${error.message}`)
|
|
46
|
+
process.exit(2)
|
|
47
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const path = require('node:path')
|
|
2
|
+
const CheckError = require('../check-error.cjs')
|
|
3
|
+
const Stylesheet = require('../stylesheet.cjs')
|
|
4
|
+
|
|
5
|
+
const TOKENS_DIR = path.join(__dirname, '../../../css/core/tokens')
|
|
6
|
+
const AMBIGUOUS = 'Could not tell if your theme is HSL or OKLCH. Pass --hsl or --oklch.'
|
|
7
|
+
|
|
8
|
+
// An Optics build (optics.css or optics-oklch.css). Each subclass names its color token folder
|
|
9
|
+
// and the CSS that marks a theme as written for it.
|
|
10
|
+
class Build {
|
|
11
|
+
// Optics channel vars (-c or -s) decide first; color functions only when neither is set,
|
|
12
|
+
// so an unrelated oklch() or hsl() elsewhere in the CSS can't pick the wrong build.
|
|
13
|
+
static for({ buildName }, stylesheet) {
|
|
14
|
+
const builds = all()
|
|
15
|
+
if (buildName) return builds.find((build) => build.name === buildName)
|
|
16
|
+
|
|
17
|
+
const byChannel = builds.filter((build) => stylesheet.mentions(build.channelVariable))
|
|
18
|
+
if (byChannel.length > 1) throw new CheckError(AMBIGUOUS)
|
|
19
|
+
if (byChannel.length === 1) return byChannel[0]
|
|
20
|
+
|
|
21
|
+
const byFunction = builds.find((build) => stylesheet.mentions(build.colorFunction))
|
|
22
|
+
if (byFunction) return byFunction
|
|
23
|
+
if (stylesheet.isEmpty) return builds[0]
|
|
24
|
+
throw new CheckError(AMBIGUOUS)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
stylesheet() {
|
|
28
|
+
const colorDir = path.join(TOKENS_DIR, this.colorFolder)
|
|
29
|
+
return Stylesheet.read([
|
|
30
|
+
// Used by both builds, and defines aliases like --op-color-border.
|
|
31
|
+
path.join(TOKENS_DIR, 'shared_tokens.css'),
|
|
32
|
+
path.join(colorDir, 'base_color_tokens.css'),
|
|
33
|
+
path.join(colorDir, 'scale_color_tokens.css'),
|
|
34
|
+
])
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// OKLCH comes first: it is the default with no theme, and a theme that uses both oklch() and hsl() is checked as OKLCH.
|
|
39
|
+
// Required on first use, not at the top, because each subclass requires this file to extend Build.
|
|
40
|
+
function all() {
|
|
41
|
+
return [new (require('./oklch-build.cjs'))(), new (require('./hsl-build.cjs'))()]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = Build
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const Build = require('./build.cjs')
|
|
2
|
+
|
|
3
|
+
// optics.css. Its color tokens are in the top-level tokens folder.
|
|
4
|
+
class HslBuild extends Build {
|
|
5
|
+
name = 'hsl'
|
|
6
|
+
colorFolder = ''
|
|
7
|
+
channelVariable = /--op-color-[a-z0-9-]+-s\s*:/
|
|
8
|
+
colorFunction = /hsla?\(/
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = HslBuild
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const CheckError = require('./check-error.cjs')
|
|
2
|
+
|
|
3
|
+
// calc() with only numbers and operators inside. No parentheses, so the innermost calc() matches first.
|
|
4
|
+
const PLAIN_CALC = /calc\(([\d.\s*/+-]+)\)/g
|
|
5
|
+
const TOKEN = /\d+\.?\d*|\.\d+|\S/g
|
|
6
|
+
|
|
7
|
+
// One calc() of plain numbers. Evaluates + - * / with the usual precedence, and leading + or - signs.
|
|
8
|
+
class CalcExpression {
|
|
9
|
+
#source
|
|
10
|
+
#tokens
|
|
11
|
+
#position = 0
|
|
12
|
+
|
|
13
|
+
// Replaces each calc() of plain numbers with its result, innermost first, until none are left.
|
|
14
|
+
static resolveAll(value) {
|
|
15
|
+
let resolved = value
|
|
16
|
+
let previous
|
|
17
|
+
do {
|
|
18
|
+
previous = resolved
|
|
19
|
+
resolved = resolved.replace(PLAIN_CALC, (source, math) => String(new CalcExpression(source, math).evaluate()))
|
|
20
|
+
} while (resolved !== previous)
|
|
21
|
+
return resolved
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
constructor(source, math) {
|
|
25
|
+
this.#source = source
|
|
26
|
+
this.#tokens = math.match(TOKEN) ?? []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
evaluate() {
|
|
30
|
+
const result = this.#sum()
|
|
31
|
+
return this.#position === this.#tokens.length ? result : this.#fail()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#sum() {
|
|
35
|
+
let result = this.#product()
|
|
36
|
+
while (this.#next === '+' || this.#next === '-')
|
|
37
|
+
result = this.#take() === '+' ? result + this.#product() : result - this.#product()
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#product() {
|
|
42
|
+
let result = this.#signed()
|
|
43
|
+
while (this.#next === '*' || this.#next === '/')
|
|
44
|
+
result = this.#take() === '*' ? result * this.#signed() : result / this.#signed()
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#signed() {
|
|
49
|
+
const token = this.#take()
|
|
50
|
+
if (token === '+') return this.#signed()
|
|
51
|
+
if (token === '-') return -this.#signed()
|
|
52
|
+
return /\d/.test(token ?? '') ? Number(token) : this.#fail()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get #next() {
|
|
56
|
+
return this.#tokens[this.#position]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#take() {
|
|
60
|
+
return this.#tokens[this.#position++]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#fail() {
|
|
64
|
+
throw new CheckError(`Can't calculate: ${this.#source}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = CalcExpression
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Only base and the plus/minus steps, so derived tokens like -on-base-alpha-40 aren't paired.
|
|
2
|
+
const STEP = 'base|(?:plus|minus)-(?:one|two|three|four|five|six|seven|eight|max)'
|
|
3
|
+
const ON_TOKEN = new RegExp(`^--op-color-([a-z0-9-]+?)-on-(${STEP})(-alt)?$`)
|
|
4
|
+
|
|
5
|
+
// A scale step and a text color meant to sit on it:
|
|
6
|
+
// --op-color-primary-plus-five with --op-color-primary-on-plus-five (or -on-plus-five-alt).
|
|
7
|
+
class ColorPair {
|
|
8
|
+
#isAlt
|
|
9
|
+
|
|
10
|
+
// Every `on` and `on-alt` token in the theme whose scale step is also in the theme.
|
|
11
|
+
static findAll(theme) {
|
|
12
|
+
return theme.names().flatMap((text) => {
|
|
13
|
+
const match = text.match(ON_TOKEN)
|
|
14
|
+
if (!match) return []
|
|
15
|
+
|
|
16
|
+
const [, color, step, alt] = match
|
|
17
|
+
const background = `--op-color-${color}-${step}`
|
|
18
|
+
// has(), not the value's truthiness, so an empty override reaches color validation instead of dropping the pair.
|
|
19
|
+
return theme.has(background) ? [new ColorPair(background, text, Boolean(alt))] : []
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
constructor(background, text, isAlt) {
|
|
24
|
+
this.background = background
|
|
25
|
+
this.text = text
|
|
26
|
+
this.#isAlt = isAlt
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `on-alt` text has its own minimum, so a theme can allow AA large text there.
|
|
30
|
+
minimum({ min, altMin }) {
|
|
31
|
+
return this.#isAlt ? altMin : min
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = ColorPair
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const CheckError = require('../check-error.cjs')
|
|
2
|
+
|
|
3
|
+
// A CSS color. Each subclass reads one format and gives its channels in linear sRGB.
|
|
4
|
+
class Color {
|
|
5
|
+
// Picks the subclass whose `pattern` matches, and passes it the text inside the match.
|
|
6
|
+
static parse(text) {
|
|
7
|
+
const value = text.trim()
|
|
8
|
+
for (const Format of formats()) {
|
|
9
|
+
const match = value.match(Format.pattern)
|
|
10
|
+
if (match) return new Format(match[1], value)
|
|
11
|
+
}
|
|
12
|
+
throw new CheckError(`Can't check color: ${value || '(empty value)'}`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Converts a gamma-encoded sRGB channel (0 to 1) to linear light.
|
|
16
|
+
static linearize(channel) {
|
|
17
|
+
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
linearRgb() {
|
|
21
|
+
throw new Error(`${this.constructor.name} must implement linearRgb()`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// WCAG relative luminance, from 0 to 1. Channels outside sRGB are clipped, as browsers do on most screens.
|
|
25
|
+
get luminance() {
|
|
26
|
+
const [r, g, b] = this.linearRgb().map((channel) => Math.min(1, Math.max(0, channel)))
|
|
27
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
contrastWith(other) {
|
|
31
|
+
const [first, second] = [this.luminance, other.luminance]
|
|
32
|
+
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Required on first use, not at the top, because each subclass requires this file to extend Color.
|
|
37
|
+
function formats() {
|
|
38
|
+
return [require('./hex-color.cjs'), require('./oklch-color.cjs'), require('./hsl-color.cjs')]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = Color
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const CheckError = require('../check-error.cjs')
|
|
2
|
+
const Color = require('./color.cjs')
|
|
3
|
+
|
|
4
|
+
// A color written as a CSS function, like oklch(l c h / alpha) or the legacy hsl(h, s, l, alpha).
|
|
5
|
+
// Translucent colors are rejected, because translucent text depends on what is behind it.
|
|
6
|
+
class FunctionColor extends Color {
|
|
7
|
+
#channels
|
|
8
|
+
|
|
9
|
+
constructor(args, value) {
|
|
10
|
+
super()
|
|
11
|
+
const [channelText, slashAlpha] = args.split('/')
|
|
12
|
+
this.#channels = channelText.trim().split(/[\s,]+/)
|
|
13
|
+
|
|
14
|
+
const alpha = slashAlpha?.trim() ?? (channelText.includes(',') ? this.#channels[3] : undefined)
|
|
15
|
+
if (alpha === undefined) return
|
|
16
|
+
const opacity = fraction(alpha, 1)
|
|
17
|
+
// NaN < 1 is false, so an unresolved alpha would otherwise count as opaque.
|
|
18
|
+
if (!Number.isFinite(opacity)) throw new CheckError(`Can't measure alpha: ${value}`)
|
|
19
|
+
if (opacity < 1) throw new CheckError(`Can't check translucent color: ${value}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
number(index) {
|
|
23
|
+
return parseFloat(this.#channels[index])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The channel as a number, or as a percentage of `whole`.
|
|
27
|
+
fraction(index, whole) {
|
|
28
|
+
return fraction(this.#channels[index], whole)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The channel as a hue in degrees from 0 to 360, from any CSS angle unit (deg is the default).
|
|
32
|
+
hue(index) {
|
|
33
|
+
const angle = this.#channels[index] ?? ''
|
|
34
|
+
const value = parseFloat(angle)
|
|
35
|
+
const unit = angle.match(/[a-z]+$/i)?.[0].toLowerCase()
|
|
36
|
+
const degrees = { rad: (value * 180) / Math.PI, grad: value * 0.9, turn: value * 360 }[unit] ?? value
|
|
37
|
+
return ((degrees % 360) + 360) % 360
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fraction(channel, whole) {
|
|
42
|
+
const number = parseFloat(channel)
|
|
43
|
+
return channel?.endsWith('%') ? (number / 100) * whole : number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = FunctionColor
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const Color = require('./color.cjs')
|
|
2
|
+
|
|
3
|
+
// #rgb or #rrggbb.
|
|
4
|
+
class HexColor extends Color {
|
|
5
|
+
static pattern = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
6
|
+
|
|
7
|
+
#digits
|
|
8
|
+
|
|
9
|
+
constructor(digits) {
|
|
10
|
+
super()
|
|
11
|
+
this.#digits = digits.length === 3 ? [...digits].map((digit) => digit + digit).join('') : digits
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
linearRgb() {
|
|
15
|
+
return this.#digits.match(/../g).map((pair) => Color.linearize(parseInt(pair, 16) / 255))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = HexColor
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const Color = require('./color.cjs')
|
|
2
|
+
const FunctionColor = require('./function-color.cjs')
|
|
3
|
+
|
|
4
|
+
// hsl() or hsla(), in the modern or the legacy comma syntax.
|
|
5
|
+
class HslColor extends FunctionColor {
|
|
6
|
+
static pattern = /^hsla?\((.*)\)$/s
|
|
7
|
+
|
|
8
|
+
// CSS clamps out-of-range channels (e.g. hsl(240 200% 75%) is drawn at 100% saturation).
|
|
9
|
+
linearRgb() {
|
|
10
|
+
const hue = this.hue(0)
|
|
11
|
+
const saturation = this.#percent(1)
|
|
12
|
+
const lightness = this.#percent(2)
|
|
13
|
+
const channel = (n) => {
|
|
14
|
+
const k = (n + hue / 30) % 12
|
|
15
|
+
return lightness - saturation * Math.min(lightness, 1 - lightness) * Math.max(-1, Math.min(k - 3, 9 - k, 1))
|
|
16
|
+
}
|
|
17
|
+
return [channel(0), channel(8), channel(4)].map(Color.linearize)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#percent(index) {
|
|
21
|
+
return Math.min(1, Math.max(0, this.number(index) / 100))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = HslColor
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const FunctionColor = require('./function-color.cjs')
|
|
2
|
+
|
|
3
|
+
// In CSS, 100% chroma is 0.4.
|
|
4
|
+
const FULL_CHROMA = 0.4
|
|
5
|
+
|
|
6
|
+
class OklchColor extends FunctionColor {
|
|
7
|
+
static pattern = /^oklch\((.*)\)$/s
|
|
8
|
+
|
|
9
|
+
// OKLCH -> OKLab -> LMS -> linear sRGB (https://bottosson.github.io/posts/oklab/)
|
|
10
|
+
// CSS clamps out-of-range lightness and chroma.
|
|
11
|
+
linearRgb() {
|
|
12
|
+
const lightness = Math.min(1, Math.max(0, this.fraction(0, 1)))
|
|
13
|
+
const chroma = Math.max(0, this.fraction(1, FULL_CHROMA))
|
|
14
|
+
const hue = (this.hue(2) * Math.PI) / 180
|
|
15
|
+
|
|
16
|
+
// CSS gamut mapping draws 0% lightness as black and 100% as white, whatever the chroma and hue.
|
|
17
|
+
// Only when they are numbers, so an unresolved channel still stops the check as unmeasurable.
|
|
18
|
+
if (Number.isFinite(chroma) && Number.isFinite(hue)) {
|
|
19
|
+
if (lightness === 0) return [0, 0, 0]
|
|
20
|
+
if (lightness === 1) return [1, 1, 1]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const a = chroma * Math.cos(hue)
|
|
24
|
+
const b = chroma * Math.sin(hue)
|
|
25
|
+
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
|
26
|
+
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
|
27
|
+
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
|
|
28
|
+
return [
|
|
29
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
30
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
31
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = OklchColor
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const CheckError = require('../check-error.cjs')
|
|
2
|
+
const Color = require('./color.cjs')
|
|
3
|
+
const { splitAtFirstComma } = require('../css-syntax.cjs')
|
|
4
|
+
|
|
5
|
+
const LIGHT_DARK = /^light-dark\((.*)\)$/s
|
|
6
|
+
|
|
7
|
+
// A token's color in light and dark mode.
|
|
8
|
+
class ThemedColor {
|
|
9
|
+
static MODES = ['light', 'dark']
|
|
10
|
+
|
|
11
|
+
#light
|
|
12
|
+
#dark
|
|
13
|
+
|
|
14
|
+
// light-dark(a, b) gives a in light mode and b in dark mode. Any other color is the same in both.
|
|
15
|
+
static parse(value) {
|
|
16
|
+
const inner = value.match(LIGHT_DARK)?.[1]
|
|
17
|
+
if (inner === undefined) {
|
|
18
|
+
const color = Color.parse(value)
|
|
19
|
+
return new ThemedColor(color, color)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const colors = splitAtFirstComma(inner)
|
|
23
|
+
if (colors.length !== 2) throw new CheckError(`Can't check color: ${value}`)
|
|
24
|
+
return new ThemedColor(Color.parse(colors[0]), Color.parse(colors[1]))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
constructor(light, dark) {
|
|
28
|
+
this.#light = light
|
|
29
|
+
this.#dark = dark
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
in(mode) {
|
|
33
|
+
return mode === 'light' ? this.#light : this.#dark
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = ThemedColor
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const CheckError = require('./check-error.cjs')
|
|
2
|
+
const ColorPair = require('./color-pair.cjs')
|
|
3
|
+
const ContrastResult = require('./contrast-result.cjs')
|
|
4
|
+
const ThemedColor = require('./colors/themed-color.cjs')
|
|
5
|
+
|
|
6
|
+
// Measures every scale step against its `on` and `on-alt` text colors, in light and dark mode, or just --mode.
|
|
7
|
+
class ContrastCheck {
|
|
8
|
+
#theme
|
|
9
|
+
#options
|
|
10
|
+
|
|
11
|
+
constructor(theme, options) {
|
|
12
|
+
this.#theme = theme
|
|
13
|
+
this.#options = options
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Yields one result per pair and mode, so a report can print each one as it is measured.
|
|
17
|
+
*results() {
|
|
18
|
+
const modes = this.#options.mode ? [this.#options.mode] : ThemedColor.MODES
|
|
19
|
+
for (const pair of ColorPair.findAll(this.#theme)) {
|
|
20
|
+
const background = this.#colorOf(pair.background)
|
|
21
|
+
const text = this.#colorOf(pair.text)
|
|
22
|
+
for (const mode of modes) {
|
|
23
|
+
const ratio = background.in(mode).contrastWith(text.in(mode))
|
|
24
|
+
// NaN < min is false, so an unmeasurable pair would otherwise pass.
|
|
25
|
+
if (!Number.isFinite(ratio))
|
|
26
|
+
throw new CheckError(`Can't measure ${pair.text} (${mode}) against ${pair.background}`)
|
|
27
|
+
yield new ContrastResult(pair, mode, ratio, pair.minimum(this.#options))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#colorOf(token) {
|
|
33
|
+
return ThemedColor.parse(this.#theme.resolve(token))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = ContrastCheck
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Writes the results to an output stream, and decides the exit code: 1 if any pair fails, unless --report was passed.
|
|
2
|
+
class ContrastReporter {
|
|
3
|
+
#output
|
|
4
|
+
#build
|
|
5
|
+
#options
|
|
6
|
+
#count = 0
|
|
7
|
+
#failures = []
|
|
8
|
+
|
|
9
|
+
constructor(output, build, options) {
|
|
10
|
+
this.#output = output
|
|
11
|
+
this.#build = build
|
|
12
|
+
this.#options = options
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
record(result) {
|
|
16
|
+
this.#count++
|
|
17
|
+
if (!result.passes) this.#failures.push(result)
|
|
18
|
+
if (this.#options.report) this.#print(pairLine(result))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
finish() {
|
|
22
|
+
const { mode, min, altMin, report } = this.#options
|
|
23
|
+
const passed = this.#count - this.#failures.length
|
|
24
|
+
const checked = mode ? ` (${mode})` : ''
|
|
25
|
+
this.#print(
|
|
26
|
+
`Optics ${this.#build.name} contrast${checked}: ${passed}/${this.#count} pairs pass (on >= ${min}, on-alt >= ${altMin})`
|
|
27
|
+
)
|
|
28
|
+
if (report || !this.#failures.length) return 0
|
|
29
|
+
|
|
30
|
+
for (const { pair, mode, ratio, minimum } of this.#failures)
|
|
31
|
+
this.#print(` ${pair.text} (${mode}): ${ratio.toFixed(2)} < ${minimum}`)
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#print(line) {
|
|
36
|
+
this.#output.write(`${line}\n`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pairLine({ pair, mode, ratio, passes }) {
|
|
41
|
+
return `${passes ? 'pass' : 'FAIL'} ${ratio.toFixed(2).padStart(5)} ${mode.padEnd(5)} ${pair.text}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = ContrastReporter
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// One pair measured in one mode.
|
|
2
|
+
class ContrastResult {
|
|
3
|
+
constructor(pair, mode, ratio, minimum) {
|
|
4
|
+
this.pair = pair
|
|
5
|
+
this.mode = mode
|
|
6
|
+
this.ratio = ratio
|
|
7
|
+
this.minimum = minimum
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get passes() {
|
|
11
|
+
return this.ratio >= this.minimum
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = ContrastResult
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Returns the index of the ")" that closes the "(" at openIndex, or -1 if it is never closed.
|
|
2
|
+
function findClosingParen(text, openIndex) {
|
|
3
|
+
let depth = 0
|
|
4
|
+
for (let index = openIndex; index < text.length; index++) {
|
|
5
|
+
if (text[index] === '(') depth++
|
|
6
|
+
else if (text[index] === ')' && --depth === 0) return index
|
|
7
|
+
}
|
|
8
|
+
return -1
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Splits at the first comma outside parentheses: "a, b(c, d), e" -> ["a", " b(c, d), e"].
|
|
12
|
+
function splitAtFirstComma(text) {
|
|
13
|
+
let depth = 0
|
|
14
|
+
for (let index = 0; index < text.length; index++) {
|
|
15
|
+
const char = text[index]
|
|
16
|
+
if (char === '(') depth++
|
|
17
|
+
else if (char === ')') depth--
|
|
18
|
+
else if (char === ',' && depth === 0) return [text.slice(0, index), text.slice(index + 1)]
|
|
19
|
+
}
|
|
20
|
+
return [text]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { findClosingParen, splitAtFirstComma }
|