@yahoo/uds-create-config 5.61.1 → 5.62.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/dist/configs/react-native-system.d.ts +9 -0
- package/dist/configs/system.js +2 -0
- package/dist/entities/native/NativeSettings.d.ts +9 -0
- package/dist/entities/native/NativeStyleProperty.js +4 -3
- package/dist/entities/system/Settings.d.ts +18 -0
- package/dist/entities/system/Settings.js +35 -16
- package/dist/framework/Config.d.ts +1 -1
- package/dist/framework/Config.js +1 -1
- package/dist/framework/layer-styles.d.ts +11 -1
- package/dist/framework/layer-styles.js +69 -25
- package/dist/framework/schema-version.d.ts +1 -1
- package/dist/framework/schema-version.js +5 -5
- package/dist/framework/utils/color.d.ts +14 -0
- package/dist/framework/utils/color.js +130 -0
- package/dist/framework/utils/css-length.d.ts +12 -0
- package/dist/framework/utils/css-length.js +16 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/lint/context.d.ts +254 -0
- package/dist/lint/context.js +622 -0
- package/dist/lint/edits.d.ts +77 -0
- package/dist/lint/edits.js +121 -0
- package/dist/lint/index.d.ts +5 -0
- package/dist/lint/index.js +5 -0
- package/dist/lint/lint.d.ts +107 -0
- package/dist/lint/lint.js +91 -0
- package/dist/lint/rules.d.ts +337 -0
- package/dist/lint/rules.js +466 -0
- package/dist/migrations/20261027205147_lint_ignore.d.ts +6 -0
- package/dist/migrations/20261027205147_lint_ignore.js +8 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { isRef } from "../framework/utils/refs.js";
|
|
2
|
+
import { resolveTokenValueUnder, stylePropEntries } from "../framework/projections.js";
|
|
3
|
+
import { contrastRatio } from "../framework/utils/color.js";
|
|
4
|
+
import { parseCssLength } from "../framework/utils/css-length.js";
|
|
5
|
+
import "../index.js";
|
|
6
|
+
import { MIN_CONTRAST, SCALE_STEP, TRANSPARENT, written } from "./context.js";
|
|
7
|
+
//#region src/lint/rules.ts
|
|
8
|
+
/**
|
|
9
|
+
* Every lint rule: its group, what it detects, how to fix it, how loud it is by default, and what it
|
|
10
|
+
* reads.
|
|
11
|
+
*
|
|
12
|
+
* - A rule that reads the config carries its `check` here, so it runs wherever a config is loaded:
|
|
13
|
+
* Create's browser on every edit, and `uds lint`.
|
|
14
|
+
* - A rule that reads the sources is checked by a {@link LintSources} adapter, which `lint()` hands it
|
|
15
|
+
* to: codegen's reads the files on disk for `uds lint`, and Create's server reads a branch's sealed
|
|
16
|
+
* sources. Without one, as in Create's browser, those rules don't run.
|
|
17
|
+
*/
|
|
18
|
+
const AUTHORING_LINT_GROUPS = {
|
|
19
|
+
portability: {
|
|
20
|
+
title: "Portability",
|
|
21
|
+
about: "Values that reach a component only through code, so a team that links or extends your system can’t change them."
|
|
22
|
+
},
|
|
23
|
+
editability: {
|
|
24
|
+
title: "Editability",
|
|
25
|
+
about: "Behavior and styling decided in code, where Create can't show or change it. Turn this group off if your system is only ever edited in code."
|
|
26
|
+
},
|
|
27
|
+
maintainability: {
|
|
28
|
+
title: "Maintainability",
|
|
29
|
+
about: "Styles written longer than they need to be: values your tokens already hold, the same value repeated under every option, and settings that change nothing. Each makes the next change harder."
|
|
30
|
+
},
|
|
31
|
+
design: {
|
|
32
|
+
title: "Design",
|
|
33
|
+
about: "Contrast, spacing and sizing your users will notice: text that is hard to read, values just off your scale, colors that stay the same in dark mode, and targets too small to tap."
|
|
34
|
+
},
|
|
35
|
+
compatibility: {
|
|
36
|
+
title: "Compatibility",
|
|
37
|
+
about: "Reliance on parts of the platform that are changing or internal, which a future update can break."
|
|
38
|
+
},
|
|
39
|
+
nursery: {
|
|
40
|
+
title: "Nursery",
|
|
41
|
+
about: "New rules still being tuned. They are off unless you turn them on, and their advice may change.",
|
|
42
|
+
/** Naming the group turns none of them on: a nursery rule is taken up one at a time. */
|
|
43
|
+
defaultSeverity: "off"
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const AUTHORING_LINT_RULES = {
|
|
47
|
+
"inline-style-on-layer": {
|
|
48
|
+
group: "portability",
|
|
49
|
+
label: "Inline style",
|
|
50
|
+
detects: "A style written directly on an element. It beats every rule, so no edit or override can change it.",
|
|
51
|
+
fix: "move the values into the layer's style rule; a value the render works out becomes a `.states()` entry a rule keys on"
|
|
52
|
+
},
|
|
53
|
+
"token-variable-as-text": {
|
|
54
|
+
group: "portability",
|
|
55
|
+
label: "Token spelled as text",
|
|
56
|
+
detects: "A token's CSS variable typed out by hand. Rename the token and this spot keeps the old name.",
|
|
57
|
+
fix: "reference the token: `Token.ref(path)` as a value, `[Token.ref(path).cssVariable.name()]` as a key; a value built from several tokens composes them with `shadow()`, `alpha()`, `timing()` or `transition()`"
|
|
58
|
+
},
|
|
59
|
+
"variable-channel": {
|
|
60
|
+
group: "portability",
|
|
61
|
+
label: "Private CSS variable",
|
|
62
|
+
detects: "A CSS variable the system sets and reads for itself, which overrides and linked systems can’t see.",
|
|
63
|
+
fix: "carry the value the way the reader can take it, which each site names: an element that reads it becomes a layer with the style property set on it, a motion preset takes a variant chosen by a state, a rule takes the value itself, and a runtime value is a prop forwarded to a style property"
|
|
64
|
+
},
|
|
65
|
+
"foreign-variable": {
|
|
66
|
+
group: "portability",
|
|
67
|
+
label: "Another package's variable",
|
|
68
|
+
detects: "A CSS variable from outside the system. Nothing records that the system depends on it.",
|
|
69
|
+
fix: "one the system sets for another library: declare it as a token under that exact name (`cssVariable: '--name'`) and reach it through `Token.ref`, so the dependency is on the record. One it only reads is blocked: a token would set it too, and could override the library's value"
|
|
70
|
+
},
|
|
71
|
+
"runtime-style-read": {
|
|
72
|
+
group: "editability",
|
|
73
|
+
label: "Style read while running",
|
|
74
|
+
detects: "A value measured in the browser at runtime, which the system's settings never see.",
|
|
75
|
+
fix: "a rule, a modifier (`_reducedMotion`, `_rtl`), logical properties, or a `Token.ref` reader"
|
|
76
|
+
},
|
|
77
|
+
"render-data-attribute": {
|
|
78
|
+
group: "editability",
|
|
79
|
+
label: "Hidden state",
|
|
80
|
+
detects: "A state the code sets for styling that the component doesn't declare, so Create can't show, force or preview it.",
|
|
81
|
+
fix: "a value the render knows becomes a `.states()` entry; a value from a declared prop becomes a `when: { prop }` rule"
|
|
82
|
+
},
|
|
83
|
+
"internal-marker": {
|
|
84
|
+
group: "compatibility",
|
|
85
|
+
fixKind: "blocked",
|
|
86
|
+
label: "Internal marker",
|
|
87
|
+
detects: "A platform-internal attribute written by hand, which an update can change without notice.",
|
|
88
|
+
fix: "use the public API that sets it; where none exists, report the gap rather than writing the marker"
|
|
89
|
+
},
|
|
90
|
+
"reserved-variable-name": {
|
|
91
|
+
group: "portability",
|
|
92
|
+
label: "Variable name a token could take",
|
|
93
|
+
detects: "A hand-written CSS variable named like a token, which a future token could collide with.",
|
|
94
|
+
fix: "rename it outside the generated prefixes, or make it a token so the build owns the name"
|
|
95
|
+
},
|
|
96
|
+
"override-copies-source": {
|
|
97
|
+
group: "portability",
|
|
98
|
+
fixKind: "auto",
|
|
99
|
+
label: "Copied instead of extended",
|
|
100
|
+
detects: "An override that copies a linked system's whole list to add to it, so later additions upstream never arrive.",
|
|
101
|
+
fix: "`extend` the list with only the additions, or drop the override when it adds nothing. Order matters only where two entries share a name, since the earlier one wins"
|
|
102
|
+
},
|
|
103
|
+
"number-in-brackets": {
|
|
104
|
+
reads: "both",
|
|
105
|
+
group: "maintainability",
|
|
106
|
+
label: "Number written as text",
|
|
107
|
+
detects: "A number wrapped in brackets, like [240px], where the plain number works and reads better.",
|
|
108
|
+
fix: "write the number (`width: 240`); a property most of whose bracketed values are px takes `arbitrary: 'px'` first, and one whose are plain numbers `arbitrary: 'unitless'`",
|
|
109
|
+
fixKind: "auto",
|
|
110
|
+
check: { value: numberInBrackets }
|
|
111
|
+
},
|
|
112
|
+
"unused-token-group": {
|
|
113
|
+
group: "nursery",
|
|
114
|
+
label: "Unused token group",
|
|
115
|
+
detects: "A group of tokens a property offers that nothing picks.",
|
|
116
|
+
fix: "drop the group from the property's `values`, once the systems and apps in other repos that use it are checked too"
|
|
117
|
+
},
|
|
118
|
+
"duplicate-value-name": {
|
|
119
|
+
group: "portability",
|
|
120
|
+
label: "Two values, one name",
|
|
121
|
+
detects: "A property that offers two values under the same name. Only one can ever be picked.",
|
|
122
|
+
fix: "rename one of them, or drop the one that isn’t wanted, so each name means one value"
|
|
123
|
+
},
|
|
124
|
+
"render-default": {
|
|
125
|
+
group: "editability",
|
|
126
|
+
label: "Default hidden in code",
|
|
127
|
+
detects: "A default value chosen in code rather than in the component's settings, so Create shows a different default than what renders.",
|
|
128
|
+
fix: "move it into `.defaultProps({ … })`; a default the component already declares is deleted from the render"
|
|
129
|
+
},
|
|
130
|
+
"undeclared-prop": {
|
|
131
|
+
group: "editability",
|
|
132
|
+
label: "Property Create can't see",
|
|
133
|
+
detects: "A property the component uses that it doesn't declare, so it doesn't show in the panel and can't be set or previewed.",
|
|
134
|
+
fix: "declare it in `.props()` with the type it takes; a handler is a forward to the layer that takes it"
|
|
135
|
+
},
|
|
136
|
+
"state-for-modifier": {
|
|
137
|
+
group: "editability",
|
|
138
|
+
label: "Hover tracked in code",
|
|
139
|
+
detects: "Hover or focus tracked by code, where a built-in state would do the same and show in Create.",
|
|
140
|
+
fix: "write the rules it chooses under the modifier the handlers follow (`_hover`, `_focus`, `_focusVisible`, `_focusWithin`, or a `_group…` one for a parent), and drop the state and its handlers"
|
|
141
|
+
},
|
|
142
|
+
"forward-replaced-by-render": {
|
|
143
|
+
group: "editability",
|
|
144
|
+
label: "Content overridden in code",
|
|
145
|
+
detects: "Content the component says goes to a layer, which the code replaces, so what you set doesn't show.",
|
|
146
|
+
fix: "say what the render does: a prop the render places itself is a slot (or a `string`), and the forward goes; content the layer should take as it is is the forward, and the render's children go"
|
|
147
|
+
},
|
|
148
|
+
"glyph-in-render": {
|
|
149
|
+
group: "editability",
|
|
150
|
+
label: "Icon chosen in code",
|
|
151
|
+
detects: "An icon the code picks, which Create can't show or swap.",
|
|
152
|
+
fix: "set `name` in a rule on the icon layer, one per value of the prop or state that picks it"
|
|
153
|
+
},
|
|
154
|
+
"computed-config": {
|
|
155
|
+
group: "editability",
|
|
156
|
+
label: "Styles built by code",
|
|
157
|
+
detects: "Styles or options assembled by code (a shared constant, a spread or a condition). Create can show them but an edit can’t be written back.",
|
|
158
|
+
fix: "write the value in place, as a literal or a `Token.ref`; a list shared with code is written in the config and read from it"
|
|
159
|
+
},
|
|
160
|
+
"restated-in-every-variant": {
|
|
161
|
+
group: "maintainability",
|
|
162
|
+
label: "Same value in every option",
|
|
163
|
+
detects: "A value set identically under every option of a property. It never varies, so it belongs in the component's base style.",
|
|
164
|
+
fix: "set it once, in the base rule"
|
|
165
|
+
},
|
|
166
|
+
"variant-passthrough": {
|
|
167
|
+
group: "nursery",
|
|
168
|
+
label: "One rule per option",
|
|
169
|
+
detects: "A separate rule for each option that only passes the option along, so every new option needs a new rule.",
|
|
170
|
+
fix: "forward the prop to the layer: `{ type: 'forward', target: '<layer>/<prop>' }`"
|
|
171
|
+
},
|
|
172
|
+
"no-op-modifier": {
|
|
173
|
+
group: "maintainability",
|
|
174
|
+
label: "State that changes nothing",
|
|
175
|
+
detects: "A hover, focus or mode style that sets a value to what it already is.",
|
|
176
|
+
fix: "delete it, once no system that links this one relies on it: one that changes the value but not the modifier's keeps the modifier's here, and loses it when it's deleted. The finding carries the deletion for whoever makes it"
|
|
177
|
+
},
|
|
178
|
+
"inherited-value-restated": {
|
|
179
|
+
group: "maintainability",
|
|
180
|
+
label: "Repeats its parent",
|
|
181
|
+
detects: "A layer set to the same value it already gets from the layer around it.",
|
|
182
|
+
fix: "delete the layer's entry, so it inherits as the base rule says, once no system that links this one relies on it: one that changes the parent's value keeps the layer's here, and the layer follows the parent once it's deleted. The finding carries the deletion for whoever makes it"
|
|
183
|
+
},
|
|
184
|
+
"hand-forwarded-prop": {
|
|
185
|
+
group: "editability",
|
|
186
|
+
label: "Property passed along in code",
|
|
187
|
+
detects: "A property the code hands to one layer by hand. Declaring where it goes lets Create show it and lets linked systems route it.",
|
|
188
|
+
fix: "declare it as a forward to that layer, `{ type: 'forward', target: '<layer>/<attribute>' }`, and drop it from the render; an element that isn't a layer becomes one first"
|
|
189
|
+
},
|
|
190
|
+
"literal-matches-token": {
|
|
191
|
+
reads: "both",
|
|
192
|
+
group: "maintainability",
|
|
193
|
+
label: "Value a token holds",
|
|
194
|
+
detects: "A value typed by hand that one of your tokens already holds. Change the token and this spot is left behind.",
|
|
195
|
+
fix: "use the token's name, as the style property takes it (`padding: '2'`)",
|
|
196
|
+
check: { value: literalMatchesToken }
|
|
197
|
+
},
|
|
198
|
+
"layer-bundle-split": {
|
|
199
|
+
group: "editability",
|
|
200
|
+
label: "Layer split apart in code",
|
|
201
|
+
detects: "A layer's styling taken apart in code and partly applied elsewhere, so Create's edits land in the wrong place.",
|
|
202
|
+
fix: "spread each layer's bundle whole on its element, and declare each prop another element takes as a forward to its layer, by name (`{ type: 'forward', target: 'input/onChange' }`); a value read off a bundle to compute with goes in a rule"
|
|
203
|
+
},
|
|
204
|
+
"context-carried-variant": {
|
|
205
|
+
group: "editability",
|
|
206
|
+
label: "Option passed down invisibly",
|
|
207
|
+
detects: "A size or variant a part inherits from its parent behind the scenes, which Create doesn't know about.",
|
|
208
|
+
fix: "a rule on the parent that sets the part's layer: `{ when: { size: 'sm' }, layers: { item: { size: 'sm' } } }`; parts rendered in a list wait on repeated parts"
|
|
209
|
+
},
|
|
210
|
+
"forced-prop-in-render": {
|
|
211
|
+
group: "editability",
|
|
212
|
+
label: "Value fixed in code",
|
|
213
|
+
detects: "A style or option the code sets on a nested component, which Create can't change.",
|
|
214
|
+
fix: "set it in a rule on that layer (`layers: { action: { variant: 'ghost' } }`), one per case for a picked value, making the element a layer first if it isn't one"
|
|
215
|
+
},
|
|
216
|
+
"ref-replaced-by-literal": {
|
|
217
|
+
group: "portability",
|
|
218
|
+
label: "Linked token copied",
|
|
219
|
+
detects: "A linked token replaced by the value it already had, so it stops following the system it came from.",
|
|
220
|
+
fix: "drop the override, or keep the ref the source has"
|
|
221
|
+
},
|
|
222
|
+
"shadowed-pin-directive": {
|
|
223
|
+
group: "portability",
|
|
224
|
+
label: "Option that does nothing",
|
|
225
|
+
detects: "A linked system's setting for a component this system replaces with its own, so the setting has no effect.",
|
|
226
|
+
fix: "delete the option, or move what it sets onto this system's own component"
|
|
227
|
+
},
|
|
228
|
+
"implicit-children": {
|
|
229
|
+
group: "compatibility",
|
|
230
|
+
label: "Content not declared",
|
|
231
|
+
detects: "Content the component accepts without declaring it. The old way is going away, and Create can't see it until then.",
|
|
232
|
+
fix: "declare it: `children: { type: 'slot' }`, or a forward to the layer the render places it in, `{ type: 'forward', target: '<layer>/children' }`"
|
|
233
|
+
},
|
|
234
|
+
"copied-component": {
|
|
235
|
+
group: "nursery",
|
|
236
|
+
label: "Copied component",
|
|
237
|
+
detects: "A component whose styles are another's, word for word, so a change to one doesn't reach the other.",
|
|
238
|
+
fix: "extend the other, `Component.ref('Other').extend({ … })`, and keep only what differs; two near copies extend one component both share"
|
|
239
|
+
},
|
|
240
|
+
"low-contrast": {
|
|
241
|
+
group: "design",
|
|
242
|
+
reads: "config",
|
|
243
|
+
label: "Low contrast",
|
|
244
|
+
detects: "Text that is hard to read against its background in light or dark mode. Text needs 4.5:1, icons 3:1.",
|
|
245
|
+
fix: "pick a text or background token that clears 4.5:1 in every mode",
|
|
246
|
+
check: { layer: lowContrast }
|
|
247
|
+
},
|
|
248
|
+
"near-token": {
|
|
249
|
+
group: "design",
|
|
250
|
+
reads: "config",
|
|
251
|
+
label: "Almost a token",
|
|
252
|
+
detects: "A value a pixel or so off one of your tokens. Usually a typo; if it's on purpose, it may belong on the scale.",
|
|
253
|
+
fix: "use the token when it was meant; if the value is on purpose, it may belong on the scale",
|
|
254
|
+
check: { value: nearToken }
|
|
255
|
+
},
|
|
256
|
+
"fixed-in-dark-mode": {
|
|
257
|
+
group: "design",
|
|
258
|
+
reads: "config",
|
|
259
|
+
label: "Same in dark mode",
|
|
260
|
+
detects: "A color that doesn't change in dark mode while the rest of the component does.",
|
|
261
|
+
fix: "use a token that has a dark value, or give this one a `_dark` value",
|
|
262
|
+
check: { component: fixedInDarkMode }
|
|
263
|
+
},
|
|
264
|
+
"small-tap-target": {
|
|
265
|
+
group: "design",
|
|
266
|
+
reads: "config",
|
|
267
|
+
label: "Small tap target",
|
|
268
|
+
detects: "Something you press that is too small to hit reliably: under 24 × 24 px on the web, 44 × 44 pt in a native app.",
|
|
269
|
+
fix: "make it at least that size each way, or grow its hit area with padding",
|
|
270
|
+
check: { layer: smallTapTarget }
|
|
271
|
+
},
|
|
272
|
+
"duplicate-token": {
|
|
273
|
+
group: "design",
|
|
274
|
+
reads: "config",
|
|
275
|
+
label: "Two tokens, one value",
|
|
276
|
+
detects: "Two steps on a scale that hold the same value, so nobody can tell which to pick.",
|
|
277
|
+
fix: "keep one, or make the other a reference to it (`Token.ref(…)`) so the two are one decision",
|
|
278
|
+
check: { system: duplicateToken }
|
|
279
|
+
},
|
|
280
|
+
"wildcard-forward": {
|
|
281
|
+
group: "nursery",
|
|
282
|
+
label: "Accepts anything",
|
|
283
|
+
detects: "A component that passes along every property it gets. It will soon accept only what it declares.",
|
|
284
|
+
fix: "declare each one as `{ type: 'forward', target: '<layer>/<prop>' }` and drop the entry. A list is ready; a `'*'` waits on what callers outside the files read pass, and on the prop type it names (`type: 'modifier'`, the `as` element forward)"
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
/** What a rule checks; see `reads` on {@link AUTHORING_LINT_RULES}. */
|
|
288
|
+
const lintReads = (rule) => {
|
|
289
|
+
const entry = AUTHORING_LINT_RULES[rule];
|
|
290
|
+
return "reads" in entry ? entry.reads : "code";
|
|
291
|
+
};
|
|
292
|
+
/** The rules that read the config, which `lintConfig` runs wherever a config is loaded. */
|
|
293
|
+
const CONFIG_LINT_RULES = Object.keys(AUTHORING_LINT_RULES).filter((rule) => lintReads(rule) !== "code");
|
|
294
|
+
/** A rule's severity as `settings.lint.rules` sets it: its own entry, else its group's, else the
|
|
295
|
+
* group's default. */
|
|
296
|
+
function ruleSeverity(rule, severities) {
|
|
297
|
+
const { group } = AUTHORING_LINT_RULES[rule];
|
|
298
|
+
const groupAbout = AUTHORING_LINT_GROUPS[group];
|
|
299
|
+
const fallback = "defaultSeverity" in groupAbout ? groupAbout.defaultSeverity : "warn";
|
|
300
|
+
return severities[rule] ?? (fallback === "off" ? "off" : severities[group]) ?? fallback;
|
|
301
|
+
}
|
|
302
|
+
/** literal-matches-token: the same text a token holds. */
|
|
303
|
+
function literalMatchesToken(ctx, value, report) {
|
|
304
|
+
const match = ctx.tokenSpelled(value.site);
|
|
305
|
+
if (!match) return;
|
|
306
|
+
report({
|
|
307
|
+
at: value,
|
|
308
|
+
message: `\`${value.bracketed}\` is what ${value.site.prop}'s \`${match.name}\` holds. Use the token, so a change to it reaches this layer.`,
|
|
309
|
+
suggestion: match.name
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Whether `literal-matches-token` already reports a value, which the other value checks then leave. */
|
|
313
|
+
const spelledOut = (ctx, value) => ctx.runs("literal-matches-token") && ctx.tokenSpelled(value.site) !== void 0;
|
|
314
|
+
/** number-in-brackets: a property that reads bare numbers, written in brackets. */
|
|
315
|
+
function numberInBrackets(ctx, value, report) {
|
|
316
|
+
const { bracketed, site } = value;
|
|
317
|
+
if (!bracketed || spelledOut(ctx, value)) return;
|
|
318
|
+
const reads = ctx.values.arbitrary.get(site.prop);
|
|
319
|
+
if (reads !== "px" && reads !== "unitless") return;
|
|
320
|
+
const length = parseCssLength(bracketed);
|
|
321
|
+
if (!length || length.unit === "rem") return;
|
|
322
|
+
if ((length.unit === "px" ? "px" : "unitless") !== reads) return;
|
|
323
|
+
const bare = bracketed.trim().replace(/px$/, "");
|
|
324
|
+
report({
|
|
325
|
+
at: value,
|
|
326
|
+
message: `\`${site.prop}\` takes a number in ${reads === "px" ? "px" : "as it is"}: write \`${bare}\`.`,
|
|
327
|
+
suggestion: bare
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/** near-token: off a token by a pixel or so. */
|
|
331
|
+
function nearToken(ctx, value, report) {
|
|
332
|
+
const { site } = value;
|
|
333
|
+
if (!value.bracketed && typeof site.value !== "number") return;
|
|
334
|
+
if (spelledOut(ctx, value)) return;
|
|
335
|
+
const px = value.px();
|
|
336
|
+
const tokens = ctx.tokensFor(site.prop).filter((one) => one.px !== void 0);
|
|
337
|
+
if (px === void 0 || px === 0 || tokens.length < 3) return;
|
|
338
|
+
const nearest = tokens.map((one) => ({
|
|
339
|
+
...one,
|
|
340
|
+
off: Math.abs(one.px - px)
|
|
341
|
+
})).sort((a, b) => a.off - b.off)[0];
|
|
342
|
+
if (!nearest || nearest.off <= 0 || nearest.off > Math.max(1, nearest.px * .05) || tokens.some((one) => one.px === px)) return;
|
|
343
|
+
report({
|
|
344
|
+
at: value,
|
|
345
|
+
message: `\`${written(site.value)}\` is ${Math.round(nearest.off * 100) / 100}px off ${site.prop}'s \`${nearest.name}\` (${nearest.literal}${/rem$/.test(nearest.literal) ? `, ${nearest.px}px at a 16px root` : ""}).`,
|
|
346
|
+
suggestion: nearest.name
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/** fixed-in-dark-mode: colors that hold still while the component's others change. */
|
|
350
|
+
function fixedInDarkMode(ctx, component, report) {
|
|
351
|
+
const { values, modes } = ctx;
|
|
352
|
+
if (modes.length <= 1) return;
|
|
353
|
+
const { sites } = component;
|
|
354
|
+
const leaf = (modifier) => modifier.replace(/^_/, "");
|
|
355
|
+
const modeOptions = new Set(modes.map(({ label }) => label));
|
|
356
|
+
const otherModes = new Set(modes.filter(({ condition }) => condition !== null).map(({ label }) => label));
|
|
357
|
+
const colors = sites.filter((site) => values.colorKeys.has(site.prop) && !site.modifiers.some((one) => modeOptions.has(leaf(one)))).map((site) => ctx.value(component, site));
|
|
358
|
+
const changes = (value) => modes.some((mode) => mode.condition !== null && String(value.literal(mode)).toLowerCase() !== String(value.literal()).toLowerCase());
|
|
359
|
+
const changing = colors.filter(changes);
|
|
360
|
+
if (changing.length < 2) return;
|
|
361
|
+
const darkSet = new Set(sites.filter((site) => site.modifiers.some((one) => otherModes.has(leaf(one)))).map((site) => `${site.when}|${site.layer}|${site.prop}`));
|
|
362
|
+
const dark = modes.find(({ condition }) => condition !== null)?.label ?? "dark";
|
|
363
|
+
for (const value of colors) {
|
|
364
|
+
const { site } = value;
|
|
365
|
+
if (changing.includes(value)) continue;
|
|
366
|
+
if (darkSet.has(`${site.when}|${site.layer}|${site.prop}`)) continue;
|
|
367
|
+
const literal = value.literal();
|
|
368
|
+
if (typeof literal !== "string" || TRANSPARENT.test(literal.trim())) continue;
|
|
369
|
+
const color = value.color();
|
|
370
|
+
if (!color || color[3] === 0) continue;
|
|
371
|
+
report({
|
|
372
|
+
at: value,
|
|
373
|
+
message: value.token ? `\`${value.token}\` is ${literal} in ${dark} mode too, while ${changing.length} of this component's colors change.` : `\`${written(site.value)}\` is written as a color, so it stays ${literal} in ${dark} mode while ${changing.length} of this component's colors change.`
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** low-contrast: a layer's text over its background, in each mode. */
|
|
378
|
+
function lowContrast(ctx, layer, report) {
|
|
379
|
+
if (!layer.ownText && !layer.ownBackground) return;
|
|
380
|
+
const { text, background } = layer;
|
|
381
|
+
if (!text || !background) return;
|
|
382
|
+
if (written(text.site.value) === written(background.site.value)) return;
|
|
383
|
+
const minimum = layer.graphic ? 3 : MIN_CONTRAST;
|
|
384
|
+
for (const mode of ctx.modes) {
|
|
385
|
+
const fg = text.color(mode);
|
|
386
|
+
const bg = background.color(mode);
|
|
387
|
+
if (!fg || !bg || bg[3] < 1 || fg[3] === 0) continue;
|
|
388
|
+
const ratio = contrastRatio(fg, bg);
|
|
389
|
+
if (ratio >= minimum) continue;
|
|
390
|
+
const contrast = {
|
|
391
|
+
component: layer.component,
|
|
392
|
+
text: text.site,
|
|
393
|
+
background: background.site,
|
|
394
|
+
minimum
|
|
395
|
+
};
|
|
396
|
+
const backdrop = ctx.passingToken({
|
|
397
|
+
change: "background",
|
|
398
|
+
...contrast
|
|
399
|
+
});
|
|
400
|
+
const recolor = backdrop ? void 0 : ctx.passingToken({
|
|
401
|
+
change: "text",
|
|
402
|
+
...contrast
|
|
403
|
+
});
|
|
404
|
+
let advice = "";
|
|
405
|
+
if (backdrop) advice = ` \`${backdrop.token}\` is the closest background that clears it in every mode.`;
|
|
406
|
+
else if (recolor) advice = ` \`${recolor.token}\` is the closest text color that clears it in every mode.`;
|
|
407
|
+
report({
|
|
408
|
+
at: text,
|
|
409
|
+
message: `\`${written(text.site.value)}\` on \`${written(background.site.value)}\` is ${(Math.floor(ratio * 100) / 100).toFixed(2)}:1 in ${mode.label} mode${layer.where ? ` (${layer.where})` : ""}; ${minimum === 4.5 ? "text" : "an icon"} needs ${minimum}:1.${advice}`,
|
|
410
|
+
...backdrop ? {
|
|
411
|
+
suggestion: backdrop.name,
|
|
412
|
+
fixAt: background
|
|
413
|
+
} : {},
|
|
414
|
+
...recolor ? { suggestion: recolor.name } : {}
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** small-tap-target: the root of something you press, under the platform's minimum. */
|
|
420
|
+
function smallTapTarget(ctx, layer, report) {
|
|
421
|
+
if (!layer.pressable) return;
|
|
422
|
+
const { minTarget, unit } = ctx.platform;
|
|
423
|
+
for (const [value, reads] of [[layer.height, "tall"], [layer.width, "wide"]]) {
|
|
424
|
+
const px = value?.px();
|
|
425
|
+
if (!value || px === void 0 || px <= 0 || px >= minTarget) continue;
|
|
426
|
+
report({
|
|
427
|
+
at: value,
|
|
428
|
+
message: `${layer.where ? `At ${layer.where}, it` : "It"}'s ${px}${unit} ${reads}; a target you press needs ${minTarget}${unit}.`
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** duplicate-token: two tokens a property offers, written with one value in every mode. */
|
|
433
|
+
function duplicateToken(ctx, report) {
|
|
434
|
+
const { config, modes } = ctx;
|
|
435
|
+
const reported = /* @__PURE__ */ new Set();
|
|
436
|
+
for (const property of config.list("style-property")) {
|
|
437
|
+
const key = property.path.split("/").pop() ?? property.path;
|
|
438
|
+
const byValue = /* @__PURE__ */ new Map();
|
|
439
|
+
const listed = stylePropEntries(config, property.path).map(({ value }) => value).filter((value) => config.resolve("token", value) !== void 0);
|
|
440
|
+
for (const token of listed) {
|
|
441
|
+
if (!ctx.own("token", token)) continue;
|
|
442
|
+
if (config.deprecated("token", token)) continue;
|
|
443
|
+
if (!SCALE_STEP.test(token.split("/").pop() ?? "")) continue;
|
|
444
|
+
const body = config.resolve("token", token)?.toJSON();
|
|
445
|
+
if (body?.value === void 0 || isRef(body.value) || typeof body.value === "object") continue;
|
|
446
|
+
const signature = modes.map(({ condition }) => String(resolveTokenValueUnder(config, token, condition)).trim().toLowerCase()).join("|");
|
|
447
|
+
byValue.set(signature, [...byValue.get(signature) ?? [], token]);
|
|
448
|
+
}
|
|
449
|
+
for (const [signature, tokens] of byValue) {
|
|
450
|
+
const [first, ...rest] = [...new Set(tokens)];
|
|
451
|
+
for (const token of rest) {
|
|
452
|
+
if (!first || reported.has(token)) continue;
|
|
453
|
+
reported.add(token);
|
|
454
|
+
report({
|
|
455
|
+
token,
|
|
456
|
+
prop: key,
|
|
457
|
+
value: signature.split("|")[0],
|
|
458
|
+
message: `\`${token}\` holds ${signature.split("|")[0]}, as \`${first}\` does${modes.length > 1 ? " in every mode" : ""}, and ${key} offers both.`,
|
|
459
|
+
suggestion: first
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
export { AUTHORING_LINT_GROUPS, AUTHORING_LINT_RULES, CONFIG_LINT_RULES, lintReads, ruleSeverity };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** `settings.lint.ignore`, the findings a system keeps on purpose, is additive: an optional list a
|
|
2
|
+
* config without one never had. Existing configs and draft patches retain their meaning.
|
|
3
|
+
* Retire this identity hop only when no supported artifact predates this schema. */
|
|
4
|
+
import type { SchemaMigration } from '../framework/schema-version';
|
|
5
|
+
export declare const LINT_IGNORE_VERSION = 20261027205147;
|
|
6
|
+
export declare const lintIgnoreMigration: SchemaMigration;
|