@clay-design-system/tokens 0.10.0 → 0.14.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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * stylelint-plugin: clay-ds/no-literal-values
3
+ *
4
+ * Every size and color in component CSS comes from a token. Flags, in declaration values:
5
+ * - length literals: px, rem, and em (em is allowed for letter-spacing only)
6
+ * - hex colors (#fff, #1c1d21aa)
7
+ * - color functions: rgb() / rgba() / hsl() / hsla()
8
+ * - a fallback on a real token (any fallback — tokens always resolve): var(--clay-size-space-500, 20)
9
+ * - a literal length/color default on a runtime custom property: var(--agent-visual-size, 160px)
10
+ * (real token names are read from ../dist/tokens.css; without a build, any --clay-* counts)
11
+ * - an unknown token: var(--clay-x) that tokens.css doesn't define, with no fallback and no declaration of
12
+ * --clay-x in the same file — it resolves to nothing (e.g. a renamed or misspelled token)
13
+ *
14
+ * Allowed:
15
+ * - 0 (with or without a unit), %, vw/vh/fr/deg/ms/s, unitless numbers (line-height, z-index, …)
16
+ * - token unit conversion: calc(var(--clay-*) * Npx) / calc(Npx * var(--clay-*)), any N — also at the
17
+ * end of a chain or after a group: var(--clay-a) * 2 * 1px, (var(--clay-a) - var(--clay-b)) * 1px
18
+ * - runtime unit conversion: calc(var(--any-prop) * 1px), e.g. a height set from JS
19
+ * - a literal multiplied by a plain number is still a literal: calc(2 * 416px) is flagged
20
+ * - em in letter-spacing: calc(var(--clay-web-type-*-letter-spacing) * 1em) or a bare em value
21
+ * - anything inside url(...) or a string
22
+ *
23
+ * Usage in .stylelintrc:
24
+ * plugins: ['@clay-design-system/tokens/stylelint-plugin/no-literal-values.js']
25
+ * rules:
26
+ * 'clay-ds/no-literal-values': true
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs'
30
+ import stylelint from 'stylelint'
31
+
32
+ const { createPlugin, utils } = stylelint
33
+
34
+ const ruleName = 'clay-ds/no-literal-values'
35
+ const messages = utils.ruleMessages(ruleName, {
36
+ length: (value, prop) => `Literal length "${value}" in "${prop}". Use ${lengthHint(prop)}.`,
37
+ color: (value, prop) =>
38
+ `Literal color "${value}" in "${prop}". Use a semantic --clay-color-* token (or --clay-effect-shadow-* for shadows).`,
39
+ tokenFallback: (fallback, name) =>
40
+ `Fallback "${fallback}" on token var(${name}, …). Tokens always resolve — drop the fallback.`,
41
+ literalDefault: (value, name) =>
42
+ `Literal default "${value}" in var(${name}, …). Default to a token instead.`,
43
+ unknownToken: (name) =>
44
+ `Unknown token var(${name}): tokens.css doesn't define it and this file doesn't declare it, so it resolves to nothing.`,
45
+ })
46
+
47
+ /** Which token family a literal length in `prop` most likely belongs to. */
48
+ function lengthHint(prop) {
49
+ const p = prop.toLowerCase()
50
+ if (/^(font-size|line-height|letter-spacing)$/.test(p)) return 'a --clay-web-type-* token'
51
+ if (/radius/.test(p)) return 'a --clay-radius-* token'
52
+ if (/^(border|outline)(-(top|right|bottom|left))?(-width)?$/.test(p))
53
+ return 'a --clay-size-stroke-* token (1px → --clay-size-stroke-border)'
54
+ if (/shadow/.test(p)) return 'a --clay-effect-shadow-* token'
55
+ return 'a token: calc(var(--clay-size-space-*) * 1px), a layout token, or a component token'
56
+ }
57
+
58
+ /** Every custom property tokens.css defines; empty when the package isn't built yet. */
59
+ const TOKEN_NAMES = (() => {
60
+ try {
61
+ const css = readFileSync(new URL('../dist/tokens.css', import.meta.url), 'utf8')
62
+ return new Set([...css.matchAll(/(--clay-[\w-]+)\s*:/g)].map((m) => m[1]))
63
+ } catch {
64
+ return new Set()
65
+ }
66
+ })()
67
+ const isToken = (name) => (TOKEN_NAMES.size ? TOKEN_NAMES.has(name) : name.startsWith('--clay-'))
68
+
69
+ /** The innermost `var(` call (the last one in the string) with its balanced inner text. */
70
+ function lastVarCall(value) {
71
+ const start = value.lastIndexOf('var(')
72
+ if (start === -1) return null
73
+ let depth = 1
74
+ let i = start + 4
75
+ for (; i < value.length && depth > 0; i++) {
76
+ if (value[i] === '(') depth++
77
+ else if (value[i] === ')') depth--
78
+ }
79
+ return { start, end: i, inner: value.slice(start + 4, i - 1) }
80
+ }
81
+
82
+ // After step 1 every var() is reduced to the placeholder VAR{--name}.
83
+ const LENGTH = /(^|[^\w.#-])(-?\d*\.?\d+)(px|rem|em)\b/gi
84
+ const HEX = /(^|[^\w&-])(#[0-9a-f]{3,8})\b/gi
85
+ const COLOR_FN = /\b(rgba?|hsla?)\(/gi
86
+
87
+ /**
88
+ * Is the length at value[start, end) a unit multiplier — `<operand> * Npx` or `Npx * VAR{…}` — whose
89
+ * operand holds a var? Token vars allow any N; other (runtime) vars only ±1. The left operand may be a
90
+ * VAR{…}, a (…) group containing one, or a numeric chain ending in one (VAR{a} * 2 * 1px).
91
+ */
92
+ function isUnitConversion(value, start, end, num) {
93
+ const allowed = (name) => name.startsWith('--clay-') || Math.abs(num) === 1
94
+ const right = value.slice(end).match(/^\s*\*\s*VAR\{(--[\w-]+)\}/)
95
+ if (right) return allowed(right[1])
96
+ let left = value.slice(0, start)
97
+ for (;;) {
98
+ const star = left.match(/\*\s*$/)
99
+ if (!star) return false
100
+ left = left.slice(0, star.index).trimEnd()
101
+ const v = left.match(/VAR\{(--[\w-]+)\}$/)
102
+ if (v) return allowed(v[1])
103
+ if (left.endsWith(')')) {
104
+ let depth = 0
105
+ let i = left.length - 1
106
+ for (; i >= 0; i--) {
107
+ if (left[i] === ')') depth++
108
+ else if (left[i] === '(' && --depth === 0) break
109
+ }
110
+ const names = [...left.slice(i).matchAll(/VAR\{(--[\w-]+)\}/g)].map((m) => m[1])
111
+ return names.length > 0 && (names.some((n) => n.startsWith('--clay-')) || Math.abs(num) === 1)
112
+ }
113
+ const n = left.match(/-?\d*\.?\d+$/)
114
+ if (!n) return false
115
+ left = left.slice(0, n.index)
116
+ }
117
+ }
118
+
119
+ /** Length and color literals in a value whose var() calls are already placeholders. */
120
+ function literals(value, prop) {
121
+ const found = []
122
+ for (const m of value.matchAll(LENGTH)) {
123
+ const [, lead, num, unit] = m
124
+ if (Number(num) === 0) continue
125
+ if (unit.toLowerCase() === 'em' && prop === 'letter-spacing') continue
126
+ const start = m.index + lead.length
127
+ if (isUnitConversion(value, start, start + num.length + unit.length, Number(num))) continue
128
+ found.push({ kind: 'length', word: `${num}${unit}` })
129
+ }
130
+ for (const m of value.matchAll(HEX)) found.push({ kind: 'color', word: m[2] })
131
+ for (const m of value.matchAll(COLOR_FN)) found.push({ kind: 'color', word: `${m[1]}()` })
132
+ return found
133
+ }
134
+
135
+ const ruleFunction = (primary) => {
136
+ return (root, result) => {
137
+ const validOptions = utils.validateOptions(result, ruleName, {
138
+ actual: primary,
139
+ possible: [true],
140
+ })
141
+ if (!validOptions) return
142
+
143
+ // Custom properties this file declares itself (runtime props, local --clay-* hooks) are never unknown.
144
+ const declared = new Set()
145
+ root.walkDecls(/^--/, (decl) => declared.add(decl.prop))
146
+
147
+ root.walkDecls((decl) => {
148
+ const prop = decl.prop.toLowerCase()
149
+ const report = (message, word) =>
150
+ utils.report({ message, node: decl, result, ruleName, word })
151
+
152
+ // Strings and url() never carry design values.
153
+ let value = decl.value.replace(/url\([^)]*\)/gi, 'url()').replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""')
154
+
155
+ // 1. var() fallbacks — report, then reduce each var(), innermost first, to VAR{--name}.
156
+ for (let call = lastVarCall(value); call; call = lastVarCall(value)) {
157
+ const { start, end, inner } = call
158
+ const comma = inner.indexOf(',')
159
+ const name = (comma === -1 ? inner : inner.slice(0, comma)).trim()
160
+ const fallback = comma === -1 ? '' : inner.slice(comma + 1).trim()
161
+ if (fallback && isToken(name)) report(messages.tokenFallback(fallback, name), fallback)
162
+ else if (fallback)
163
+ for (const { word } of literals(fallback, prop)) report(messages.literalDefault(word, name), word)
164
+ else if (TOKEN_NAMES.size && name.startsWith('--clay-') && !isToken(name) && !declared.has(name))
165
+ report(messages.unknownToken(name), name)
166
+ value = `${value.slice(0, start)}VAR{${name}}${value.slice(end)}`
167
+ }
168
+
169
+ // 2. No length or color literal may remain (unit conversions excepted, see isUnitConversion).
170
+ for (const { kind, word } of literals(value, prop)) report(messages[kind](word, decl.prop), word)
171
+ })
172
+ }
173
+ }
174
+
175
+ ruleFunction.ruleName = ruleName
176
+ ruleFunction.messages = messages
177
+
178
+ export default createPlugin(ruleName, ruleFunction)
@@ -0,0 +1,57 @@
1
+ /**
2
+ * stylelint-plugin: clay-ds/reduced-motion
3
+ *
4
+ * A file that animates must also handle reduced motion. If a file declares `@keyframes`, or an
5
+ * `animation` / `animation-name` other than `none`, it must contain a
6
+ * `@media (prefers-reduced-motion: …)` block — `reduce` (switch the motion off) or
7
+ * `no-preference` (only animate when allowed). Reported once per file, on the first animation.
8
+ *
9
+ * Usage in .stylelintrc:
10
+ * plugins: ['@clay-design-system/tokens/stylelint-plugin/reduced-motion.js']
11
+ * rules:
12
+ * 'clay-ds/reduced-motion': true
13
+ */
14
+
15
+ import stylelint from 'stylelint'
16
+
17
+ const { createPlugin, utils } = stylelint
18
+
19
+ const ruleName = 'clay-ds/reduced-motion'
20
+ const messages = utils.ruleMessages(ruleName, {
21
+ missing: (what) =>
22
+ `${what} animates, but this file has no @media (prefers-reduced-motion: …) block. Disable or gate the motion for users who ask for less.`,
23
+ })
24
+
25
+ const ruleFunction = (primary) => {
26
+ return (root, result) => {
27
+ const validOptions = utils.validateOptions(result, ruleName, {
28
+ actual: primary,
29
+ possible: [true],
30
+ })
31
+ if (!validOptions) return
32
+
33
+ let firstMotion = null
34
+ let hasReducedMotion = false
35
+
36
+ root.walk((node) => {
37
+ if (node.type === 'atrule') {
38
+ const name = node.name.toLowerCase()
39
+ if (name === 'media' && /prefers-reduced-motion/i.test(node.params)) hasReducedMotion = true
40
+ if (!firstMotion && /-?keyframes$/.test(name))
41
+ firstMotion = { node, what: `@keyframes ${node.params}` }
42
+ } else if (node.type === 'decl' && !firstMotion) {
43
+ const prop = node.prop.toLowerCase()
44
+ if ((prop === 'animation' || prop === 'animation-name') && !/^\s*none\s*$/i.test(node.value))
45
+ firstMotion = { node, what: `"${node.prop}: ${node.value}"` }
46
+ }
47
+ })
48
+
49
+ if (firstMotion && !hasReducedMotion)
50
+ utils.report({ message: messages.missing(firstMotion.what), node: firstMotion.node, result, ruleName })
51
+ }
52
+ }
53
+
54
+ ruleFunction.ruleName = ruleName
55
+ ruleFunction.messages = messages
56
+
57
+ export default createPlugin(ruleName, ruleFunction)
@@ -0,0 +1,66 @@
1
+ /**
2
+ * stylelint-plugin: clay-ds/require-layer
3
+ *
4
+ * Design-system CSS sits in `@layer clay.components`, so unlayered consumer styles always win.
5
+ * Every top-level rule, declaration and at-rule must be inside an `@layer clay.components { … }`
6
+ * block. Allowed at the top level as well:
7
+ * - @layer order statements (`@layer clay.tokens, clay.base, clay.components;`)
8
+ * - @keyframes (Chrome needs scroll-driven keyframes outside a layer), @property, @font-face
9
+ * - @view-transition (a document-level descriptor block; cascade layers don't apply to it)
10
+ * - @import, @charset
11
+ *
12
+ * Page CSS (apps/pages) is unlayered by design — don't enable this rule there.
13
+ *
14
+ * Usage in .stylelintrc:
15
+ * plugins: ['@clay-design-system/tokens/stylelint-plugin/require-layer.js']
16
+ * rules:
17
+ * 'clay-ds/require-layer': true // or [true, { layer: 'clay.components' }]
18
+ */
19
+
20
+ import stylelint from 'stylelint'
21
+
22
+ const { createPlugin, utils } = stylelint
23
+
24
+ const ruleName = 'clay-ds/require-layer'
25
+ const messages = utils.ruleMessages(ruleName, {
26
+ outside: (what, layer) => `${what} sits outside @layer ${layer}. Wrap it in @layer ${layer} { … }.`,
27
+ wrongLayer: (name, layer) => `@layer ${name} — design-system CSS belongs in @layer ${layer}.`,
28
+ })
29
+
30
+ const TOP_LEVEL_OK = new Set(['keyframes', 'property', 'font-face', 'view-transition', 'import', 'charset'])
31
+
32
+ const ruleFunction = (primary, secondary) => {
33
+ return (root, result) => {
34
+ const validOptions = utils.validateOptions(
35
+ result,
36
+ ruleName,
37
+ { actual: primary, possible: [true] },
38
+ { actual: secondary, possible: { layer: [(v) => typeof v === 'string'] }, optional: true },
39
+ )
40
+ if (!validOptions) return
41
+ const layer = secondary?.layer ?? 'clay.components'
42
+
43
+ for (const node of root.nodes ?? []) {
44
+ if (node.type === 'comment') continue
45
+ if (node.type === 'atrule') {
46
+ const name = node.name.toLowerCase()
47
+ if (/-?keyframes$/.test(name) || TOP_LEVEL_OK.has(name)) continue
48
+ if (name === 'layer') {
49
+ if (!node.nodes) continue // order statement: @layer a, b;
50
+ if (node.params.trim() !== layer)
51
+ utils.report({ message: messages.wrongLayer(node.params.trim(), layer), node, result, ruleName })
52
+ continue
53
+ }
54
+ utils.report({ message: messages.outside(`@${node.name} ${node.params}`.trim(), layer), node, result, ruleName })
55
+ continue
56
+ }
57
+ const what = node.type === 'rule' ? `"${node.selector}"` : `"${node.prop}"`
58
+ utils.report({ message: messages.outside(what, layer), node, result, ruleName })
59
+ }
60
+ }
61
+ }
62
+
63
+ ruleFunction.ruleName = ruleName
64
+ ruleFunction.messages = messages
65
+
66
+ export default createPlugin(ruleName, ruleFunction)