flet-box 1.0.7 → 1.0.9
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/README.md +3 -0
- package/bin/cli.js +13 -0
- package/bin/commands/checkApi.js +13 -1
- package/bin/commands/checkUi.js +1369 -0
- package/bin/commands/create.js +49 -5
- package/bin/commands/createBundle.js +21 -0
- package/bin/commands/runServer.js +11 -2
- package/bin/utils/pwaShell.js +137 -0
- package/bin/utils/templates.js +1398 -443
- package/docs/README.md +4 -0
- package/docs/cli/README.md +63 -2
- package/docs/guides/README.md +2 -0
- package/docs/guides/clean-ui.md +211 -0
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/mobile-and-platforms.md +31 -0
- package/docs/guides/online-updates.md +48 -0
- package/docs/tools/README.md +1 -1
- package/docs/tools/animation.md +1 -1
- package/docs/tools/markdown.md +3 -3
- package/docs/tools/theme.md +42 -1
- package/docs/widget/Accordion.md +3 -0
- package/docs/widget/AdMob.md +4 -1
- package/docs/widget/AdSense.md +3 -0
- package/docs/widget/AnimatedBox.md +6 -3
- package/docs/widget/AnimatedText.md +3 -0
- package/docs/widget/Audio.md +3 -0
- package/docs/widget/Avatar.md +3 -0
- package/docs/widget/Badge.md +3 -0
- package/docs/widget/Button.md +3 -0
- package/docs/widget/COMMON_PROPS.md +52 -0
- package/docs/widget/Card.md +3 -0
- package/docs/widget/Carousel.md +3 -0
- package/docs/widget/Chart.md +3 -0
- package/docs/widget/Checkbox.md +3 -0
- package/docs/widget/Chip.md +3 -0
- package/docs/widget/CircularBar.md +3 -0
- package/docs/widget/CircularChart.md +3 -0
- package/docs/widget/CodeViewer.md +3 -0
- package/docs/widget/Column.md +3 -0
- package/docs/widget/Container.md +3 -0
- package/docs/widget/DataTable.md +3 -0
- package/docs/widget/Divider.md +3 -0
- package/docs/widget/DraggBox.md +3 -0
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +3 -0
- package/docs/widget/FloatingActionButton.md +3 -0
- package/docs/widget/GridView.md +3 -0
- package/docs/widget/Icon.md +3 -0
- package/docs/widget/Image.md +3 -0
- package/docs/widget/Input.md +3 -0
- package/docs/widget/Inspector.md +2 -2
- package/docs/widget/InstallButton.md +3 -0
- package/docs/widget/ListTile.md +3 -0
- package/docs/widget/ListView.md +3 -0
- package/docs/widget/Maps.md +3 -0
- package/docs/widget/Markdown.md +3 -0
- package/docs/widget/MatrixRain.md +3 -0
- package/docs/widget/Pagination.md +3 -0
- package/docs/widget/ParallaxBox.md +3 -0
- package/docs/widget/ProgressBar.md +3 -0
- package/docs/widget/QRCode.md +3 -0
- package/docs/widget/README.md +2 -0
- package/docs/widget/Radio.md +4 -1
- package/docs/widget/Rating.md +3 -0
- package/docs/widget/Row.md +3 -0
- package/docs/widget/Skeleton.md +3 -0
- package/docs/widget/Slider.md +3 -0
- package/docs/widget/Stack.md +3 -0
- package/docs/widget/Stepper.md +3 -0
- package/docs/widget/Switch.md +3 -0
- package/docs/widget/Tabs.md +5 -5
- package/docs/widget/Text.md +3 -0
- package/docs/widget/Tooltip.md +3 -0
- package/docs/widget/TreeView.md +3 -0
- package/docs/widget/Video.md +3 -0
- package/package.json +2 -2
- package/src/animations/AnimatedBox.js +4 -1
- package/src/animations/MatrixRain.js +3 -3
- package/src/index.d.ts +64 -8
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +7 -12
- package/src/navigations/BottomNavigation.js +8 -13
- package/src/navigations/CollapsibleSideBar.js +30 -8
- package/src/navigations/Drawer.js +16 -14
- package/src/navigations/DrawerItem.js +3 -1
- package/src/navigations/Scaffold.js +26 -12
- package/src/navigations/Tabs.js +104 -59
- package/src/tools/color.js +54 -0
- package/src/tools/index.js +9 -1
- package/src/tools/scale.js +41 -0
- package/src/tools/shadow.js +20 -0
- package/src/utils/index.js +1 -0
- package/src/utils/readableInk.js +19 -0
- package/src/utils/syntaxHighlight.js +78 -10
- package/src/utils/themes.js +265 -58
- package/src/utils/units.js +33 -11
- package/src/widget-factory/effects.js +56 -5
- package/src/widget-factory/processProps.js +10 -1
- package/src/widget-factory/remTool.js +15 -29
- package/src/widget-factory/stackPosition.js +18 -3
- package/src/widget-factory/translateProps.js +28 -0
- package/src/widgets/Accordion.js +20 -23
- package/src/widgets/AdMob.js +7 -2
- package/src/widgets/Avatar.js +18 -12
- package/src/widgets/Badge.js +3 -2
- package/src/widgets/BottomSheet.js +14 -13
- package/src/widgets/Button.js +69 -30
- package/src/widgets/Card.js +6 -2
- package/src/widgets/Carousel.js +12 -10
- package/src/widgets/Chart.js +28 -5
- package/src/widgets/Checkbox.js +76 -18
- package/src/widgets/Chip.js +31 -10
- package/src/widgets/CircularBar.js +7 -7
- package/src/widgets/CircularChart.js +26 -8
- package/src/widgets/CodeViewer.js +93 -20
- package/src/widgets/DataTable.js +17 -11
- package/src/widgets/DraggBox.js +2 -1
- package/src/widgets/Dropdown.js +26 -13
- package/src/widgets/DroppBox.js +4 -2
- package/src/widgets/FloatingActionButton.js +13 -7
- package/src/widgets/Icon.js +3 -1
- package/src/widgets/Input.js +30 -23
- package/src/widgets/Inspector.js +54 -1
- package/src/widgets/InstallButton.js +6 -4
- package/src/widgets/ListTile.js +4 -3
- package/src/widgets/ListView.js +30 -12
- package/src/widgets/Markdown.js +37 -26
- package/src/widgets/Modal.js +14 -11
- package/src/widgets/Pagination.js +16 -11
- package/src/widgets/ProgressBar.js +6 -4
- package/src/widgets/Radio.js +73 -15
- package/src/widgets/Rating.js +24 -6
- package/src/widgets/Skeleton.js +31 -31
- package/src/widgets/Slider.js +29 -18
- package/src/widgets/SnackBar.js +35 -39
- package/src/widgets/Stepper.js +13 -9
- package/src/widgets/Switch.js +59 -18
- package/src/widgets/Tooltip.js +12 -10
- package/src/widgets/TreeView.js +27 -15
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bin/commands/checkUi.js
|
|
3
|
+
* @description `flet-box check-ui` — the clean-UI gate for a generated app.
|
|
4
|
+
*
|
|
5
|
+
* It reads the app's own `src/` and reports the design mistakes a static pass can
|
|
6
|
+
* prove: a hand-typed color literal instead of a token, a size that is not on the
|
|
7
|
+
* 8-based scale, a tap target below the Fitts floor, a form field with no label,
|
|
8
|
+
* and text whose contrast against the fill it sits on is below WCAG AA. Every prop
|
|
9
|
+
* name it uses comes from the installed package's own prop registry, so the
|
|
10
|
+
* checker cannot invent an API while judging someone else's.
|
|
11
|
+
*
|
|
12
|
+
* What it deliberately does not judge: taste, hierarchy, and whether the screen
|
|
13
|
+
* solves the user's problem. Those need a real person, and the skill file that
|
|
14
|
+
* ships with the app says so instead of pretending otherwise.
|
|
15
|
+
*
|
|
16
|
+
* flet-box check-ui scan ./src
|
|
17
|
+
* flet-box check-ui --root <dir> scan another project
|
|
18
|
+
* flet-box check-ui --tokens print the measured token contrast table
|
|
19
|
+
* flet-box check-ui --strict contrast warnings fail too
|
|
20
|
+
* flet-box check-ui --json machine-readable findings
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from "fs";
|
|
24
|
+
import path from "path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { loadApi, collectSources } from "./checkApi.js";
|
|
27
|
+
import { resolveInterfaceProps } from "../utils/dtsProps.js";
|
|
28
|
+
import { c, banner, section } from "../utils/colors.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The 8-based rhythm, the radii and the Fitts floor. These are not this file's
|
|
32
|
+
* opinion: they are the framework's own `src/tools/scale.js`, the same table the
|
|
33
|
+
* widget defaults are drawn from. A gate that keeps its own copy drifts, and the
|
|
34
|
+
* app then gets told to be clean by numbers the widgets it uses do not follow.
|
|
35
|
+
*/
|
|
36
|
+
export { SPACING_SCALE, RADIUS_SCALE, TYPE_SCALE, TAP_MINIMUM } from "../../src/tools/scale.js";
|
|
37
|
+
import { SPACING_SCALE, RADIUS_SCALE, TYPE_SCALE, TAP_MINIMUM } from "../../src/tools/scale.js";
|
|
38
|
+
|
|
39
|
+
/** WCAG 2.1: 4.5:1 for body text, 3:1 for large text and for non-text UI. */
|
|
40
|
+
export const CONTRAST_BODY = 4.5;
|
|
41
|
+
export const CONTRAST_LARGE = 3;
|
|
42
|
+
/** At or above this text size WCAG moves to the 3:1 threshold. */
|
|
43
|
+
export const LARGE_TEXT_SIZE = 24;
|
|
44
|
+
|
|
45
|
+
/** A run of text this long is a paragraph, and a paragraph never centers. */
|
|
46
|
+
export const BODY_TEXT_CHARS = 60;
|
|
47
|
+
|
|
48
|
+
/** The widest a block of body copy may run before the eye loses the next line. */
|
|
49
|
+
export const MEASURE_CHARS = 75;
|
|
50
|
+
|
|
51
|
+
const PRESS_KEYS = ["onPress", "onClick", "onclick", "onTap"];
|
|
52
|
+
const COLOR_FALLBACK_KEYS = new Set(["color", "textColor", "bgColor", "backgroundColor"]);
|
|
53
|
+
const FIELD_WIDGETS = ["Input", "Dropdown", "Slider", "Checkbox", "Radio", "Switch"];
|
|
54
|
+
const TOGGLE_WIDGETS = ["Switch", "Checkbox", "Radio"];
|
|
55
|
+
const LIST_WIDGETS = ["ListView", "GridView", "DataTable"];
|
|
56
|
+
const STATE_WIDGETS = ["Skeleton", "ProgressBar", "SnackBar", "AlertDialog", "Modal"];
|
|
57
|
+
const ICON_WIDGETS = ["Icon", "IconButton"];
|
|
58
|
+
const TEXT_KEYS = ["text", "value", "label", "message", "title"];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Copy that says nothing about what pressing the button does. "Cancel" is absent
|
|
62
|
+
* on purpose: on a destructive dialog it is the one correct word.
|
|
63
|
+
*/
|
|
64
|
+
const GENERIC_LABELS = new Set(["ok", "yes", "submit", "confirm", "continue", "click here", "go"]);
|
|
65
|
+
|
|
66
|
+
/** The verbs that destroy something, and so get the danger color and the second thought. */
|
|
67
|
+
const DESTRUCTIVE_LABELS = /\b(delete|remove|erase|discard|unsubscribe|cancel account|wipe)\b/i;
|
|
68
|
+
const CANCEL_LABELS = /^(cancel|back|keep|no,? thanks|not now|go back)$/i;
|
|
69
|
+
|
|
70
|
+
/** Keys whose value is children/data, not a value a scale can judge. */
|
|
71
|
+
const SKIP_VALUE_KEYS = new Set([
|
|
72
|
+
"children",
|
|
73
|
+
"child",
|
|
74
|
+
"content",
|
|
75
|
+
"items",
|
|
76
|
+
"actions",
|
|
77
|
+
"columns",
|
|
78
|
+
"rows",
|
|
79
|
+
"data",
|
|
80
|
+
"builder",
|
|
81
|
+
"widgetName",
|
|
82
|
+
"style",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
export const ERROR_CODES = new Set([
|
|
86
|
+
"RAW_COLOR",
|
|
87
|
+
"OFF_SCALE_SPACING",
|
|
88
|
+
"OFF_SCALE_RADIUS",
|
|
89
|
+
"OFF_SCALE_TYPE",
|
|
90
|
+
"SMALL_TAP_TARGET",
|
|
91
|
+
"LOW_CONTRAST",
|
|
92
|
+
"UNLABELED_FIELD",
|
|
93
|
+
"UNLABELED_TOGGLE",
|
|
94
|
+
"DESTRUCTIVE_COLOR",
|
|
95
|
+
"UNREADABLE",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
export const WARN_CODES = new Set([
|
|
99
|
+
"LOW_CONTRAST_SOFT",
|
|
100
|
+
"LOW_CONTRAST_UI",
|
|
101
|
+
"LOW_CONTRAST_ON_SURFACE",
|
|
102
|
+
"HAND_MIXED_COLOR",
|
|
103
|
+
"DEAD_ELEVATION",
|
|
104
|
+
"IMAGE_NO_ALT",
|
|
105
|
+
"ICON_ONLY_CONTROL",
|
|
106
|
+
"CENTERED_BODY_TEXT",
|
|
107
|
+
"GENERIC_ACTION_LABEL",
|
|
108
|
+
"CONFIRM_ORDER",
|
|
109
|
+
"UNCONSTRAINED_FORM_WIDTH",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const INFO_CODES = new Set([
|
|
113
|
+
"SMALL_PRESET",
|
|
114
|
+
"CROWDED_FORM",
|
|
115
|
+
"LIST_WITHOUT_STATE",
|
|
116
|
+
"MULTIPLE_PRIMARY_ACTIONS",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The legal home for a hex value is the app's own token file. Anything else in
|
|
121
|
+
* `src/` that types a color by hand is the drift this rule exists to catch.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} file
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
export const isTokenFile = (file) =>
|
|
127
|
+
/^(themes?|colors|palette)\.m?js$/.test(path.basename(file));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The light palette an app declares in its own token file. That is what the
|
|
131
|
+
* screen actually paints with, so it is what a contrast rule must measure — the
|
|
132
|
+
* package palette is only the default the app did not override. An app may name
|
|
133
|
+
* its themes (`lightColors`, `darkColors`) instead of shadowing `colors`, so the
|
|
134
|
+
* literal is chosen by name: the light theme is the one a bare `colors.text`
|
|
135
|
+
* resolves to on a light page.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} file a token file
|
|
138
|
+
* @returns {Record<string, string>} the hex literals it declares
|
|
139
|
+
*/
|
|
140
|
+
export const appPalette = (file) => {
|
|
141
|
+
const src = fs.readFileSync(file, "utf8");
|
|
142
|
+
const literals = [];
|
|
143
|
+
for (const start of src.matchAll(
|
|
144
|
+
/(?:export\s+)?(?:let|const|var)\s+([A-Za-z0-9_]+)\s*=\s*\{/g,
|
|
145
|
+
)) {
|
|
146
|
+
const from = start.index + start[0].length - 1;
|
|
147
|
+
let depth = 0;
|
|
148
|
+
for (let i = from; i < src.length; i++) {
|
|
149
|
+
if (src[i] === "{") depth++;
|
|
150
|
+
else if (src[i] === "}") {
|
|
151
|
+
depth--;
|
|
152
|
+
if (depth === 0) {
|
|
153
|
+
literals.push({ name: start[1], body: src.slice(from, i + 1) });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// The palette a bare `colors.text` resolves to is the one a contrast rule must
|
|
160
|
+
// measure. Apps name their themes (`lightColors`, `darkColors`) rather than
|
|
161
|
+
// shadowing `colors`, so the choice is by name, not by declaration order.
|
|
162
|
+
const rank = (name) =>
|
|
163
|
+
/^colors$/i.test(name)
|
|
164
|
+
? 0
|
|
165
|
+
: /light|day|default/i.test(name)
|
|
166
|
+
? 1
|
|
167
|
+
: /dark|night/i.test(name)
|
|
168
|
+
? 3
|
|
169
|
+
: 2;
|
|
170
|
+
const chosen = literals
|
|
171
|
+
.filter((entry) => entry.body.includes("#"))
|
|
172
|
+
.sort((a, b) => rank(a.name) - rank(b.name))[0];
|
|
173
|
+
if (!chosen) return {};
|
|
174
|
+
const found = {};
|
|
175
|
+
for (const match of chosen.body.matchAll(
|
|
176
|
+
/([A-Za-z0-9_]+)\s*:\s*(['"])#([0-9a-fA-F]{3,8})\2/g,
|
|
177
|
+
)) {
|
|
178
|
+
found[match[1]] = `#${match[3]}`;
|
|
179
|
+
}
|
|
180
|
+
return found;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const isQuote = (ch) => ch === '"' || ch === "'" || ch === "`";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Index just past a string or template literal that starts at `start`.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} src
|
|
189
|
+
* @param {number} start index of the opening quote
|
|
190
|
+
* @returns {number}
|
|
191
|
+
*/
|
|
192
|
+
const skipString = (src, start) => {
|
|
193
|
+
const quote = src[start];
|
|
194
|
+
let i = start + 1;
|
|
195
|
+
while (i < src.length) {
|
|
196
|
+
if (src[i] === "\\") i += 2;
|
|
197
|
+
else if (src[i] === quote) return i + 1;
|
|
198
|
+
else i++;
|
|
199
|
+
}
|
|
200
|
+
return i;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Blank out comments while keeping every character offset, so a `// padding: 13`
|
|
205
|
+
* note never becomes a finding and line numbers still match the real file.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} src
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
export const blankComments = (src) => {
|
|
211
|
+
const out = src.split("");
|
|
212
|
+
for (let i = 0; i < src.length; i++) {
|
|
213
|
+
const ch = src[i];
|
|
214
|
+
if (ch === "/" && src[i + 1] === "/") {
|
|
215
|
+
let j = i;
|
|
216
|
+
while (j < src.length && src[j] !== "\n") out[j++] = " ";
|
|
217
|
+
i = j - 1;
|
|
218
|
+
} else if (ch === "/" && src[i + 1] === "*") {
|
|
219
|
+
let j = i;
|
|
220
|
+
while (j < src.length && !(src[j] === "*" && src[j + 1] === "/")) {
|
|
221
|
+
if (src[j] !== "\n") out[j] = " ";
|
|
222
|
+
j++;
|
|
223
|
+
}
|
|
224
|
+
if (j < src.length) out[j] = " ";
|
|
225
|
+
if (j + 1 < src.length) out[j + 1] = " ";
|
|
226
|
+
i = j + 1;
|
|
227
|
+
} else if (isQuote(ch)) {
|
|
228
|
+
i = skipString(src, i) - 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out.join("");
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The byte ranges that are string or template text. A widget call written inside
|
|
236
|
+
* one is documentation, not a call the screen makes: every example file ships the
|
|
237
|
+
* same call twice on purpose — once built, once as the `code:` line the reader
|
|
238
|
+
* copies — and reporting both prints one finding per pixel and half the list.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} code comment-free source
|
|
241
|
+
* @returns {Array<[number, number]>}
|
|
242
|
+
*/
|
|
243
|
+
const stringSpans = (code) => {
|
|
244
|
+
const spans = [];
|
|
245
|
+
for (let i = 0; i < code.length; i++) {
|
|
246
|
+
if (!isQuote(code[i])) continue;
|
|
247
|
+
const end = skipString(code, i);
|
|
248
|
+
spans.push([i, end]);
|
|
249
|
+
i = end - 1;
|
|
250
|
+
}
|
|
251
|
+
return spans;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Find every `Widget({ … })` call, nested ones included.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} code comment-free source
|
|
258
|
+
* @returns {Array<{name: string, open: number, close: number}>}
|
|
259
|
+
*/
|
|
260
|
+
export const findCalls = (code) => {
|
|
261
|
+
const calls = [];
|
|
262
|
+
const spans = stringSpans(code);
|
|
263
|
+
const inText = (index) => spans.some(([start, end]) => index > start && index < end);
|
|
264
|
+
const ident = /[A-Za-z_$][\w$]*[ \t]*\([ \t]*\{/g;
|
|
265
|
+
let match;
|
|
266
|
+
while ((match = ident.exec(code))) {
|
|
267
|
+
if (inText(match.index)) continue;
|
|
268
|
+
const name = match[0].slice(0, match[0].indexOf("(")).trim();
|
|
269
|
+
if (!/^[A-Z]/.test(name)) continue;
|
|
270
|
+
const open = match.index + match[0].length - 1;
|
|
271
|
+
let depth = 0;
|
|
272
|
+
let close = -1;
|
|
273
|
+
for (let i = open; i < code.length; i++) {
|
|
274
|
+
const ch = code[i];
|
|
275
|
+
if (isQuote(ch)) {
|
|
276
|
+
i = skipString(code, i) - 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (ch === "{") depth++;
|
|
280
|
+
else if (ch === "}") {
|
|
281
|
+
depth--;
|
|
282
|
+
if (depth === 0) {
|
|
283
|
+
close = i;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (close === -1) continue;
|
|
289
|
+
calls.push({ name, open, close });
|
|
290
|
+
ident.lastIndex = open + 1;
|
|
291
|
+
}
|
|
292
|
+
return calls;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Top-level `key: value` pairs of an object literal, with absolute offsets.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} code comment-free source
|
|
299
|
+
* @param {number} open index of the opening brace
|
|
300
|
+
* @param {number} close index of the matching brace
|
|
301
|
+
* @returns {Array<{key: string, value: string, valueStart: number}>}
|
|
302
|
+
*/
|
|
303
|
+
export const entriesOf = (code, open, close) => {
|
|
304
|
+
const pieces = [];
|
|
305
|
+
let depth = 0;
|
|
306
|
+
let start = open + 1;
|
|
307
|
+
for (let i = open; i <= close; i++) {
|
|
308
|
+
const ch = code[i];
|
|
309
|
+
if (isQuote(ch)) {
|
|
310
|
+
i = skipString(code, i) - 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (ch === "{" || ch === "[" || ch === "(") {
|
|
314
|
+
depth++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ch === "}" || ch === "]" || ch === ")") {
|
|
318
|
+
depth--;
|
|
319
|
+
if (depth === 0) {
|
|
320
|
+
if (i === close) pieces.push([start, i]);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (depth === 1 && ch === ",") {
|
|
326
|
+
pieces.push([start, i]);
|
|
327
|
+
start = i + 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const entries = [];
|
|
332
|
+
for (const [from, to] of pieces) {
|
|
333
|
+
const piece = code.slice(from, to);
|
|
334
|
+
// Keys are almost always on their own line in a real screen, so the leading
|
|
335
|
+
// and trailing whitespace here has to cross newlines.
|
|
336
|
+
const keyMatch =
|
|
337
|
+
/^[ \t\r\n]*(?:\.\.\.)?[ \t\r\n]*((?:[A-Za-z_$][\w$]*)|(?:['"][^'"]*['"]))[ \t\r\n]*:/.exec(
|
|
338
|
+
piece,
|
|
339
|
+
);
|
|
340
|
+
if (!keyMatch) continue;
|
|
341
|
+
const valueStart = from + keyMatch[0].length;
|
|
342
|
+
entries.push({
|
|
343
|
+
key: keyMatch[1].replace(/^['"]|['"]$/g, ""),
|
|
344
|
+
value: code.slice(valueStart, to),
|
|
345
|
+
valueStart,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return entries;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Numeric literals written in a value, skipping the ones inside strings.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} value
|
|
355
|
+
* @returns {Array<{number: number, offset: number}>}
|
|
356
|
+
*/
|
|
357
|
+
export const numbersIn = (value) => {
|
|
358
|
+
const found = [];
|
|
359
|
+
const code = blankComments(value);
|
|
360
|
+
for (let i = 0; i < code.length; i++) {
|
|
361
|
+
const ch = code[i];
|
|
362
|
+
if (isQuote(ch)) {
|
|
363
|
+
i = skipString(code, i) - 1;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (!/[-0-9.]/.test(ch)) continue;
|
|
367
|
+
const m = /^-?\d+(?:\.\d+)?/.exec(code.slice(i));
|
|
368
|
+
if (!m) continue;
|
|
369
|
+
const before = i > 0 ? code[i - 1] : "";
|
|
370
|
+
if (/[\w$.]/.test(before)) continue;
|
|
371
|
+
found.push({ number: Number(m[0]), offset: i });
|
|
372
|
+
i += m[0].length - 1;
|
|
373
|
+
}
|
|
374
|
+
return found;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `#rgb`/`#rrggbb`/`#rrggbbaa` or a functional color typed as a literal.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} value raw prop value
|
|
381
|
+
* @returns {string | null} the offending text, or null when it is a token
|
|
382
|
+
*/
|
|
383
|
+
export const colorLiteralIn = (value) => {
|
|
384
|
+
const hex = /(['"])#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\1/.exec(
|
|
385
|
+
value,
|
|
386
|
+
);
|
|
387
|
+
if (hex) return hex[0];
|
|
388
|
+
const fn = /\b(?:rgb|rgba|hsl|hsla)\s*\(/.exec(blankComments(value));
|
|
389
|
+
return fn ? `${fn[0].replace(/\s+$/, "")}…` : null;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve a value that names a color into something `color()` can read.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} value raw prop value
|
|
396
|
+
* @param {Record<string, string>} tokens the package `colors` object
|
|
397
|
+
* @returns {string | null} a hex string, or null when the value is not provable
|
|
398
|
+
*/
|
|
399
|
+
export const resolveColor = (value, tokens) => {
|
|
400
|
+
const text = value.trim();
|
|
401
|
+
const token = /^colors\.([A-Za-z0-9_]+)$/.exec(text);
|
|
402
|
+
if (token) return tokens[token[1]] ?? null;
|
|
403
|
+
const literal = /^(['"])#([0-9a-fA-F]{3,8})\1$/.exec(text);
|
|
404
|
+
if (literal) return `#${literal[2]}`;
|
|
405
|
+
return null;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const lineOffsets = (src) => {
|
|
409
|
+
const offsets = [0];
|
|
410
|
+
for (let i = 0; i < src.length; i++) if (src[i] === "\n") offsets.push(i + 1);
|
|
411
|
+
return offsets;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const lineOf = (offsets, index) => {
|
|
415
|
+
let lo = 0;
|
|
416
|
+
let hi = offsets.length - 1;
|
|
417
|
+
while (lo < hi) {
|
|
418
|
+
const mid = (lo + hi + 1) >> 1;
|
|
419
|
+
if (offsets[mid] <= index) lo = mid;
|
|
420
|
+
else hi = mid - 1;
|
|
421
|
+
}
|
|
422
|
+
return lo + 1;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Is this text large enough that WCAG relaxes 4.5:1 to 3:1? 24px, or 18px and
|
|
427
|
+
* bold (the standard's 18pt / 14pt-bold line, in the pixels this scale uses).
|
|
428
|
+
*
|
|
429
|
+
* @param {Map<string, {value: string}>} props
|
|
430
|
+
* @returns {boolean}
|
|
431
|
+
*/
|
|
432
|
+
const isLargeText = (props) => {
|
|
433
|
+
let size = null;
|
|
434
|
+
for (const key of ["fontSize", "size"]) {
|
|
435
|
+
const numbers = numbersIn(props.get(key)?.value ?? "");
|
|
436
|
+
if (numbers.length === 1) size = numbers[0].number;
|
|
437
|
+
}
|
|
438
|
+
if (size === null) return false;
|
|
439
|
+
if (size >= LARGE_TEXT_SIZE) return true;
|
|
440
|
+
return size >= 18 && /bold|['"]?(600|700)['"]?/.test(props.get("weight")?.value ?? "");
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/** The props that paint the plate a label sits on, in the order they win. */
|
|
444
|
+
const FILL_KEYS = ["bgColor", "backgroundColor", "gradient"];
|
|
445
|
+
|
|
446
|
+
/** The first colour a gradient string names — the stop the words sit on. */
|
|
447
|
+
const gradientStop = (value, tokens) => {
|
|
448
|
+
const token = /colors\.([A-Za-z0-9_]+)/.exec(value);
|
|
449
|
+
if (token) return tokens[token[1]] ?? null;
|
|
450
|
+
const hex = /#([0-9a-fA-F]{3,8})/.exec(value);
|
|
451
|
+
return hex ? `#${hex[1]}` : null;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const fillOf = (props, tokens) => {
|
|
455
|
+
for (const key of FILL_KEYS) {
|
|
456
|
+
const entry = props.get(key);
|
|
457
|
+
if (!entry) continue;
|
|
458
|
+
const resolved =
|
|
459
|
+
key === "gradient"
|
|
460
|
+
? gradientStop(entry.value, tokens)
|
|
461
|
+
: resolveColor(entry.value, tokens);
|
|
462
|
+
if (resolved) return resolved;
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Does this call say it paints a plate, whether or not this pass can name the
|
|
469
|
+
* colour? A `backgroundColor` fed by a parameter is a fill the reader sees and
|
|
470
|
+
* the checker cannot resolve, and measuring its text against the page would
|
|
471
|
+
* report a surface that is never painted.
|
|
472
|
+
*/
|
|
473
|
+
const declaresFill = (props) => FILL_KEYS.some((key) => props.has(key));
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Widgets whose `color` is an accent, not a sentence: the implementation reads it
|
|
477
|
+
* into a background, a track, a tint or an indicator, so the number that matters
|
|
478
|
+
* is what the words written *on* it measure, not what the colour measures on the
|
|
479
|
+
* page. Measuring it as ink reported `Chip({ label: "info", color: colors.info })`
|
|
480
|
+
* as 3.52:1 text no reader ever sees — the chip's label is `colors.onPrimary`.
|
|
481
|
+
*
|
|
482
|
+
* Each entry cites the line that turns the prop into a fill, and says which token
|
|
483
|
+
* writes on it (`null` = nothing does). The literal is what `resolveColor` gets,
|
|
484
|
+
* so the finding still names a real hex.
|
|
485
|
+
*/
|
|
486
|
+
const ACCENTS = {
|
|
487
|
+
// Slider.js:171 `backgroundColor: color` — the track and thumb, no words on it.
|
|
488
|
+
Slider: { when: () => true, ink: () => null },
|
|
489
|
+
// ProgressBar.js:39 `backgroundColor: color` — the bar; its label is
|
|
490
|
+
// `colors.textSecondary` painted outside it (:99).
|
|
491
|
+
ProgressBar: { when: () => true, ink: () => null },
|
|
492
|
+
// Tabs.js:111/231 the indicator and the `${color}1A` pill tint; the active label
|
|
493
|
+
// is `colors.text` (:107) unless the slider variant puts it on the thumb itself.
|
|
494
|
+
Tabs: { when: () => true, ink: (props) => (variantOf(props) === "slider" ? "onPrimary" : null) },
|
|
495
|
+
// Pagination.js:88-89 the active page is `bgColor: color` with `colors.onPrimary`.
|
|
496
|
+
Pagination: { when: () => true, ink: () => "onPrimary" },
|
|
497
|
+
// Chip.js:70-78 filled paints the plate and writes `colors.onPrimary` on it;
|
|
498
|
+
// outlined paints nothing and uses `color` *as the label*, so that one stays an
|
|
499
|
+
// ordinary ink measurement and its 3.52:1 blue on a light page is reported.
|
|
500
|
+
Chip: { when: (props) => variantOf(props) !== "outlined", ink: () => "onPrimary" },
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const variantOf = (props) => (props.get("variant")?.value ?? "").replace(/['"]/g, "").trim();
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Contrast between a text color and the fill it is written on, measured with the
|
|
507
|
+
* package's own `color()` — WCAG 2.1 relative luminance, not an opinion.
|
|
508
|
+
*
|
|
509
|
+
* The fill is the call's own `bgColor`/`backgroundColor`/`gradient`, else the
|
|
510
|
+
* nearest enclosing call that paints one (so a label inside a brand header is
|
|
511
|
+
* judged on the brand). When nothing paints a plate, the two surfaces a screen
|
|
512
|
+
* uses are both tested, because the real background is a layout fact this pass
|
|
513
|
+
* cannot see.
|
|
514
|
+
*
|
|
515
|
+
* Every number is measured once per theme the package ships. `colors` is the
|
|
516
|
+
* object the module boots with, and a token can clear AA in one theme and fail
|
|
517
|
+
* in the other — `info` is 3.52:1 on the light surface and 6.97:1 on the dark
|
|
518
|
+
* one — so the worst theme is what gets reported, and a token only the boot
|
|
519
|
+
* palette dislikes stops generating findings nobody can reproduce.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} widget
|
|
522
|
+
* @param {Map<string, {value: string}>} props
|
|
523
|
+
* @param {Map<string, {value: string}> | null} scopeProps nearest enclosing call that declares a fill
|
|
524
|
+
* @param {object} ctx
|
|
525
|
+
* @param {number} line
|
|
526
|
+
* @returns {object | null}
|
|
527
|
+
*/
|
|
528
|
+
const checkContrast = (widget, props, scopeProps, ctx, line) => {
|
|
529
|
+
if (!ctx.contrast) return null;
|
|
530
|
+
let fgEntry = props.get("color") || props.get("textColor");
|
|
531
|
+
if (!fgEntry) return null;
|
|
532
|
+
if ((props.get("disabled")?.value ?? "").trim() === "true") return null;
|
|
533
|
+
|
|
534
|
+
// An accent call is measured the other way round: the plate is `color` and the
|
|
535
|
+
// words are the theme's own ink, so `colors.onPrimary` on a pale brand fill is
|
|
536
|
+
// the finding worth printing and the colour on the page is not a sentence.
|
|
537
|
+
let accent = null;
|
|
538
|
+
const accentRule = ACCENTS[widget];
|
|
539
|
+
const accentEntry =
|
|
540
|
+
accentRule && accentRule.when(props) && !declaresFill(props) ? props.get("color") : null;
|
|
541
|
+
if (accentEntry) {
|
|
542
|
+
const inkToken = accentRule.ink(props);
|
|
543
|
+
if (!inkToken) return null;
|
|
544
|
+
accent = accentEntry;
|
|
545
|
+
fgEntry = { value: `colors.${inkToken}` };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const graphic = ICON_WIDGETS.includes(widget);
|
|
549
|
+
const minimum = graphic
|
|
550
|
+
? CONTRAST_LARGE
|
|
551
|
+
: isLargeText(props)
|
|
552
|
+
? CONTRAST_LARGE
|
|
553
|
+
: CONTRAST_BODY;
|
|
554
|
+
|
|
555
|
+
const fillProps = declaresFill(props) ? props : scopeProps;
|
|
556
|
+
const themes = ctx.themes ?? [{ name: "default", tokens: ctx.tokens }];
|
|
557
|
+
|
|
558
|
+
/** The theme whose answer is hardest to read, plus the numbers to print. */
|
|
559
|
+
let worst = null;
|
|
560
|
+
for (const theme of themes) {
|
|
561
|
+
const fg = resolveColor(fgEntry.value, theme.tokens);
|
|
562
|
+
if (!fg) continue;
|
|
563
|
+
if (accent) {
|
|
564
|
+
const bg = resolveColor(accent.value, theme.tokens);
|
|
565
|
+
if (!bg) continue;
|
|
566
|
+
const ratio = ctx.contrast(fg, bg);
|
|
567
|
+
if (!worst || ratio < worst.ratio) worst = { ratio, fg, bg, theme: theme.name };
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (fillProps) {
|
|
571
|
+
const bg = fillOf(fillProps, theme.tokens);
|
|
572
|
+
if (!bg) continue;
|
|
573
|
+
const ratio = ctx.contrast(fg, bg);
|
|
574
|
+
if (!worst || ratio < worst.ratio) worst = { ratio, fg, bg, theme: theme.name };
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const onBackground = ctx.contrast(fg, theme.tokens.background);
|
|
578
|
+
const onSurface = ctx.contrast(fg, theme.tokens.surface);
|
|
579
|
+
const ratio = Math.min(onBackground, onSurface);
|
|
580
|
+
if (!worst || ratio < worst.ratio) {
|
|
581
|
+
worst = { ratio, fg, onBackground, onSurface, theme: theme.name };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// A fill is declared but no theme can name it (a parameter, a runtime
|
|
585
|
+
// gradient): those words are on that plate, not on the page.
|
|
586
|
+
if (!worst) return null;
|
|
587
|
+
|
|
588
|
+
const fg = worst.fg;
|
|
589
|
+
const bg = worst.bg ?? null;
|
|
590
|
+
const inTheme = worst.theme === "default" ? "" : ` in the ${worst.theme} theme`;
|
|
591
|
+
|
|
592
|
+
// The plate came from the accent prop, so the advice is about the plate: the
|
|
593
|
+
// widget picks the ink itself and the app never wrote it.
|
|
594
|
+
if (accent) {
|
|
595
|
+
const ratio = worst.ratio;
|
|
596
|
+
if (ratio + 1e-9 >= minimum) return null;
|
|
597
|
+
return finding(
|
|
598
|
+
ctx,
|
|
599
|
+
line,
|
|
600
|
+
ratio < CONTRAST_LARGE ? "LOW_CONTRAST" : "LOW_CONTRAST_SOFT",
|
|
601
|
+
`${widget} writes ${fg} on its \`color\` plate, ${bg}, and it measures ${ratio.toFixed(2)}:1${inTheme} (needs ${minimum}:1).`,
|
|
602
|
+
`The label is not yours to recolor — \`${widget}\` paints the theme's own ink on the accent you passed. Pick an accent that carries it: \`colors.primary\` does, and a signal tone like \`colors.info\` (#3b82f6, 3.68:1) is too light. \`flet-box check-ui --tokens\` prints the number for each.`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (bg) {
|
|
607
|
+
const ratio = worst.ratio;
|
|
608
|
+
if (ratio + 1e-9 >= minimum) return null;
|
|
609
|
+
if (ratio < CONTRAST_LARGE) {
|
|
610
|
+
return finding(
|
|
611
|
+
ctx,
|
|
612
|
+
line,
|
|
613
|
+
graphic ? "LOW_CONTRAST_UI" : "LOW_CONTRAST",
|
|
614
|
+
`${widget} ${fg} on ${bg} measures ${ratio.toFixed(2)}:1${inTheme} — ${graphic ? "a meaningful graphic needs" : "text needs"} ${minimum}:1.`,
|
|
615
|
+
graphic
|
|
616
|
+
? "An icon that carries information is UI, not decoration: 3:1. Darken it (`colors.gray700`) or put it on a fill that carries it."
|
|
617
|
+
: "Below 3:1 no size makes it readable. Use `colors.text` on this fill, or move the text to `background`/`surface`.",
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return finding(
|
|
621
|
+
ctx,
|
|
622
|
+
line,
|
|
623
|
+
"LOW_CONTRAST_SOFT",
|
|
624
|
+
`${widget} text ${fg} on ${bg} measures ${ratio.toFixed(2)}:1${inTheme} (AA body text is ${minimum}:1).`,
|
|
625
|
+
"Legal only as large text: 24px, or 18px bold. Small body text on this fill needs a darker token.",
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// No fill this pass can name: warn against the surfaces of the worst theme.
|
|
630
|
+
const { onBackground, onSurface } = worst;
|
|
631
|
+
if (onBackground >= minimum && onSurface >= minimum) return null;
|
|
632
|
+
return finding(
|
|
633
|
+
ctx,
|
|
634
|
+
line,
|
|
635
|
+
graphic ? "LOW_CONTRAST_UI" : "LOW_CONTRAST_ON_SURFACE",
|
|
636
|
+
`${widget} ${fg} measures ${onBackground.toFixed(2)}:1 on \`background\` and ${onSurface.toFixed(2)}:1 on \`surface\`${inTheme} (needs ${minimum}:1).`,
|
|
637
|
+
graphic
|
|
638
|
+
? "A graphic that carries meaning needs 3:1 against the page. If it is decoration next to a label, ignore this one."
|
|
639
|
+
: `Body text needs ${minimum}:1. If this sits on a brand or dark fill, give the call that \`bgColor\` — or the fill is inherited and this is the number to beat.`,
|
|
640
|
+
);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
const finding = (ctx, line, code, message, detail) => ({
|
|
645
|
+
file: ctx.currentFile,
|
|
646
|
+
line,
|
|
647
|
+
code,
|
|
648
|
+
message,
|
|
649
|
+
detail,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* @param {string} file
|
|
654
|
+
* @param {object} ctx resolved package data
|
|
655
|
+
* @returns {object[]} findings
|
|
656
|
+
*/
|
|
657
|
+
const checkFile = (file, ctx) => {
|
|
658
|
+
const findings = [];
|
|
659
|
+
const src = fs.readFileSync(file, "utf8");
|
|
660
|
+
const code = blankComments(src);
|
|
661
|
+
const offsets = lineOffsets(src);
|
|
662
|
+
const tokenFile = isTokenFile(file);
|
|
663
|
+
const push = (line, codeName, message, detail) =>
|
|
664
|
+
findings.push(finding(ctx, line, codeName, message, detail));
|
|
665
|
+
|
|
666
|
+
let fieldCount = 0;
|
|
667
|
+
let destructiveButtons = 0;
|
|
668
|
+
let sawList = false;
|
|
669
|
+
let sawState = false;
|
|
670
|
+
|
|
671
|
+
const calls = findCalls(code).map((call) => {
|
|
672
|
+
const props = new Map();
|
|
673
|
+
for (const entry of entriesOf(code, call.open, call.close)) {
|
|
674
|
+
if (!props.has(entry.key)) props.set(entry.key, entry);
|
|
675
|
+
}
|
|
676
|
+
return { ...call, props };
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The nearest enclosing call that says it paints a plate. Its props are handed
|
|
681
|
+
* to the contrast rule, which resolves the colour per theme — the call itself
|
|
682
|
+
* is what matters, since a fill this pass cannot name still means the text is
|
|
683
|
+
* not sitting on the page.
|
|
684
|
+
*/
|
|
685
|
+
const inheritedFillProps = (call) => {
|
|
686
|
+
for (const other of enclosing(call)) {
|
|
687
|
+
if (declaresFill(other.props)) return other.props;
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
/** Every call this one sits inside, nearest first. */
|
|
693
|
+
const enclosing = (call) =>
|
|
694
|
+
calls
|
|
695
|
+
.filter(
|
|
696
|
+
(other) => other !== call && other.open < call.open && other.close > call.close,
|
|
697
|
+
)
|
|
698
|
+
.sort((a, b) => b.open - a.open);
|
|
699
|
+
|
|
700
|
+
const parentOf = (call) => enclosing(call)[0] ?? null;
|
|
701
|
+
|
|
702
|
+
const childrenOf = (parent) =>
|
|
703
|
+
calls.filter((other) => other !== parent && parentOf(other) === parent);
|
|
704
|
+
|
|
705
|
+
const descendantsOf = (parent) =>
|
|
706
|
+
calls.filter(
|
|
707
|
+
(other) => other !== parent && other.open > parent.open && other.close < parent.close,
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The words a call displays, when they are a literal in the source. A computed
|
|
712
|
+
* label is not something a copy rule may judge, so it answers null.
|
|
713
|
+
*/
|
|
714
|
+
const literalText = (call) => {
|
|
715
|
+
for (const key of TEXT_KEYS) {
|
|
716
|
+
const entry = call.props.get(key);
|
|
717
|
+
if (!entry) continue;
|
|
718
|
+
const match = /^['"`]([\s\S]*)['"`]$/.exec(entry.value.trim());
|
|
719
|
+
return match ? match[1] : null;
|
|
720
|
+
}
|
|
721
|
+
return null;
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
/** A `Text` sibling with words in it: the label of a toggle or of an icon button. */
|
|
725
|
+
const labeledBySibling = (call) => {
|
|
726
|
+
const parent = parentOf(call);
|
|
727
|
+
if (!parent) return false;
|
|
728
|
+
return childrenOf(parent).some(
|
|
729
|
+
(other) => other !== call && other.name === "Text" && (literalText(other) ?? "") !== "",
|
|
730
|
+
);
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
const namedByTooltip = (call) =>
|
|
734
|
+
enclosing(call).some((other) => other.name === "Tooltip" && other.props.has("text"));
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* A glyph control that carries its own accessible name is named, even when this
|
|
738
|
+
* pass cannot see the wrapper: an app that builds the control inside a helper
|
|
739
|
+
* and wraps the *result* in a Tooltip has a `child: iconButton("share")` at the
|
|
740
|
+
* call site, and a lexical scan stops at the helper's own braces. `aria-*` is a
|
|
741
|
+
* routed passthrough (translateProps.js:948), so the name really does reach the
|
|
742
|
+
* DOM — which is the sentence the finding asks for.
|
|
743
|
+
*/
|
|
744
|
+
const namedByAria = (call) =>
|
|
745
|
+
[...call.props.keys()].some((key) => /^aria-(label|labelledby)$/i.test(key));
|
|
746
|
+
|
|
747
|
+
const isFilled = (call) =>
|
|
748
|
+
call.name === "Button" &&
|
|
749
|
+
!/['"`](outlined|text)['"`]/.test(call.props.get("variant")?.value ?? "");
|
|
750
|
+
|
|
751
|
+
/** A width that caps the block: a number or a percentage below full bleed. */
|
|
752
|
+
const capsWidth = (call) => {
|
|
753
|
+
const max = call.props.get("maxWidth");
|
|
754
|
+
if (max) return true;
|
|
755
|
+
const width = call.props.get("width");
|
|
756
|
+
if (!width) return false;
|
|
757
|
+
return !/['"]?100%|['"]?\d{4,}|stretch/.test(width.value);
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
for (const call of calls) {
|
|
761
|
+
const props = call.props;
|
|
762
|
+
const line = lineOf(offsets, call.open);
|
|
763
|
+
const tappable = PRESS_KEYS.some((key) => props.has(key));
|
|
764
|
+
|
|
765
|
+
if (STATE_WIDGETS.includes(call.name)) sawState = true;
|
|
766
|
+
if (LIST_WIDGETS.includes(call.name)) sawList = true;
|
|
767
|
+
if (FIELD_WIDGETS.includes(call.name)) fieldCount++;
|
|
768
|
+
|
|
769
|
+
for (const [key, entry] of props) {
|
|
770
|
+
if (SKIP_VALUE_KEYS.has(key)) continue;
|
|
771
|
+
const kind = ctx.propKinds.get(key);
|
|
772
|
+
const valueLine = lineOf(offsets, entry.valueStart);
|
|
773
|
+
const numbers = numbersIn(entry.value);
|
|
774
|
+
|
|
775
|
+
if (kind === "color" || COLOR_FALLBACK_KEYS.has(key)) {
|
|
776
|
+
const literal = colorLiteralIn(entry.value);
|
|
777
|
+
if (literal && !tokenFile) {
|
|
778
|
+
push(
|
|
779
|
+
valueLine,
|
|
780
|
+
"RAW_COLOR",
|
|
781
|
+
`${call.name} sets \`${key}\` to the literal ${literal}.`,
|
|
782
|
+
"Color comes from a token (`colors.text`, `colors.primary`). A typed hex is the first sign a screen drifted from the rest; if a token is missing, add it to src/database/themes.js.",
|
|
783
|
+
);
|
|
784
|
+
} else if (/colors\.[A-Za-z0-9_]+\s*\+\s*['"][0-9a-fA-F]{2}['"]/.test(entry.value)) {
|
|
785
|
+
push(
|
|
786
|
+
valueLine,
|
|
787
|
+
"HAND_MIXED_COLOR",
|
|
788
|
+
`${call.name} builds a color by appending alpha to a token (${entry.value.trim().slice(0, 40)}).`,
|
|
789
|
+
"That is a one-off shade the rest of the app cannot reuse. `colors.primaryBg`, `colors.errorBg` and friends are the tints; add a token if the one you need is missing.",
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (kind === "spacing") {
|
|
795
|
+
for (const n of numbers) {
|
|
796
|
+
if (SPACING_SCALE.has(Math.abs(n.number))) continue;
|
|
797
|
+
push(
|
|
798
|
+
valueLine,
|
|
799
|
+
"OFF_SCALE_SPACING",
|
|
800
|
+
`${call.name}.${key} = ${n.number} is not on the spacing scale.`,
|
|
801
|
+
"Use 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96. The scale is what makes two screens look like one app.",
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
// A CSS shorthand still has to obey the scale: `padding: '12px 20px'` is
|
|
805
|
+
// the same mistake with a unit glued on.
|
|
806
|
+
if (!numbers.length) {
|
|
807
|
+
for (const match of entry.value.matchAll(/\d+(?:\.\d+)?px/g)) {
|
|
808
|
+
const value = Number(match[0].slice(0, -2));
|
|
809
|
+
if (SPACING_SCALE.has(value)) continue;
|
|
810
|
+
push(
|
|
811
|
+
valueLine,
|
|
812
|
+
"OFF_SCALE_SPACING",
|
|
813
|
+
`${call.name}.${key} = '${match[0]}' is not on the spacing scale.`,
|
|
814
|
+
"Use 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96, or the plain number the factory prefers.",
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
} else if (kind === "radius") {
|
|
819
|
+
for (const n of numbers) {
|
|
820
|
+
if (RADIUS_SCALE.has(Math.abs(n.number))) continue;
|
|
821
|
+
push(
|
|
822
|
+
valueLine,
|
|
823
|
+
"OFF_SCALE_RADIUS",
|
|
824
|
+
`${call.name}.${key} = ${n.number} is not a scale radius.`,
|
|
825
|
+
"Use 4 · 8 · 12 · 16 · 999 (a pill). A radius between them is a shape no other card in this app has.",
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
} else if (kind === "type" && (key === "fontSize" || call.name === "Text")) {
|
|
829
|
+
for (const n of numbers) {
|
|
830
|
+
if (TYPE_SCALE.has(Math.abs(n.number))) continue;
|
|
831
|
+
push(
|
|
832
|
+
valueLine,
|
|
833
|
+
"OFF_SCALE_TYPE",
|
|
834
|
+
`${call.name}.${key} = ${n.number} is not a scale type size.`,
|
|
835
|
+
"12 caption · 14 body-small · 16 body · 18 subtitle · 24 title · 32 headline · 40/48 display. One size per element, one weight per role.",
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (tappable && (key === "height" || key === "minHeight")) {
|
|
841
|
+
for (const n of numbers) {
|
|
842
|
+
if (n.number <= 0 || n.number >= TAP_MINIMUM) continue;
|
|
843
|
+
push(
|
|
844
|
+
valueLine,
|
|
845
|
+
"SMALL_TAP_TARGET",
|
|
846
|
+
`${call.name} is pressable but only ${n.number}px tall.`,
|
|
847
|
+
`Fitts: a finger needs ${TAP_MINIMUM}x${TAP_MINIMUM}. Get there with \`height\`, not with padding.`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (tappable && ICON_WIDGETS.includes(call.name) && key === "size") {
|
|
853
|
+
for (const n of numbers) {
|
|
854
|
+
if (n.number >= TAP_MINIMUM || props.has("height")) continue;
|
|
855
|
+
push(
|
|
856
|
+
valueLine,
|
|
857
|
+
"SMALL_TAP_TARGET",
|
|
858
|
+
`${call.name} is pressable at size ${n.number}, so its whole target is ${n.number}px.`,
|
|
859
|
+
`Wrap it in the ${TAP_MINIMUM}px box it needs, or use \`Button({ icon, onPress })\` and keep the glyph at 20.`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (
|
|
865
|
+
tappable &&
|
|
866
|
+
key === "size" &&
|
|
867
|
+
/(['"])small\1/.test(entry.value) &&
|
|
868
|
+
!props.has("height") &&
|
|
869
|
+
!props.has("minHeight")
|
|
870
|
+
) {
|
|
871
|
+
push(
|
|
872
|
+
valueLine,
|
|
873
|
+
"SMALL_PRESET",
|
|
874
|
+
`${call.name} uses the \`small\` preset as a tap target.`,
|
|
875
|
+
"It renders about 30px tall — fine for a secondary control in a dense desktop grid, not for the main action on a phone.",
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Rule 10 — depth is a scale, and only the widgets that read `elevation` have
|
|
881
|
+
// one. The engine routes the prop straight into `style.boxShadow`, so a number
|
|
882
|
+
// anywhere else is an invalid declaration the browser drops in silence.
|
|
883
|
+
if (
|
|
884
|
+
ctx.elevationReaders?.size &&
|
|
885
|
+
props.has("elevation") &&
|
|
886
|
+
!ctx.elevationReaders.has(call.name) &&
|
|
887
|
+
/^\s*\d+\s*$/.test(props.get("elevation").value)
|
|
888
|
+
) {
|
|
889
|
+
push(
|
|
890
|
+
line,
|
|
891
|
+
"DEAD_ELEVATION",
|
|
892
|
+
`${call.name} sets a numeric \`elevation\` that nothing reads.`,
|
|
893
|
+
`Only ${[...ctx.elevationReaders]
|
|
894
|
+
.sort()
|
|
895
|
+
.join(", ")} turn a number into a shadow. Everywhere else the value reaches \`style.boxShadow\` as a bare number, CSS drops it, and the box stays flat in silence. Write the depth as a \`shadow\` string.`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Rule 11 — an image either says what it is or says that it is decoration.
|
|
900
|
+
if (
|
|
901
|
+
call.name === "Image" &&
|
|
902
|
+
!props.has("alt") &&
|
|
903
|
+
ctx.declaredProps("Image").has("alt")
|
|
904
|
+
) {
|
|
905
|
+
push(
|
|
906
|
+
line,
|
|
907
|
+
"IMAGE_NO_ALT",
|
|
908
|
+
"Image has no `alt`.",
|
|
909
|
+
'A picture that carries information names it; a decoration answers `alt: ""`. The empty string is a decision, the missing prop is an oversight.',
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Rule 11 — a control that is only a glyph is only clean when it is also named.
|
|
914
|
+
if (
|
|
915
|
+
ICON_WIDGETS.includes(call.name) &&
|
|
916
|
+
tappable &&
|
|
917
|
+
!labeledBySibling(call) &&
|
|
918
|
+
!namedByTooltip(call) &&
|
|
919
|
+
!namedByAria(call)
|
|
920
|
+
) {
|
|
921
|
+
push(
|
|
922
|
+
line,
|
|
923
|
+
"ICON_ONLY_CONTROL",
|
|
924
|
+
`${call.name} is pressable and carries no name.`,
|
|
925
|
+
"Wrap it in `Tooltip({ text, child })`, put a `Text` beside it, or name the control itself with `\"aria-label\": \"Share\"`. A glyph with no words is a guess, and a screen reader says nothing at all.",
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
if (
|
|
929
|
+
call.name === "Button" &&
|
|
930
|
+
["icon", "iconLeft", "iconRight"].some((key) => props.has(key)) &&
|
|
931
|
+
!props.has("text") &&
|
|
932
|
+
!labeledBySibling(call) &&
|
|
933
|
+
!namedByTooltip(call) &&
|
|
934
|
+
!namedByAria(call)
|
|
935
|
+
) {
|
|
936
|
+
push(
|
|
937
|
+
line,
|
|
938
|
+
"ICON_ONLY_CONTROL",
|
|
939
|
+
"Button shows an icon and no label.",
|
|
940
|
+
"Give it `text`, or wrap it in `Tooltip({ text, child })`. Every person reads an unnamed glyph slightly differently.",
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Rule 11 — a switch with an icon and no words is not clean, it is unreadable.
|
|
945
|
+
if (
|
|
946
|
+
TOGGLE_WIDGETS.includes(call.name) &&
|
|
947
|
+
!props.has("label") &&
|
|
948
|
+
!labeledBySibling(call) &&
|
|
949
|
+
ctx.declaredProps(call.name).has("label")
|
|
950
|
+
) {
|
|
951
|
+
push(
|
|
952
|
+
line,
|
|
953
|
+
"UNLABELED_TOGGLE",
|
|
954
|
+
`${call.name} has no \`label\` and no text beside it.`,
|
|
955
|
+
'Say what it switches: `Switch({ label: "Email me a copy", value, onToggle })`.',
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Rule 08 — center alignment carries a title, never a run of text.
|
|
960
|
+
if (call.name === "Text") {
|
|
961
|
+
const align = props.get("align") ?? props.get("textAlign");
|
|
962
|
+
const body = literalText(call) ?? "";
|
|
963
|
+
if (align && /center/.test(align.value) && body.length > BODY_TEXT_CHARS) {
|
|
964
|
+
push(
|
|
965
|
+
line,
|
|
966
|
+
"CENTERED_BODY_TEXT",
|
|
967
|
+
`Text centers ${body.length} characters.`,
|
|
968
|
+
`Centered copy is for a short title or a lone action. A paragraph centers its reader: set \`align: "left"\` and hold the line near ${MEASURE_CHARS} characters.`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Rules 13 and 14 — the label of an action is part of the action.
|
|
974
|
+
if (call.name === "Button") {
|
|
975
|
+
const label = (literalText(call) ?? "").trim();
|
|
976
|
+
if (label && GENERIC_LABELS.has(label.toLowerCase())) {
|
|
977
|
+
push(
|
|
978
|
+
line,
|
|
979
|
+
"GENERIC_ACTION_LABEL",
|
|
980
|
+
`Button is labeled "${label}".`,
|
|
981
|
+
'Say what happens instead: "Save changes", "Send invite", "Pay $65". A generic label hides the consequence until after the click.',
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
if (label && DESTRUCTIVE_LABELS.test(label)) {
|
|
985
|
+
destructiveButtons++;
|
|
986
|
+
const colorsUsed = ["bgColor", "backgroundColor", "color", "textColor"]
|
|
987
|
+
.map((key) => props.get(key)?.value ?? "")
|
|
988
|
+
.join(" ");
|
|
989
|
+
if (!/danger|error/i.test(colorsUsed)) {
|
|
990
|
+
push(
|
|
991
|
+
line,
|
|
992
|
+
"DESTRUCTIVE_COLOR",
|
|
993
|
+
`Button "${label}" destroys something and is not in the danger color.`,
|
|
994
|
+
"Red is the convention that makes a person pause: `bgColor: colors.danger` on a filled button, `color: colors.dangerStrong` on a text one — `danger` is the signal, `dangerStrong` is the red that clears AA as words in this theme. A delete in the brand color reads as the safe choice.",
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const contrast = checkContrast(call.name, props, inheritedFillProps(call), ctx, line);
|
|
1001
|
+
if (contrast) findings.push(contrast);
|
|
1002
|
+
|
|
1003
|
+
if (
|
|
1004
|
+
FIELD_WIDGETS.includes(call.name) &&
|
|
1005
|
+
!props.has("label") &&
|
|
1006
|
+
!props.has("placeholder") &&
|
|
1007
|
+
ctx.declaredProps(call.name).has("label")
|
|
1008
|
+
) {
|
|
1009
|
+
push(
|
|
1010
|
+
line,
|
|
1011
|
+
"UNLABELED_FIELD",
|
|
1012
|
+
`${call.name} has neither \`label\` nor \`placeholder\`.`,
|
|
1013
|
+
"A field the user has to guess at is a field they leave wrong. The label also names it for a screen reader.",
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Rule 14 — in a row of buttons the safe choice comes first: it is the one a
|
|
1019
|
+
// reading eye reaches before the one that destroys something.
|
|
1020
|
+
if (destructiveButtons > 0) {
|
|
1021
|
+
for (const call of calls) {
|
|
1022
|
+
const buttons = childrenOf(call).filter((other) => other.name === "Button");
|
|
1023
|
+
if (buttons.length < 2) continue;
|
|
1024
|
+
const first = (test) =>
|
|
1025
|
+
buttons.findIndex((button) => test((literalText(button) ?? "").trim()));
|
|
1026
|
+
const destructive = first((label) => label && DESTRUCTIVE_LABELS.test(label));
|
|
1027
|
+
const cancel = first((label) => CANCEL_LABELS.test(label));
|
|
1028
|
+
if (destructive < 0 || cancel < 0 || destructive > cancel) continue;
|
|
1029
|
+
push(
|
|
1030
|
+
lineOf(offsets, buttons[destructive].open),
|
|
1031
|
+
"CONFIRM_ORDER",
|
|
1032
|
+
`In this row the destructive button (${(literalText(buttons[destructive]) ?? "").trim()}) comes before ${(literalText(buttons[cancel]) ?? "").trim()}.`,
|
|
1033
|
+
"Left is where the eye lands first, so put the way out there: Cancel, then the action. The button that cannot be undone is the last thing a finger reaches.",
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Rule 04 — one screen, one primary action. Emphasis is a budget, not a style.
|
|
1039
|
+
const primaries = calls.filter(isFilled);
|
|
1040
|
+
if (primaries.length > 1) {
|
|
1041
|
+
push(
|
|
1042
|
+
1,
|
|
1043
|
+
"MULTIPLE_PRIMARY_ACTIONS",
|
|
1044
|
+
`${primaries.length} filled buttons in this file (lines ${primaries
|
|
1045
|
+
.map((call) => lineOf(offsets, call.open))
|
|
1046
|
+
.join(", ")}).`,
|
|
1047
|
+
"Everything filled is competing with everything else. Keep one filled, make the next `variant: \"outlined\"`, and drop the rest to `variant: \"text\"`.",
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Rule 13 — a form stretched across a desktop is a form nobody finishes.
|
|
1052
|
+
const formBlocks = calls.filter(
|
|
1053
|
+
(call) => descendantsOf(call).filter((other) => other.name === "Input").length >= 3,
|
|
1054
|
+
);
|
|
1055
|
+
for (const block of formBlocks) {
|
|
1056
|
+
if (
|
|
1057
|
+
formBlocks.some(
|
|
1058
|
+
(other) => other !== block && other.open > block.open && other.close < block.close,
|
|
1059
|
+
)
|
|
1060
|
+
) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if ([block, ...enclosing(block)].some(capsWidth)) continue;
|
|
1064
|
+
push(
|
|
1065
|
+
lineOf(offsets, block.open),
|
|
1066
|
+
"UNCONSTRAINED_FORM_WIDTH",
|
|
1067
|
+
`${block.name} holds ${descendantsOf(block).filter((other) => other.name === "Input").length} fields and nothing caps its width.`,
|
|
1068
|
+
"A field as wide as the window says 'type anything'. Put the form in `Container({ maxWidth: 480 })` and size each field to its data — a CVC box is four characters, not the page.",
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (fieldCount > 7) {
|
|
1073
|
+
push(
|
|
1074
|
+
1,
|
|
1075
|
+
"CROWDED_FORM",
|
|
1076
|
+
`${fieldCount} form fields in one file.`,
|
|
1077
|
+
"Chunk the ask: 3 to 7 fields per step, one card per step, and a `Stepper` or a progress line when there is more than one step.",
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
if (sawList && !sawState) {
|
|
1081
|
+
push(
|
|
1082
|
+
1,
|
|
1083
|
+
"LIST_WITHOUT_STATE",
|
|
1084
|
+
"A list with no loading, empty or failed state in this file.",
|
|
1085
|
+
"`Skeleton` while rows are on their way, a plain message when there are none, a `SnackBar` when the load fails — silence reads as a broken app.",
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return findings;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Classify the installed package's style props into the families these rules use.
|
|
1094
|
+
* Registry-driven, so no prop name is invented here.
|
|
1095
|
+
*
|
|
1096
|
+
* @param {object} api resolved package surface
|
|
1097
|
+
* @returns {Map<string, "color" | "spacing" | "radius" | "type">}
|
|
1098
|
+
*/
|
|
1099
|
+
export const propKindsFromRegistry = (api) => {
|
|
1100
|
+
const kinds = new Map();
|
|
1101
|
+
for (const entry of Object.values(api.ALL_PROPS)) {
|
|
1102
|
+
if (!entry || entry.type !== "style") continue;
|
|
1103
|
+
const dom = String(entry.domProp || "");
|
|
1104
|
+
if (/color$/i.test(dom) || dom === "fill" || dom === "stroke") kinds.set(entry.prop, "color");
|
|
1105
|
+
else if (/^(padding|margin)/.test(dom) || /Gap$/.test(dom) || dom === "gap")
|
|
1106
|
+
kinds.set(entry.prop, "spacing");
|
|
1107
|
+
else if (/Radius$/.test(dom)) kinds.set(entry.prop, "radius");
|
|
1108
|
+
else if (dom === "fontSize") kinds.set(entry.prop, "type");
|
|
1109
|
+
}
|
|
1110
|
+
return kinds;
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* The measured contrast of every text-ish token against the two surfaces a screen
|
|
1115
|
+
* paints. Prints the honest table, including the tokens that only pass for large
|
|
1116
|
+
* text — that is the number a designer needs before arguing about one.
|
|
1117
|
+
*
|
|
1118
|
+
* @param {Record<string, string>} tokens
|
|
1119
|
+
* @param {(fg: string, bg: string) => number | null} contrast
|
|
1120
|
+
* @returns {string}
|
|
1121
|
+
*/
|
|
1122
|
+
export const tokenTable = (tokens, contrast) => {
|
|
1123
|
+
if (!contrast) return "";
|
|
1124
|
+
const names = [
|
|
1125
|
+
"text",
|
|
1126
|
+
"textSecondary",
|
|
1127
|
+
"textDisabled",
|
|
1128
|
+
"primary",
|
|
1129
|
+
"secondary",
|
|
1130
|
+
"danger",
|
|
1131
|
+
"dangerStrong",
|
|
1132
|
+
"successStrong",
|
|
1133
|
+
"warningStrong",
|
|
1134
|
+
"infoStrong",
|
|
1135
|
+
"warning",
|
|
1136
|
+
"success",
|
|
1137
|
+
"border",
|
|
1138
|
+
"divider",
|
|
1139
|
+
].filter((name) => name !== "onPrimary");
|
|
1140
|
+
const lines = [section("Token contrast", "measured against the palette in use (WCAG 2.1)")];
|
|
1141
|
+
for (const name of names) {
|
|
1142
|
+
const value = tokens[name];
|
|
1143
|
+
if (typeof value !== "string") continue;
|
|
1144
|
+
const onBg = contrast(value, tokens.background);
|
|
1145
|
+
const onSurface = contrast(value, tokens.surface);
|
|
1146
|
+
const verdict =
|
|
1147
|
+
onBg >= CONTRAST_BODY
|
|
1148
|
+
? "AA body text"
|
|
1149
|
+
: onBg >= CONTRAST_LARGE
|
|
1150
|
+
? "large text / UI only"
|
|
1151
|
+
: "below 3:1 — decoration only";
|
|
1152
|
+
lines.push(
|
|
1153
|
+
` colors.${name.padEnd(14)} ${value.padEnd(9)} ${onBg.toFixed(2)}:1 on background · ${onSurface.toFixed(2)}:1 on surface ${verdict}`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
lines.push(section("On a brand fill", "colors.onPrimary written on each fill"));
|
|
1157
|
+
for (const name of [
|
|
1158
|
+
"primary",
|
|
1159
|
+
"secondary",
|
|
1160
|
+
"danger",
|
|
1161
|
+
"dangerStrong",
|
|
1162
|
+
"successStrong",
|
|
1163
|
+
"warningStrong",
|
|
1164
|
+
"warning",
|
|
1165
|
+
"success",
|
|
1166
|
+
"info",
|
|
1167
|
+
"infoStrong",
|
|
1168
|
+
"gray900",
|
|
1169
|
+
]) {
|
|
1170
|
+
const fill = tokens[name];
|
|
1171
|
+
if (typeof fill !== "string" || typeof tokens.onPrimary !== "string") continue;
|
|
1172
|
+
const ratio = contrast(tokens.onPrimary, fill);
|
|
1173
|
+
const verdict =
|
|
1174
|
+
ratio >= CONTRAST_BODY
|
|
1175
|
+
? "any size"
|
|
1176
|
+
: ratio >= CONTRAST_LARGE
|
|
1177
|
+
? "24px, or 18px bold only"
|
|
1178
|
+
: "do not write text here";
|
|
1179
|
+
lines.push(
|
|
1180
|
+
` onPrimary on colors.${name.padEnd(10)} ${fill.padEnd(9)} ${ratio.toFixed(2)}:1 ${verdict}`,
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
return `${lines.join("\n")}\n`;
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Run the clean-UI gate over a project.
|
|
1188
|
+
*
|
|
1189
|
+
* @param {string[]} argv
|
|
1190
|
+
* @param {{print?: (line: string) => void}} [io]
|
|
1191
|
+
* @returns {Promise<{findings: object[], exitCode: number}>}
|
|
1192
|
+
*/
|
|
1193
|
+
export const checkUi = async (argv = [], io = {}) => {
|
|
1194
|
+
const print = io.print ?? ((line) => console.log(line));
|
|
1195
|
+
const rootIndex = argv.indexOf("--root");
|
|
1196
|
+
const root = path.resolve(
|
|
1197
|
+
rootIndex >= 0 && argv[rootIndex + 1] ? argv[rootIndex + 1] : process.cwd(),
|
|
1198
|
+
);
|
|
1199
|
+
const asJson = argv.includes("--json");
|
|
1200
|
+
const strict = argv.includes("--strict");
|
|
1201
|
+
|
|
1202
|
+
let api;
|
|
1203
|
+
try {
|
|
1204
|
+
api = await loadApi(root);
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
print(c("red", `❌ ${error.message}`));
|
|
1207
|
+
return { findings: [], exitCode: 1 };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const barrel = await import(pathToFileURL(path.join(api.dir, "src", "index.js")).href);
|
|
1211
|
+
const packageTokens = barrel.colors ?? {};
|
|
1212
|
+
const makeColor = typeof barrel.color === "function" ? barrel.color : null;
|
|
1213
|
+
const probe = makeColor ? makeColor("#000000") : null;
|
|
1214
|
+
const contrast =
|
|
1215
|
+
probe && typeof probe.contrastRatio === "function"
|
|
1216
|
+
? (fg, bg) => {
|
|
1217
|
+
const a = makeColor(fg);
|
|
1218
|
+
const b = makeColor(bg);
|
|
1219
|
+
if (!a || !b || typeof a.luminance !== "function") return CONTRAST_BODY;
|
|
1220
|
+
return a.contrastRatio(b);
|
|
1221
|
+
}
|
|
1222
|
+
: null;
|
|
1223
|
+
|
|
1224
|
+
if (!contrast && !asJson) {
|
|
1225
|
+
print(
|
|
1226
|
+
c(
|
|
1227
|
+
"yellow",
|
|
1228
|
+
"⚠️ This flet-box build has no contrastRatio() — the WCAG rules are skipped. Update the package to read them.",
|
|
1229
|
+
),
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const cache = new Map();
|
|
1234
|
+
const declaredProps = (widget) => {
|
|
1235
|
+
if (!cache.has(widget)) {
|
|
1236
|
+
const props = resolveInterfaceProps(api.interfaces, `${widget}Props`);
|
|
1237
|
+
cache.set(widget, new Set([...(props?.keys?.() ?? [])]));
|
|
1238
|
+
}
|
|
1239
|
+
return cache.get(widget);
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Which of the installed widgets actually turn a numeric `elevation` into a
|
|
1244
|
+
* shadow. The engine routes `elevation` straight into `style.boxShadow`, so
|
|
1245
|
+
* anywhere outside this set the number paints nothing. The list is read off the
|
|
1246
|
+
* package instead of hardcoded here, because the package changes and this
|
|
1247
|
+
* checker must not invent a depth API either.
|
|
1248
|
+
*
|
|
1249
|
+
* @returns {Set<string>}
|
|
1250
|
+
*/
|
|
1251
|
+
const readElevationReaders = () => {
|
|
1252
|
+
const found = new Set();
|
|
1253
|
+
const buildsAShadow =
|
|
1254
|
+
/SHADOW_LEVELS\s*\[|Shadows\s*\[|shadows\s*\[|\$\{\s*currentElevation\b|\$\{\s*elevation\b/;
|
|
1255
|
+
for (const dir of ["widgets", "navigations"]) {
|
|
1256
|
+
let names = [];
|
|
1257
|
+
try {
|
|
1258
|
+
names = fs.readdirSync(path.join(api.dir, "src", dir));
|
|
1259
|
+
} catch {
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
for (const name of names.filter((file) => /\.js$/.test(file))) {
|
|
1263
|
+
const source = fs.readFileSync(path.join(api.dir, "src", dir, name), "utf8");
|
|
1264
|
+
if (/elevation/.test(source) && buildsAShadow.test(source)) {
|
|
1265
|
+
found.add(path.basename(name, ".js"));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return found;
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
const files = collectSources(root).filter(
|
|
1273
|
+
(file) => !/^(service-worker|sw[-.].*)\.m?js$/.test(path.basename(file)),
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
// The app's own token file wins: a screen paints with what it declares, and the
|
|
1277
|
+
// contrast rules have to measure that, not the library default.
|
|
1278
|
+
const tokens = files
|
|
1279
|
+
.filter(isTokenFile)
|
|
1280
|
+
.reduce((all, file) => ({ ...all, ...appPalette(file) }), { ...packageTokens });
|
|
1281
|
+
|
|
1282
|
+
// Which palettes a person actually sees. `colors` is the object the module
|
|
1283
|
+
// boots with; `palettes.light` and `palettes.dark` are what `setTheme()`
|
|
1284
|
+
// paints, and the two disagree about a token: `primary` is #6165ef at boot,
|
|
1285
|
+
// #18181B in the light theme and #FAFAFA in the dark one, so a finding raised
|
|
1286
|
+
// only against the boot object cannot be reproduced on any screen. A package
|
|
1287
|
+
// with no `palettes` export is measured against the boot object alone.
|
|
1288
|
+
const themes = (() => {
|
|
1289
|
+
const real = barrel.palettes;
|
|
1290
|
+
const named =
|
|
1291
|
+
real && typeof real === "object"
|
|
1292
|
+
? Object.entries(real).filter(
|
|
1293
|
+
([, palette]) => palette && typeof palette === "object",
|
|
1294
|
+
)
|
|
1295
|
+
: [];
|
|
1296
|
+
if (!named.length) return [{ name: "default", tokens }];
|
|
1297
|
+
return named.map(([name, palette]) => ({ name, tokens: { ...tokens, ...palette } }));
|
|
1298
|
+
})();
|
|
1299
|
+
|
|
1300
|
+
const ctx = {
|
|
1301
|
+
currentFile: "",
|
|
1302
|
+
propKinds: propKindsFromRegistry(api),
|
|
1303
|
+
tokens,
|
|
1304
|
+
themes,
|
|
1305
|
+
contrast,
|
|
1306
|
+
declaredProps,
|
|
1307
|
+
elevationReaders: readElevationReaders(),
|
|
1308
|
+
};
|
|
1309
|
+
const findings = files.flatMap((file) => {
|
|
1310
|
+
ctx.currentFile = file;
|
|
1311
|
+
try {
|
|
1312
|
+
return checkFile(file, ctx);
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
return [{ file, line: 0, code: "UNREADABLE", message: error.message, detail: "" }];
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
if (argv.includes("--tokens")) print(tokenTable(tokens, contrast));
|
|
1319
|
+
|
|
1320
|
+
if (asJson) {
|
|
1321
|
+
print(
|
|
1322
|
+
JSON.stringify({ package: api.pkg.version, scanned: files.length, findings }, null, 2),
|
|
1323
|
+
);
|
|
1324
|
+
} else {
|
|
1325
|
+
print(
|
|
1326
|
+
banner(
|
|
1327
|
+
"🎨 FletBox clean-UI check",
|
|
1328
|
+
`flet-box@${api.pkg.version} · ${files.length} file(s) scanned`,
|
|
1329
|
+
),
|
|
1330
|
+
);
|
|
1331
|
+
if (!findings.length) {
|
|
1332
|
+
print(`\n${c("green", "✅ On the scale, on the tokens, and readable.")}\n`);
|
|
1333
|
+
return { findings, exitCode: 0 };
|
|
1334
|
+
}
|
|
1335
|
+
const byCode = new Map();
|
|
1336
|
+
for (const item of findings) {
|
|
1337
|
+
if (!byCode.has(item.code)) byCode.set(item.code, []);
|
|
1338
|
+
byCode.get(item.code).push(item);
|
|
1339
|
+
}
|
|
1340
|
+
for (const [code, list] of byCode) {
|
|
1341
|
+
print(
|
|
1342
|
+
section(
|
|
1343
|
+
"•",
|
|
1344
|
+
`${code} — ${list.length} finding(s)`,
|
|
1345
|
+
ERROR_CODES.has(code) ? "#f87171" : INFO_CODES.has(code) ? "#94a3b8" : "#fbbf24",
|
|
1346
|
+
),
|
|
1347
|
+
);
|
|
1348
|
+
for (const item of list) {
|
|
1349
|
+
print(` ${path.relative(root, item.file)}:${item.line} ${item.message}`);
|
|
1350
|
+
if (item.detail) print(` ${c("gray", item.detail)}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const errors = findings.filter((item) => ERROR_CODES.has(item.code));
|
|
1356
|
+
const warns = findings.filter((item) => WARN_CODES.has(item.code));
|
|
1357
|
+
const infos = findings.filter(
|
|
1358
|
+
(item) => !ERROR_CODES.has(item.code) && !WARN_CODES.has(item.code),
|
|
1359
|
+
);
|
|
1360
|
+
const failed = errors.length > 0 || (strict && warns.length > 0);
|
|
1361
|
+
if (!asJson) {
|
|
1362
|
+
print(
|
|
1363
|
+
`\n${failed ? c("red", "❌") : c("green", "✅")} ${errors.length} error(s), ${warns.length} warning(s), ${infos.length} to review${
|
|
1364
|
+
strict ? " (--strict also fails on contrast warnings)" : ""
|
|
1365
|
+
}\n`,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
return { findings, exitCode: failed ? 1 : 0 };
|
|
1369
|
+
};
|