@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.
Files changed (65) hide show
  1. package/.github/workflows/linting.yml +2 -0
  2. package/.prettierignore +3 -0
  3. package/.yarn/install-state.gz +0 -0
  4. package/CHANGELOG.md +41 -0
  5. package/README.md +16 -0
  6. package/build_token_json.js +2 -1
  7. package/dist/README.md +16 -0
  8. package/dist/css/core/tokens/base_color_tokens.css +74 -0
  9. package/dist/css/core/tokens/base_tokens.css +3 -345
  10. package/dist/css/core/tokens/index.css +2 -1
  11. package/dist/css/core/tokens/oklch/base_color_tokens.css +74 -0
  12. package/dist/css/core/tokens/oklch/index.css +3 -0
  13. package/dist/css/core/tokens/oklch/scale_color_tokens.css +1498 -0
  14. package/dist/css/core/tokens/shared_tokens.css +272 -0
  15. package/dist/css/optics+feather_icons.css +74 -73
  16. package/dist/css/optics+feather_icons.min.css +1 -1
  17. package/dist/css/optics+lucide_icons.css +74 -73
  18. package/dist/css/optics+lucide_icons.min.css +1 -1
  19. package/dist/css/optics+material_outlined_variable.css +74 -73
  20. package/dist/css/optics+material_outlined_variable.min.css +1 -1
  21. package/dist/css/optics+no_icons.css +74 -73
  22. package/dist/css/optics+no_icons.min.css +1 -1
  23. package/dist/css/optics+phosphor_icons.css +74 -73
  24. package/dist/css/optics+phosphor_icons.min.css +1 -1
  25. package/dist/css/optics+tabler_icons.css +74 -73
  26. package/dist/css/optics+tabler_icons.min.css +1 -1
  27. package/dist/css/optics-oklch+feather_icons.css +6566 -0
  28. package/dist/css/optics-oklch+feather_icons.min.css +1 -0
  29. package/dist/css/optics-oklch+lucide_icons.css +13598 -0
  30. package/dist/css/optics-oklch+lucide_icons.min.css +1 -0
  31. package/dist/css/optics-oklch+material_outlined_variable.css +5636 -0
  32. package/dist/css/optics-oklch+material_outlined_variable.min.css +1 -0
  33. package/dist/css/optics-oklch+no_icons.css +5627 -0
  34. package/dist/css/optics-oklch+no_icons.min.css +1 -0
  35. package/dist/css/optics-oklch+phosphor_icons.css +5671 -0
  36. package/dist/css/optics-oklch+phosphor_icons.min.css +1 -0
  37. package/dist/css/optics-oklch+tabler_icons.css +5638 -0
  38. package/dist/css/optics-oklch+tabler_icons.min.css +1 -0
  39. package/dist/css/optics-oklch.css +5640 -0
  40. package/dist/css/optics-oklch.min.css +1 -0
  41. package/dist/css/optics.css +74 -73
  42. package/dist/css/optics.min.css +1 -1
  43. package/dist/package.json +10 -6
  44. package/dist/tokens/tokens.json +16 -16
  45. package/dist/tools/check-contrast.cjs +47 -0
  46. package/dist/tools/contrast/builds/build.cjs +44 -0
  47. package/dist/tools/contrast/builds/hsl-build.cjs +11 -0
  48. package/dist/tools/contrast/builds/oklch-build.cjs +11 -0
  49. package/dist/tools/contrast/calc-expression.cjs +68 -0
  50. package/dist/tools/contrast/check-error.cjs +4 -0
  51. package/dist/tools/contrast/color-pair.cjs +35 -0
  52. package/dist/tools/contrast/colors/color.cjs +41 -0
  53. package/dist/tools/contrast/colors/function-color.cjs +46 -0
  54. package/dist/tools/contrast/colors/hex-color.cjs +19 -0
  55. package/dist/tools/contrast/colors/hsl-color.cjs +25 -0
  56. package/dist/tools/contrast/colors/oklch-color.cjs +36 -0
  57. package/dist/tools/contrast/colors/themed-color.cjs +37 -0
  58. package/dist/tools/contrast/contrast-check.cjs +37 -0
  59. package/dist/tools/contrast/contrast-reporter.cjs +44 -0
  60. package/dist/tools/contrast/contrast-result.cjs +15 -0
  61. package/dist/tools/contrast/css-syntax.cjs +23 -0
  62. package/dist/tools/contrast/stylesheet.cjs +174 -0
  63. package/dist/tools/contrast/theme.cjs +84 -0
  64. package/dist/tools/contrast/tool-options.cjs +50 -0
  65. package/package.json +10 -6
@@ -0,0 +1,174 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const CheckError = require('./check-error.cjs')
4
+ const { findClosingParen } = require('./css-syntax.cjs')
5
+
6
+ // All custom properties, not just --op-*, so theme vars like var(--brand-hue) resolve.
7
+ const DECLARATION = /(--[\w-]+)\s*:\s*([^;}]*)/g
8
+ const IMPORTANT = /\s*!\s*important$/i
9
+ // Blocks that change whether or how much a declaration applies, which the cascade here doesn't follow.
10
+ const SCOPED_RULE = /^@(media|supports|container|layer)\b/i
11
+ // Optics itself is already loaded, and a remote URL (https:, data:, or //host) is usually a font, not a theme.
12
+ const SKIPPED_IMPORT = /^(?:[a-z][a-z0-9+.-]*:|\/\/)|(^|\/)@rolemodel\/optics(\/|@|$)/i
13
+
14
+ // The text of one or more CSS files, with their imports and without comments. Selectors are ignored.
15
+ class Stylesheet {
16
+ #css
17
+
18
+ // Reads the files in order, as if they were one file.
19
+ static read(paths) {
20
+ const missing = paths.find((file) => !fs.existsSync(file))
21
+ if (missing) throw new CheckError(`File not found: ${missing}`)
22
+ return new Stylesheet(paths.map((file) => readWithImports(file, [path.resolve(file)])).join('\n'))
23
+ }
24
+
25
+ // Comments are stripped first, so commented-out tokens can't sway build detection or the cascade.
26
+ constructor(css) {
27
+ this.#css = stripComments(css)
28
+ }
29
+
30
+ get isEmpty() {
31
+ return this.#css === ''
32
+ }
33
+
34
+ mentions(pattern) {
35
+ return pattern.test(this.#css)
36
+ }
37
+
38
+ // Every custom property declaration, in source order, with duplicates. `rule` is the innermost
39
+ // @media, @supports, @container or @layer rule around it, if any.
40
+ *declarations() {
41
+ const scopedBlocks = this.#scopedBlocks()
42
+ for (const match of this.#css.matchAll(DECLARATION)) {
43
+ const [, name, rawValue] = match
44
+ const block = scopedBlocks.find(({ start, end }) => match.index > start && match.index < end)
45
+ const value = rawValue.trim()
46
+ yield { name, value: value.replace(IMPORTANT, ''), important: IMPORTANT.test(value), rule: block?.rule }
47
+ }
48
+ }
49
+
50
+ // The start and end of each scoped block, innermost first, with its rule, like "@media (prefers-color-scheme: dark)".
51
+ #scopedBlocks() {
52
+ const blocks = []
53
+ const open = []
54
+ let ruleStart = 0
55
+ for (let index = 0; index < this.#css.length; index++) {
56
+ const char = this.#css[index]
57
+ if (char === '{') {
58
+ const rule = this.#css.slice(ruleStart, index).trim()
59
+ open.push(SCOPED_RULE.test(rule) ? { rule, start: index } : undefined)
60
+ } else if (char === '}') {
61
+ const block = open.pop()
62
+ if (block) blocks.push({ ...block, end: index })
63
+ }
64
+ if (char === '{' || char === '}' || char === ';') ruleStart = index + 1
65
+ }
66
+ // A block left open runs to the end of the file, as in CSS.
67
+ for (const block of open) if (block) blocks.push({ ...block, end: this.#css.length })
68
+ return blocks
69
+ }
70
+ }
71
+
72
+ // Replaces each @import with the imported file, found relative to the importing file, as CSS does.
73
+ // An import that can't be read or found stops the check, because the theme would be checked without its tokens.
74
+ function readWithImports(file, chain) {
75
+ const css = stripComments(fs.readFileSync(file, 'utf8'))
76
+ const where = path.relative('.', file)
77
+ let output = ''
78
+ let index = 0
79
+ for (const { index: start } of css.matchAll(/@import\b/gi)) {
80
+ if (start < index) continue
81
+ const statement = parseImport(css, start)
82
+ if (!statement) throw new CheckError(`Can't read an @import in ${where}. Pass your compiled CSS instead.`)
83
+ output += css.slice(index, start) + importedCss(statement, file, where, chain)
84
+ index = statement.end
85
+ }
86
+ return output + css.slice(index)
87
+ }
88
+
89
+ function importedCss({ url, conditions }, file, where, chain) {
90
+ if (SKIPPED_IMPORT.test(url)) return ''
91
+
92
+ const imported = [url, `${url}.css`]
93
+ .map((candidate) => path.resolve(path.dirname(file), candidate))
94
+ .find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile())
95
+ if (!imported)
96
+ throw new CheckError(`Can't find @import '${url}' in ${where}. Fix the path, or pass your compiled CSS instead.`)
97
+ // CSS skips an import that would loop back to a file already being read.
98
+ if (chain.includes(imported)) return ''
99
+ return withConditions(readWithImports(imported, [...chain, imported]), conditions)
100
+ }
101
+
102
+ // Reads `@import "url" conditions;`, `@import 'url' ...;` or `@import url(...) ...;` starting at `start`.
103
+ // Quoted URLs can hold spaces and semicolons. Returns undefined when the statement can't be read.
104
+ function parseImport(css, start) {
105
+ let index = start + '@import'.length
106
+ const skipSpaces = () => {
107
+ while (/\s/.test(css[index] ?? '')) index++
108
+ }
109
+
110
+ skipSpaces()
111
+ const isUrlFunction = css.slice(index, index + 4).toLowerCase() === 'url('
112
+ if (isUrlFunction) {
113
+ index += 4
114
+ skipSpaces()
115
+ }
116
+
117
+ let url
118
+ const quote = css[index]
119
+ if (quote === '"' || quote === "'") {
120
+ const close = css.indexOf(quote, index + 1)
121
+ if (close === -1) return undefined
122
+ url = css.slice(index + 1, close)
123
+ index = close + 1
124
+ } else if (isUrlFunction) {
125
+ const close = css.indexOf(')', index)
126
+ if (close === -1) return undefined
127
+ url = css.slice(index, close).trim()
128
+ index = close
129
+ } else {
130
+ return undefined
131
+ }
132
+
133
+ if (isUrlFunction) {
134
+ skipSpaces()
135
+ if (css[index] !== ')') return undefined
136
+ index++
137
+ }
138
+
139
+ // The last statement in a file may end without a semicolon.
140
+ const semicolon = css.indexOf(';', index)
141
+ const end = semicolon === -1 ? css.length : semicolon
142
+ return { url, conditions: css.slice(index, end).trim(), end: end + 1 }
143
+ }
144
+
145
+ // Wraps imported CSS in the import's media, supports() and layer() parts, so its tokens count as scoped.
146
+ function withConditions(css, conditions) {
147
+ let rest = conditions
148
+ let layer
149
+ const layerMatch = rest.match(/^layer(?:\(([^)]*)\))?\s*/i)
150
+ if (layerMatch) {
151
+ layer = layerMatch[1]?.trim() ?? ''
152
+ rest = rest.slice(layerMatch[0].length)
153
+ }
154
+
155
+ let supports
156
+ if (/^supports\(/i.test(rest)) {
157
+ const end = findClosingParen(rest, 'supports'.length)
158
+ if (end !== -1) {
159
+ supports = rest.slice('supports('.length, end)
160
+ rest = rest.slice(end + 1).trim()
161
+ }
162
+ }
163
+
164
+ let wrapped = css
165
+ if (layer !== undefined) wrapped = `@layer ${layer} {\n${wrapped}\n}`
166
+ if (supports !== undefined) wrapped = `@supports (${supports}) {\n${wrapped}\n}`
167
+ return rest ? `@media ${rest} {\n${wrapped}\n}` : wrapped
168
+ }
169
+
170
+ function stripComments(css) {
171
+ return css.replace(/\/\*[\s\S]*?\*\//g, '')
172
+ }
173
+
174
+ module.exports = Stylesheet
@@ -0,0 +1,84 @@
1
+ const CalcExpression = require('./calc-expression.cjs')
2
+ const CheckError = require('./check-error.cjs')
3
+ const { findClosingParen, splitAtFirstComma } = require('./css-syntax.cjs')
4
+
5
+ const VAR_NAME = /^--[\w-]+$/
6
+
7
+ // The user's theme on top of the Optics tokens: one final value per custom property, like getComputedStyle(:root).
8
+ class Theme {
9
+ #values = new Map()
10
+ // The @media, @supports, @container or @layer rule a property's final value was set inside, if any.
11
+ #rules = new Map()
12
+
13
+ // Applies the stylesheets in order. The last value wins, except a normal value can't replace an !important one, as in CSS.
14
+ static cascade(...stylesheets) {
15
+ const theme = new Theme()
16
+ const important = new Set()
17
+ for (const stylesheet of stylesheets) {
18
+ for (const { name, value, important: isImportant, rule } of stylesheet.declarations()) {
19
+ if (important.has(name) && !isImportant) continue
20
+ if (isImportant) important.add(name)
21
+ theme.#values.set(name, value)
22
+ if (rule) theme.#rules.set(name, rule)
23
+ else theme.#rules.delete(name)
24
+ }
25
+ }
26
+ return theme
27
+ }
28
+
29
+ names() {
30
+ return [...this.#values.keys()]
31
+ }
32
+
33
+ has(name) {
34
+ return this.#values.has(name)
35
+ }
36
+
37
+ // The token's value with every var() substituted and every plain-number calc() worked out.
38
+ resolve(name) {
39
+ this.#assertUnscoped(name, name)
40
+ return CalcExpression.resolveAll(this.#substituteVars(this.#values.get(name), [], name))
41
+ }
42
+
43
+ // Walks each var() with balanced parentheses, so fallbacks can hold their own var(),
44
+ // and a fallback is only resolved when its variable is not defined.
45
+ #substituteVars(value, seen, token) {
46
+ let output = ''
47
+ let index = 0
48
+ for (let start; (start = value.indexOf('var(', index)) !== -1;) {
49
+ const end = findClosingParen(value, start + 'var'.length)
50
+ if (end === -1) throw new CheckError(`Can't resolve: ${value}`)
51
+
52
+ const [name, fallback] = splitAtFirstComma(value.slice(start + 'var('.length, end)).map((part) => part.trim())
53
+ output += value.slice(index, start) + this.#substituteVar(name, fallback, seen, value, token)
54
+ index = end + 1
55
+ }
56
+ return output + value.slice(index)
57
+ }
58
+
59
+ #substituteVar(name, fallback, seen, value, token) {
60
+ if (!VAR_NAME.test(name)) throw new CheckError(`Can't resolve: ${value}`)
61
+ if (seen.includes(name)) throw new CheckError(`Circular reference: ${[...seen, name].join(' -> ')}`)
62
+ if (this.#values.has(name)) {
63
+ this.#assertUnscoped(token, name)
64
+ return this.#substituteVars(this.#values.get(name), [...seen, name], token)
65
+ }
66
+ if (fallback !== undefined) return this.#substituteVars(fallback, seen, token)
67
+ throw new CheckError(`${name} is not defined`)
68
+ }
69
+
70
+ // A value set inside @media, @supports or @container applies only under that condition, and one inside @layer
71
+ // loses to any unlayered value. The cascade here follows neither, so a color that depends on one could pass a
72
+ // theme that fails.
73
+ #assertUnscoped(token, name) {
74
+ const rule = this.#rules.get(name)
75
+ if (!rule) return
76
+ const reason = name === token ? 'it is set' : `it uses ${name}, which is set`
77
+ const advice = /^@layer/i.test(rule)
78
+ ? 'Layers change which value wins, so move color tokens out of @layer.'
79
+ : 'Set mode colors with light-dark(), or check that theme in its own file.'
80
+ throw new CheckError(`Can't check ${token}: ${reason} inside ${rule}. ${advice}`)
81
+ }
82
+ }
83
+
84
+ module.exports = Theme
@@ -0,0 +1,50 @@
1
+ const CheckError = require('./check-error.cjs')
2
+ const ThemedColor = require('./colors/themed-color.cjs')
3
+
4
+ const DEFAULT_MIN = 4.5
5
+ const FLAG = /^--([a-z-]+)(?:=(.*))?$/
6
+
7
+ // The command-line arguments: optics-check-contrast [files...] [--hsl | --oklch] [--mode=light | --mode=dark] [--min=4.5] [--alt-min=4.5] [--report]
8
+ class ToolOptions {
9
+ static parse(args) {
10
+ const flags = {}
11
+ const files = []
12
+ for (const arg of args) {
13
+ const flag = arg.match(FLAG)
14
+ if (flag) flags[flag[1]] = flag[2] ?? true
15
+ else files.push(arg)
16
+ }
17
+ return new ToolOptions(files, flags)
18
+ }
19
+
20
+ constructor(files, flags) {
21
+ this.files = files
22
+ this.buildName = forcedBuildName(flags)
23
+ this.mode = mode(flags.mode)
24
+ this.min = threshold(flags.min)
25
+ this.altMin = threshold(flags['alt-min'])
26
+ this.report = Boolean(flags.report)
27
+ }
28
+ }
29
+
30
+ function forcedBuildName(flags) {
31
+ if (flags.hsl) return 'hsl'
32
+ if (flags.oklch) return 'oklch'
33
+ return undefined
34
+ }
35
+
36
+ function mode(value) {
37
+ if (value === undefined || ThemedColor.MODES.includes(value)) return value
38
+ throw new CheckError('--mode must be light or dark')
39
+ }
40
+
41
+ function threshold(value) {
42
+ if (value === undefined) return DEFAULT_MIN
43
+ // A bare --min (true) or empty --min= ('') would become 1 or 0 with Number(), so require a numeric string.
44
+ const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
45
+ // A NaN threshold would make every ratio < min check false, so every pair would pass.
46
+ if (!Number.isFinite(number)) throw new CheckError('--min and --alt-min must be numbers, like 4.5')
47
+ return number
48
+ }
49
+
50
+ module.exports = ToolOptions
package/package.json CHANGED
@@ -1,28 +1,32 @@
1
1
  {
2
2
  "name": "@rolemodel/optics",
3
- "version": "2.5.1",
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": "exit 0",
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",