@evolve.ui/theme-system 1.1.0 → 1.2.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/{allThemes-CFS_9-tk.cjs → allThemes-3d8nhymo.cjs} +12 -2
- package/allThemes-3d8nhymo.cjs.map +1 -0
- package/{allThemes-OV-ayn4C.mjs → allThemes-Abn6KDm7.mjs} +7 -3
- package/allThemes-Abn6KDm7.mjs.map +1 -0
- package/allThemes.cjs +1 -1
- package/allThemes.d.cts +1 -0
- package/allThemes.d.cts.map +1 -1
- package/allThemes.d.mts +1 -0
- package/allThemes.d.mts.map +1 -1
- package/allThemes.mjs +1 -1
- package/brands-CE0wIkKX.d.cts +58 -0
- package/brands-CE0wIkKX.d.cts.map +1 -0
- package/brands-CE0wIkKX.d.mts +58 -0
- package/brands-CE0wIkKX.d.mts.map +1 -0
- package/brands.cjs +16 -13
- package/brands.cjs.map +1 -1
- package/brands.d.cts +2 -2
- package/brands.d.mts +2 -2
- package/brands.mjs +16 -13
- package/brands.mjs.map +1 -1
- package/css/EverydayHarbor.css +377 -0
- package/css/EverydayHarbor.effects.css +650 -0
- package/{default-b-tgfyxR.cjs → default-B6VWF0Js.cjs} +237 -36
- package/default-B6VWF0Js.cjs.map +1 -0
- package/{default-B2n4PnsJ.mjs → default-BogJDhKE.mjs} +238 -37
- package/default-BogJDhKE.mjs.map +1 -0
- package/effects.cjs +26 -0
- package/effects.cjs.map +1 -0
- package/effects.d.cts +22 -0
- package/effects.d.cts.map +1 -0
- package/effects.d.mts +22 -0
- package/effects.d.mts.map +1 -0
- package/effects.mjs +22 -0
- package/effects.mjs.map +1 -0
- package/evolve-ui.entries.json +5 -0
- package/index.cjs +12 -5
- package/index.cjs.map +1 -1
- package/index.d.cts +108 -4
- package/index.d.cts.map +1 -1
- package/index.d.mts +108 -4
- package/index.d.mts.map +1 -1
- package/index.mjs +9 -6
- package/index.mjs.map +1 -1
- package/package.json +5 -1
- package/{plugin-DPtvp4lp.cjs → plugin-B3PF4RMI.cjs} +299 -110
- package/plugin-B3PF4RMI.cjs.map +1 -0
- package/{plugin-D4eCyUJe.mjs → plugin-D6aLCeNB.mjs} +258 -111
- package/plugin-D6aLCeNB.mjs.map +1 -0
- package/src/effects/__fixtures__/fixtureLayer.ts +88 -0
- package/src/effects/brands/EverydayHarbor.ts +392 -0
- package/src/effects/classes.ts +19 -0
- package/src/effects/emit.ts +314 -0
- package/src/effects/hooks.ts +493 -0
- package/src/effects/index.ts +2 -0
- package/src/effects/layers.ts +18 -0
- package/src/effects/rules.ts +90 -0
- package/src/effects/rules.typecheck.ts +50 -0
- package/src/effects/tokens.ts +148 -0
- package/src/effects/validate.ts +683 -0
- package/src/effects/vocabulary.ts +32 -0
- package/src/index.ts +3 -0
- package/src/tailwind/animation.ts +30 -0
- package/src/tailwind/borderWidth.ts +14 -0
- package/src/tailwind/plugin.ts +230 -239
- package/src/tailwind/reducedMotion.ts +164 -0
- package/src/tailwind/text.ts +4 -2
- package/src/themeValues.snapshot.ts +6735 -0
- package/src/themes/EverydayHarbor.ts +147 -0
- package/src/themes/allThemes.ts +2 -0
- package/src/themes/brandSources.ts +37 -0
- package/src/themes/brands.ts +41 -15
- package/src/themes/contrastFloors.ts +26 -0
- package/src/themes/inkRamps.ts +43 -0
- package/src/themes/runtime/buildTheme.ts +6 -19
- package/src/themes/runtime/everydayHarbor.ts +3 -0
- package/src/themes/semanticColorTokenSteps.ts +5 -2
- package/src/themes/themeValues.ts +374 -0
- package/src/themes/wcag.ts +48 -0
- package/tailwind/plugin.cjs +5 -1
- package/tailwind/plugin.d.cts +458 -1
- package/tailwind/plugin.d.cts.map +1 -1
- package/tailwind/plugin.d.mts +458 -1
- package/tailwind/plugin.d.mts.map +1 -1
- package/tailwind/plugin.mjs +2 -2
- package/themes/runtime/default.cjs +1 -1
- package/themes/runtime/default.mjs +1 -1
- package/themes/themeType.d.cts +1 -1
- package/themes/themeType.d.mts +1 -1
- package/allThemes-CFS_9-tk.cjs.map +0 -1
- package/allThemes-OV-ayn4C.mjs.map +0 -1
- package/brands-n30rolHQ.d.cts +0 -33
- package/brands-n30rolHQ.d.cts.map +0 -1
- package/brands-n30rolHQ.d.mts +0 -33
- package/brands-n30rolHQ.d.mts.map +0 -1
- package/default-B2n4PnsJ.mjs.map +0 -1
- package/default-b-tgfyxR.cjs.map +0 -1
- package/plugin-D4eCyUJe.mjs.map +0 -1
- package/plugin-DPtvp4lp.cjs.map +0 -1
- package/src/themes/generate.ts +0 -46
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain data checks run before a sheet is emitted. One throw lists every problem.
|
|
3
|
+
*/
|
|
4
|
+
import * as cssVars from '../tailwind/cssVars';
|
|
5
|
+
import { effectClasses } from './classes';
|
|
6
|
+
import { effectHooks, freePseudoSlot, rootOf, type EffectHookId, type PseudoSlot, type StateBackground } from './hooks';
|
|
7
|
+
import { materials, type EffectLayer, type EffectMaterial, type EffectRule } from './rules';
|
|
8
|
+
import {
|
|
9
|
+
effectTokens,
|
|
10
|
+
tokenOf,
|
|
11
|
+
type EffectTokenKind,
|
|
12
|
+
type EffectTokenName,
|
|
13
|
+
type EffectTokenSpec,
|
|
14
|
+
type EffectTokenValues,
|
|
15
|
+
} from './tokens';
|
|
16
|
+
|
|
17
|
+
export class EffectLayerError extends Error {
|
|
18
|
+
constructor(readonly problems: readonly string[]) {
|
|
19
|
+
super(`Invalid effect layer:\n- ${problems.join('\n- ')}`);
|
|
20
|
+
this.name = 'EffectLayerError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Literal colour syntax. Token values may contain these; rule values never do. */
|
|
25
|
+
const LITERAL_COLOUR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(\s*(?!var\()/i;
|
|
26
|
+
|
|
27
|
+
/** `:disabled` or `[attr]` / `[attr="value"]`. A comma takes at most one ASCII space. Names start with a letter. Quoted values are word characters, spaces, and hyphens. */
|
|
28
|
+
const UNLESS_GRAMMAR =
|
|
29
|
+
/^(?::disabled|\[[a-z][a-z-]*(?:="[\w -]*")?\])(?:, ?(?::disabled|\[[a-z][a-z-]*(?:="[\w -]*")?\]))*$/;
|
|
30
|
+
|
|
31
|
+
const CHANNELS = /^(\d{1,3}) (\d{1,3}) (\d{1,3})$/;
|
|
32
|
+
|
|
33
|
+
/** Channels escape: exactly `var(--name)`. An `--effect-*` name must itself be a channels token. */
|
|
34
|
+
const CHANNEL_VAR = /^var\(--([a-z0-9-]+)\)$/;
|
|
35
|
+
|
|
36
|
+
const REMAPPABLE_COLOR = /^--color-(text|icon)-/;
|
|
37
|
+
|
|
38
|
+
const remappableVars = new Set<string>([
|
|
39
|
+
'--effect-font-accent',
|
|
40
|
+
...Object.values(cssVars).filter((value) => typeof value === 'string' && REMAPPABLE_COLOR.test(value)),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const PAINT_PROPERTIES = new Set([
|
|
44
|
+
'background',
|
|
45
|
+
'backgroundColor',
|
|
46
|
+
'backgroundImage',
|
|
47
|
+
'backdropFilter',
|
|
48
|
+
'color',
|
|
49
|
+
'fontFamily',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const BACKGROUND_PAINTS = new Set(['background', 'backgroundColor', 'backgroundImage']);
|
|
53
|
+
|
|
54
|
+
const MATERIAL_PAINTS = ['background', 'backgroundColor', 'backgroundImage', 'backdropFilter'] as const;
|
|
55
|
+
|
|
56
|
+
/** Only `inset` may be negative. A negative border width or radius is invalid CSS and drops the declaration. */
|
|
57
|
+
const LENGTH_PX = /^-?\d+(\.\d+)?px$/;
|
|
58
|
+
|
|
59
|
+
const NON_NEGATIVE_PX = /^\d+(\.\d+)?px$/;
|
|
60
|
+
|
|
61
|
+
const BORDER_STYLES = new Set<string>(['dashed', 'dotted', 'solid']);
|
|
62
|
+
|
|
63
|
+
const DECORATION_FIELDS = new Set([
|
|
64
|
+
'inset',
|
|
65
|
+
'borderRadius',
|
|
66
|
+
'borderWidth',
|
|
67
|
+
'borderStyle',
|
|
68
|
+
'borderColor',
|
|
69
|
+
'background',
|
|
70
|
+
'boxShadow',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
type LooseRule = {
|
|
74
|
+
readonly acknowledge?: unknown;
|
|
75
|
+
readonly after?: unknown;
|
|
76
|
+
readonly before?: unknown;
|
|
77
|
+
readonly material?: unknown;
|
|
78
|
+
readonly on?: unknown;
|
|
79
|
+
readonly paint?: unknown;
|
|
80
|
+
readonly radius?: unknown;
|
|
81
|
+
readonly shadow?: unknown;
|
|
82
|
+
readonly unless?: unknown;
|
|
83
|
+
readonly vars?: unknown;
|
|
84
|
+
readonly when?: unknown;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type RuleOrigin = 'brand' | 'vocabulary';
|
|
88
|
+
|
|
89
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
90
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
91
|
+
|
|
92
|
+
const hasOwn = (source: object, key: string): boolean => Object.prototype.hasOwnProperty.call(source, key);
|
|
93
|
+
|
|
94
|
+
/** An unquoted `url(` is a url-token. Only ASCII spaces may precede the quote; CSS reads any other space as url content. */
|
|
95
|
+
const UNQUOTED_URL = /url\((?! *["'])/i;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `var(` must be followed by a custom-property name and then `,` or `)`.
|
|
99
|
+
* Anything else (`var()`, `var(--a b)`, `var(--a.b)`) is dropped at parse time and the token becomes guaranteed-invalid.
|
|
100
|
+
*/
|
|
101
|
+
const VAR_CALL = /var\(/gi;
|
|
102
|
+
const VAR_SHAPE = /var\( *--[\w-]+ *[,)]/iy;
|
|
103
|
+
|
|
104
|
+
/** Functions a colour, gradient, shadow, filter, or font value needs. `env()`, `attr()`, and `if()` are not among them. */
|
|
105
|
+
const ALLOWED_FUNCTIONS = new Set([
|
|
106
|
+
'blur',
|
|
107
|
+
'brightness',
|
|
108
|
+
'calc',
|
|
109
|
+
'clamp',
|
|
110
|
+
'color',
|
|
111
|
+
'color-mix',
|
|
112
|
+
'conic-gradient',
|
|
113
|
+
'contrast',
|
|
114
|
+
'drop-shadow',
|
|
115
|
+
'grayscale',
|
|
116
|
+
'hsl',
|
|
117
|
+
'hsla',
|
|
118
|
+
'hue-rotate',
|
|
119
|
+
'hwb',
|
|
120
|
+
'invert',
|
|
121
|
+
'lab',
|
|
122
|
+
'lch',
|
|
123
|
+
'linear-gradient',
|
|
124
|
+
'max',
|
|
125
|
+
'min',
|
|
126
|
+
'oklab',
|
|
127
|
+
'oklch',
|
|
128
|
+
'opacity',
|
|
129
|
+
'radial-gradient',
|
|
130
|
+
'repeating-conic-gradient',
|
|
131
|
+
'repeating-linear-gradient',
|
|
132
|
+
'repeating-radial-gradient',
|
|
133
|
+
'rgb',
|
|
134
|
+
'rgba',
|
|
135
|
+
'saturate',
|
|
136
|
+
'sepia',
|
|
137
|
+
'url',
|
|
138
|
+
'var',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A token is consumed through `var()` by enforced declarations. A CSS-wide keyword there is invalid at computed-value
|
|
143
|
+
* time, so the consumer falls back to `unset` and wipes the host's own paint. Decorations may still say `inherit`.
|
|
144
|
+
*/
|
|
145
|
+
const CSS_WIDE_KEYWORD = /(?<![\w-])(?:initial|inherit|unset|revert|revert-layer)(?![\w-])/i;
|
|
146
|
+
|
|
147
|
+
const QUOTED = /"[^"]*"|'[^']*'/g;
|
|
148
|
+
|
|
149
|
+
const hasMalformedVar = (value: string): boolean =>
|
|
150
|
+
[...value.matchAll(VAR_CALL)].some((match) => {
|
|
151
|
+
VAR_SHAPE.lastIndex = match.index;
|
|
152
|
+
return !VAR_SHAPE.test(value);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One CSS declaration value. Code points below U+0020, U+007F, and `\` are rejected anywhere.
|
|
157
|
+
* Outside quotes, `;` `{` `}` `!`, comments, and functions outside `ALLOWED_FUNCTIONS` are rejected.
|
|
158
|
+
* `url(` must be quoted and `var(` must name a custom property followed by `,` or `)`.
|
|
159
|
+
* `(` and `[` share a stack: a closer must match the opener, and the stack must end empty.
|
|
160
|
+
* A value that trims to nothing is rejected. Quoted strings may contain `;`.
|
|
161
|
+
*/
|
|
162
|
+
const isSingleValue = (value: string): boolean => {
|
|
163
|
+
if (value.trim() === '' || UNQUOTED_URL.test(value) || hasMalformedVar(value)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const stack: Array<'(' | '['> = [];
|
|
167
|
+
let quote: '"' | "'" | undefined;
|
|
168
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
169
|
+
const char = value[index] ?? '';
|
|
170
|
+
const code = char.charCodeAt(0);
|
|
171
|
+
if (code < 0x20 || code === 0x7f || char === '\\') {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (quote !== undefined) {
|
|
175
|
+
if (char === quote) {
|
|
176
|
+
quote = undefined;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (char === '"' || char === "'") {
|
|
181
|
+
quote = char;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (char === ';' || char === '{' || char === '}' || char === '!') {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const next = value[index + 1];
|
|
188
|
+
if ((char === '/' && next === '*') || (char === '*' && next === '/')) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (char === '(') {
|
|
192
|
+
let start = index;
|
|
193
|
+
while (start > 0 && /[\w-]/.test(value[start - 1] ?? '')) {
|
|
194
|
+
start -= 1;
|
|
195
|
+
}
|
|
196
|
+
const name = value.slice(start, index).toLowerCase();
|
|
197
|
+
if (name !== '' && !ALLOWED_FUNCTIONS.has(name)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (char === '(' || char === '[') {
|
|
202
|
+
stack.push(char);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (char === ')' || char === ']') {
|
|
206
|
+
const open = stack.pop();
|
|
207
|
+
if (char === ')' ? open !== '(' : open !== '[') {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return quote === undefined && stack.length === 0;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const isHookId = (value: unknown): value is EffectHookId => typeof value === 'string' && hasOwn(effectHooks, value);
|
|
216
|
+
|
|
217
|
+
const isEffectClassName = (value: unknown): boolean =>
|
|
218
|
+
typeof value === 'string' && (Object.values(effectClasses) as readonly string[]).includes(value);
|
|
219
|
+
|
|
220
|
+
const isMaterial = (value: unknown): value is EffectMaterial =>
|
|
221
|
+
value === 'surface' || value === 'raised' || value === 'scrim';
|
|
222
|
+
|
|
223
|
+
const isStateBackground = (value: unknown): value is StateBackground =>
|
|
224
|
+
value === 'hover' || value === 'active' || value === 'focus-within';
|
|
225
|
+
|
|
226
|
+
const tokenKind = (name: string): EffectTokenKind | undefined =>
|
|
227
|
+
hasOwn(effectTokens, name) ? effectTokens[name as EffectTokenName].kind : undefined;
|
|
228
|
+
|
|
229
|
+
const readValue = (source: object, key: string): unknown => {
|
|
230
|
+
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
return Reflect.get(source, key) as unknown;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const expectRef = (value: unknown, kind: EffectTokenKind, label: string, index: number, problems: string[]): void => {
|
|
237
|
+
if (typeof value !== 'string') {
|
|
238
|
+
problems.push(`rules[${index}] ${label} must be a token ref`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (LITERAL_COLOUR.test(value)) {
|
|
242
|
+
problems.push(`rules[${index}] ${label} contains a literal colour`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const name = tokenOf(value);
|
|
246
|
+
if (name === undefined) {
|
|
247
|
+
const shaped = /^var\(--effect-([a-z0-9-]+)\)$/.exec(value);
|
|
248
|
+
problems.push(
|
|
249
|
+
shaped === null
|
|
250
|
+
? `rules[${index}] ${label} must be exactly one effect token`
|
|
251
|
+
: `rules[${index}] ${label} unknown effect token ${value}`
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (effectTokens[name].kind !== kind) {
|
|
256
|
+
problems.push(`rules[${index}] ${label} must be a ${kind} token`);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const paintKind = (property: string): EffectTokenKind => {
|
|
261
|
+
if (property === 'fontFamily') {
|
|
262
|
+
return 'font';
|
|
263
|
+
}
|
|
264
|
+
if (property === 'backdropFilter') {
|
|
265
|
+
return 'filter';
|
|
266
|
+
}
|
|
267
|
+
return 'paint';
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const enforcesBackground = (rule: LooseRule): boolean => {
|
|
271
|
+
if (isMaterial(rule.material)) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (!isRecord(rule.paint)) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
return Object.keys(rule.paint).some((key) => BACKGROUND_PAINTS.has(key));
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const hasEdge = (rule: LooseRule): boolean => isMaterial(rule.material) && materials[rule.material].edge;
|
|
281
|
+
|
|
282
|
+
const takenOf = (on: string): readonly PseudoSlot[] =>
|
|
283
|
+
isHookId(on) ? (effectHooks[on] as { taken: readonly PseudoSlot[] }).taken : [];
|
|
284
|
+
|
|
285
|
+
const collectTokenProblems = (tokens: EffectTokenValues): string[] => {
|
|
286
|
+
const problems: string[] = [];
|
|
287
|
+
for (const key of Object.keys(tokens)) {
|
|
288
|
+
if (!hasOwn(effectTokens, key)) {
|
|
289
|
+
problems.push(`unknown token ${key}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const [key, spec] of Object.entries(effectTokens) as [EffectTokenName, EffectTokenSpec][]) {
|
|
293
|
+
const value = readValue(tokens, key);
|
|
294
|
+
if (value === undefined) {
|
|
295
|
+
if (spec.default === undefined) {
|
|
296
|
+
problems.push(`missing required token ${key}`);
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (typeof value !== 'string') {
|
|
301
|
+
problems.push(`token ${key} must be a string`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!isSingleValue(value)) {
|
|
305
|
+
problems.push(`token ${key} must be a single CSS value`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (CSS_WIDE_KEYWORD.test(value.replace(QUOTED, '""'))) {
|
|
309
|
+
problems.push(`token ${key} must not use a CSS-wide keyword`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (spec.kind === 'channels' && !isChannels(value)) {
|
|
313
|
+
problems.push(`token ${key} must be R G B channels`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return [...problems, ...collectReferenceProblems(tokens)];
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const isChannels = (value: string): boolean => {
|
|
320
|
+
const channels = CHANNELS.exec(value);
|
|
321
|
+
if (channels !== null) {
|
|
322
|
+
return channels.slice(1).every((channel) => Number(channel) <= 255);
|
|
323
|
+
}
|
|
324
|
+
const name = CHANNEL_VAR.exec(value)?.[1];
|
|
325
|
+
if (name === undefined) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
return !name.startsWith('effect-') || tokenKind(name.slice('effect-'.length)) === 'channels';
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const EFFECT_REF = /var\( *--effect-([\w-]+)/gi;
|
|
332
|
+
|
|
333
|
+
const referencesOf = (value: string): string[] => [...value.matchAll(EFFECT_REF)].map((match) => match[1] ?? '');
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* `var(--effect-*)` references must name a known token and must not form a cycle.
|
|
337
|
+
* Two graphs are checked: the token block as written, and the fallback block, where every token with an `opaque`
|
|
338
|
+
* target is replaced by a reference to that target (`none` has no reference). A cycle in either makes every token on
|
|
339
|
+
* it guaranteed-invalid, and the fallback cycle only shows when a user has asked for less transparency.
|
|
340
|
+
*/
|
|
341
|
+
const collectReferenceProblems = (tokens: EffectTokenValues): string[] => {
|
|
342
|
+
const problems: string[] = [];
|
|
343
|
+
const written = new Map<string, string[]>();
|
|
344
|
+
for (const [key, spec] of Object.entries(effectTokens) as [EffectTokenName, EffectTokenSpec][]) {
|
|
345
|
+
const value = readValue(tokens, key) ?? spec.default;
|
|
346
|
+
const references = typeof value === 'string' ? referencesOf(value) : [];
|
|
347
|
+
for (const reference of references) {
|
|
348
|
+
if (!hasOwn(effectTokens, reference)) {
|
|
349
|
+
problems.push(`token ${key} references unknown token --effect-${reference}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
written.set(
|
|
353
|
+
key,
|
|
354
|
+
references.filter((reference) => hasOwn(effectTokens, reference))
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const fallback = new Map(written);
|
|
358
|
+
for (const [key, spec] of Object.entries(effectTokens) as [EffectTokenName, EffectTokenSpec][]) {
|
|
359
|
+
if (spec.opaque !== undefined) {
|
|
360
|
+
fallback.set(key, spec.opaque === 'none' ? [] : [spec.opaque]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const [label, graph] of [
|
|
364
|
+
['', written],
|
|
365
|
+
[' in the opaque fallback', fallback],
|
|
366
|
+
] as const) {
|
|
367
|
+
const cycle = findCycle(graph);
|
|
368
|
+
if (cycle !== undefined) {
|
|
369
|
+
problems.push(`tokens form a var() cycle${label}: ${cycle.join(' -> ')}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return problems;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const findCycle = (graph: ReadonlyMap<string, readonly string[]>): string[] | undefined => {
|
|
376
|
+
const state = new Map<string, 'visiting' | 'done'>();
|
|
377
|
+
const path: string[] = [];
|
|
378
|
+
const visit = (node: string): string[] | undefined => {
|
|
379
|
+
if (state.get(node) === 'done') {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (state.get(node) === 'visiting') {
|
|
383
|
+
return [...path.slice(path.indexOf(node)), node];
|
|
384
|
+
}
|
|
385
|
+
state.set(node, 'visiting');
|
|
386
|
+
path.push(node);
|
|
387
|
+
for (const next of graph.get(node) ?? []) {
|
|
388
|
+
const cycle = visit(next);
|
|
389
|
+
if (cycle !== undefined) {
|
|
390
|
+
return cycle;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
path.pop();
|
|
394
|
+
state.set(node, 'done');
|
|
395
|
+
return undefined;
|
|
396
|
+
};
|
|
397
|
+
for (const node of graph.keys()) {
|
|
398
|
+
const cycle = visit(node);
|
|
399
|
+
if (cycle !== undefined) {
|
|
400
|
+
return cycle;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return undefined;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
type ElementClaim = {
|
|
407
|
+
readonly element: string;
|
|
408
|
+
readonly index: number;
|
|
409
|
+
readonly on: string;
|
|
410
|
+
readonly slot: PseudoSlot;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const isExclusive = (first: string, second: string): boolean =>
|
|
414
|
+
isHookId(first) &&
|
|
415
|
+
((effectHooks[first] as { exclusiveWith?: readonly string[] }).exclusiveWith ?? []).includes(second);
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The element a rule styles. A state hook shares its root's element when one host extends the other
|
|
419
|
+
* (`listFileCard.selected`, `dropzone.disabled`); `modal.backdrop` is a different element from `modal`.
|
|
420
|
+
*/
|
|
421
|
+
const elementOf = (on: string): string => {
|
|
422
|
+
if (!isHookId(on)) {
|
|
423
|
+
return on;
|
|
424
|
+
}
|
|
425
|
+
const root = rootOf(on);
|
|
426
|
+
if (!isHookId(root)) {
|
|
427
|
+
return root;
|
|
428
|
+
}
|
|
429
|
+
const rootHost: string = effectHooks[root].host;
|
|
430
|
+
const host: string = effectHooks[on].host;
|
|
431
|
+
return rootHost.startsWith(host) || host.startsWith(rootHost) ? root : on;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const collectRuleProblems = (rules: readonly EffectRule[], origin: RuleOrigin): string[] => {
|
|
435
|
+
const loose = rules as readonly LooseRule[];
|
|
436
|
+
const problems: string[] = [];
|
|
437
|
+
const claims: { index: number; on: string; slot: PseudoSlot }[] = [];
|
|
438
|
+
const edges: ElementClaim[] = [];
|
|
439
|
+
const decorations: ElementClaim[] = [];
|
|
440
|
+
|
|
441
|
+
loose.forEach((rule, index) => {
|
|
442
|
+
const on = typeof rule.on === 'string' ? rule.on : undefined;
|
|
443
|
+
const hook = on !== undefined && isHookId(on);
|
|
444
|
+
const optIn = on !== undefined && isEffectClassName(on);
|
|
445
|
+
|
|
446
|
+
if (on === undefined || (!hook && !optIn)) {
|
|
447
|
+
problems.push(`rules[${index}] unknown hook ${String(rule.on)}`);
|
|
448
|
+
} else if (origin === 'brand' && optIn) {
|
|
449
|
+
problems.push(`rules[${index}] brand rules cannot target opt-in class ${on}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (rule.when !== undefined && rule.when !== 'hover' && rule.when !== 'active') {
|
|
453
|
+
problems.push(`rules[${index}] when must be hover or active`);
|
|
454
|
+
}
|
|
455
|
+
if (rule.unless !== undefined && (typeof rule.unless !== 'string' || !UNLESS_GRAMMAR.test(rule.unless))) {
|
|
456
|
+
problems.push(`rules[${index}] unless must be :disabled or attribute selectors`);
|
|
457
|
+
}
|
|
458
|
+
if (
|
|
459
|
+
rule.acknowledge !== undefined &&
|
|
460
|
+
(!Array.isArray(rule.acknowledge) || rule.acknowledge.some((entry) => !isStateBackground(entry)))
|
|
461
|
+
) {
|
|
462
|
+
problems.push(`rules[${index}] acknowledge must be hover, active, or focus-within`);
|
|
463
|
+
}
|
|
464
|
+
if (rule.material !== undefined && !isMaterial(rule.material)) {
|
|
465
|
+
problems.push(`rules[${index}] unknown material ${String(rule.material)}`);
|
|
466
|
+
}
|
|
467
|
+
if (rule.shadow !== undefined && tokenKind(typeof rule.shadow === 'string' ? rule.shadow : '') !== 'shadow') {
|
|
468
|
+
problems.push(`rules[${index}] shadow must name a shadow token`);
|
|
469
|
+
}
|
|
470
|
+
if (rule.radius !== undefined && tokenKind(typeof rule.radius === 'string' ? rule.radius : '') !== 'length') {
|
|
471
|
+
problems.push(`rules[${index}] radius must name a length token`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (rule.paint !== undefined) {
|
|
475
|
+
if (!isRecord(rule.paint)) {
|
|
476
|
+
problems.push(`rules[${index}] paint must be an object`);
|
|
477
|
+
} else {
|
|
478
|
+
for (const [key, value] of Object.entries(rule.paint)) {
|
|
479
|
+
if (!PAINT_PROPERTIES.has(key)) {
|
|
480
|
+
problems.push(`rules[${index}] paint property ${key} is not allowed`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (typeof value === 'string' && !isSingleValue(value)) {
|
|
484
|
+
problems.push(`rules[${index}] paint.${key} must be a single CSS value`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
expectRef(value, paintKind(key), `paint.${key}`, index, problems);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (rule.material !== undefined && rule.shadow !== undefined && isMaterial(rule.material)) {
|
|
493
|
+
if (materials[rule.material].shadow !== null) {
|
|
494
|
+
problems.push(`rules[${index}] material ${rule.material} already sets box-shadow; drop shadow`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (rule.material !== undefined && isRecord(rule.paint)) {
|
|
499
|
+
for (const key of MATERIAL_PAINTS) {
|
|
500
|
+
if (rule.paint[key] !== undefined) {
|
|
501
|
+
problems.push(`rules[${index}] material cannot combine with paint.${key}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (rule.vars !== undefined) {
|
|
507
|
+
if (!isRecord(rule.vars)) {
|
|
508
|
+
problems.push(`rules[${index}] vars must be an object`);
|
|
509
|
+
} else {
|
|
510
|
+
for (const [key, value] of Object.entries(rule.vars)) {
|
|
511
|
+
if (!remappableVars.has(key)) {
|
|
512
|
+
problems.push(`rules[${index}] vars key ${key} is not remappable`);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (typeof value === 'string' && !isSingleValue(value)) {
|
|
516
|
+
problems.push(`rules[${index}] vars ${key} must be a single CSS value`);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (key === '--effect-font-accent') {
|
|
520
|
+
expectRef(value, 'font', `vars ${key}`, index, problems);
|
|
521
|
+
} else {
|
|
522
|
+
expectRef(value, 'channels', `vars ${key}`, index, problems);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
for (const slot of ['before', 'after'] as const) {
|
|
529
|
+
const decoration = rule[slot];
|
|
530
|
+
if (decoration === undefined) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!isRecord(decoration)) {
|
|
534
|
+
problems.push(`rules[${index}] ${slot} must be a decoration`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
for (const [key, value] of Object.entries(decoration)) {
|
|
538
|
+
if (!DECORATION_FIELDS.has(key)) {
|
|
539
|
+
problems.push(`rules[${index}] ${slot} property ${key} is not allowed`);
|
|
540
|
+
}
|
|
541
|
+
if (typeof value !== 'string' || !isSingleValue(value)) {
|
|
542
|
+
problems.push(`rules[${index}] ${slot}.${key} must be a single CSS value`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (!DECORATION_FIELDS.has(key)) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (key === 'inset' && !LENGTH_PX.test(value)) {
|
|
549
|
+
problems.push(`rules[${index}] ${slot}.inset must be a px length`);
|
|
550
|
+
} else if (key === 'borderWidth' && !NON_NEGATIVE_PX.test(value)) {
|
|
551
|
+
problems.push(`rules[${index}] ${slot}.borderWidth must be a non-negative px length`);
|
|
552
|
+
} else if (key === 'borderRadius' && value !== 'inherit' && !NON_NEGATIVE_PX.test(value)) {
|
|
553
|
+
problems.push(`rules[${index}] ${slot}.borderRadius must be a non-negative px length or inherit`);
|
|
554
|
+
} else if (key === 'borderStyle' && !BORDER_STYLES.has(value)) {
|
|
555
|
+
problems.push(`rules[${index}] ${slot}.borderStyle must be dashed, dotted, or solid`);
|
|
556
|
+
} else if (key === 'borderColor' || key === 'background') {
|
|
557
|
+
expectRef(value, 'paint', `${slot}.${key}`, index, problems);
|
|
558
|
+
} else if (key === 'boxShadow') {
|
|
559
|
+
expectRef(value, 'shadow', `${slot}.${key}`, index, problems);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (on === undefined) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const taken = takenOf(on);
|
|
568
|
+
if (rule.before !== undefined && taken.includes('before')) {
|
|
569
|
+
problems.push(`rules[${index}] decoration uses taken slot before`);
|
|
570
|
+
}
|
|
571
|
+
if (rule.after !== undefined && taken.includes('after')) {
|
|
572
|
+
problems.push(`rules[${index}] decoration uses taken slot after`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const element = elementOf(on);
|
|
576
|
+
for (const slot of ['before', 'after'] as const) {
|
|
577
|
+
if (rule[slot] !== undefined && !taken.includes(slot)) {
|
|
578
|
+
decorations.push({ element, index, on, slot });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (rule.when !== undefined) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (hasEdge(rule)) {
|
|
586
|
+
const slot = freePseudoSlot(taken);
|
|
587
|
+
if (slot === undefined) {
|
|
588
|
+
problems.push(`rules[${index}] edge needs a free slot`);
|
|
589
|
+
} else {
|
|
590
|
+
const decorated =
|
|
591
|
+
(slot === 'before' && rule.before !== undefined) || (slot === 'after' && rule.after !== undefined);
|
|
592
|
+
if (decorated) {
|
|
593
|
+
problems.push(`rules[${index}] edge and decoration claim ${slot}`);
|
|
594
|
+
}
|
|
595
|
+
claims.push({ index, on, slot });
|
|
596
|
+
edges.push({ element, index, on, slot });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (rule.before !== undefined && !taken.includes('before')) {
|
|
600
|
+
claims.push({ index, on, slot: 'before' });
|
|
601
|
+
}
|
|
602
|
+
if (rule.after !== undefined && !taken.includes('after')) {
|
|
603
|
+
claims.push({ index, on, slot: 'after' });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (isHookId(on) && enforcesBackground(rule)) {
|
|
607
|
+
const backgrounds = effectHooks[on].stateBackgrounds;
|
|
608
|
+
for (const state of backgrounds) {
|
|
609
|
+
const acknowledged = Array.isArray(rule.acknowledge) && rule.acknowledge.includes(state);
|
|
610
|
+
const covered =
|
|
611
|
+
state !== 'focus-within' &&
|
|
612
|
+
loose.some((other, otherIndex) => otherIndex > index && other.when === state && other.on === on);
|
|
613
|
+
if (!acknowledged && !covered) {
|
|
614
|
+
problems.push(`rules[${index}] flattens ${state} on ${on}; cover it with when or acknowledge`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const grouped = new Map<string, number[]>();
|
|
621
|
+
for (const claim of claims) {
|
|
622
|
+
const key = `${claim.on}\0${claim.slot}`;
|
|
623
|
+
const indices = grouped.get(key) ?? [];
|
|
624
|
+
indices.push(claim.index);
|
|
625
|
+
grouped.set(key, indices);
|
|
626
|
+
}
|
|
627
|
+
for (const [key, indices] of grouped) {
|
|
628
|
+
const unique = [...new Set(indices)];
|
|
629
|
+
if (unique.length < 2) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const slot = key.split('\0')[1];
|
|
633
|
+
const target = key.split('\0')[0];
|
|
634
|
+
problems.push(`rules[${unique.join(', ')}] claim the same ${slot} slot on ${target}`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A state hook or a `when` rule styles the same element as its root, so its decoration would merge with the
|
|
638
|
+
// root's masked edge (padding, mask, edge background) instead of replacing it.
|
|
639
|
+
for (const decoration of decorations) {
|
|
640
|
+
const edge = edges.find(
|
|
641
|
+
(other) =>
|
|
642
|
+
other.index !== decoration.index &&
|
|
643
|
+
other.element === decoration.element &&
|
|
644
|
+
other.slot === decoration.slot &&
|
|
645
|
+
!isExclusive(other.on, decoration.on)
|
|
646
|
+
);
|
|
647
|
+
if (edge !== undefined) {
|
|
648
|
+
problems.push(
|
|
649
|
+
`rules[${decoration.index}] decorates ${decoration.slot} on ${decoration.element}, where rules[${edge.index}] paints the edge`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
loose.forEach((rule, index) => {
|
|
655
|
+
if (!isHookId(rule.on) || !rule.on.includes('.')) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const root = rootOf(rule.on);
|
|
659
|
+
if (!isHookId(root)) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const laterRoot = loose.findIndex((other, otherIndex) => otherIndex > index && other.on === root);
|
|
663
|
+
if (laterRoot !== -1) {
|
|
664
|
+
problems.push(`rules[${index}] state rule must come after rules for ${root}`);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return problems;
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const throwIf = (problems: readonly string[]): void => {
|
|
672
|
+
if (problems.length > 0) {
|
|
673
|
+
throw new EffectLayerError(problems);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
export const validateEffectLayer = (layer: EffectLayer): void => {
|
|
678
|
+
throwIf([...collectTokenProblems(layer.tokens), ...collectRuleProblems(layer.rules, 'brand')]);
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
export const validateRules = (rules: readonly EffectRule[], origin: RuleOrigin): void => {
|
|
682
|
+
throwIf(collectRuleProblems(rules, origin));
|
|
683
|
+
};
|