flet-box 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/bin/cli.js +13 -0
  2. package/bin/commands/checkApi.js +13 -1
  3. package/bin/commands/checkUi.js +1369 -0
  4. package/bin/commands/create.js +49 -5
  5. package/bin/commands/createBundle.js +21 -0
  6. package/bin/commands/runServer.js +11 -2
  7. package/bin/utils/pwaShell.js +137 -0
  8. package/bin/utils/templates.js +1373 -434
  9. package/docs/README.md +2 -0
  10. package/docs/cli/README.md +62 -1
  11. package/docs/guides/README.md +2 -0
  12. package/docs/guides/clean-ui.md +211 -0
  13. package/docs/guides/minification-and-protection.md +1 -1
  14. package/docs/guides/online-updates.md +48 -0
  15. package/docs/tools/README.md +1 -1
  16. package/docs/tools/animation.md +1 -1
  17. package/docs/tools/markdown.md +3 -3
  18. package/docs/tools/theme.md +41 -1
  19. package/docs/widget/Accordion.md +3 -0
  20. package/docs/widget/AdMob.md +4 -1
  21. package/docs/widget/AdSense.md +3 -0
  22. package/docs/widget/AnimatedBox.md +6 -3
  23. package/docs/widget/AnimatedText.md +3 -0
  24. package/docs/widget/Audio.md +3 -0
  25. package/docs/widget/Avatar.md +3 -0
  26. package/docs/widget/Badge.md +3 -0
  27. package/docs/widget/Button.md +3 -0
  28. package/docs/widget/COMMON_PROPS.md +52 -0
  29. package/docs/widget/Card.md +3 -0
  30. package/docs/widget/Carousel.md +3 -0
  31. package/docs/widget/Chart.md +3 -0
  32. package/docs/widget/Checkbox.md +3 -0
  33. package/docs/widget/Chip.md +3 -0
  34. package/docs/widget/CircularBar.md +3 -0
  35. package/docs/widget/CircularChart.md +3 -0
  36. package/docs/widget/CodeViewer.md +3 -0
  37. package/docs/widget/Column.md +3 -0
  38. package/docs/widget/Container.md +3 -0
  39. package/docs/widget/DataTable.md +3 -0
  40. package/docs/widget/Divider.md +3 -0
  41. package/docs/widget/DraggBox.md +3 -0
  42. package/docs/widget/Dropdown.md +3 -0
  43. package/docs/widget/DroppBox.md +3 -0
  44. package/docs/widget/FloatingActionButton.md +3 -0
  45. package/docs/widget/GridView.md +3 -0
  46. package/docs/widget/Icon.md +3 -0
  47. package/docs/widget/Image.md +3 -0
  48. package/docs/widget/Input.md +3 -0
  49. package/docs/widget/Inspector.md +2 -2
  50. package/docs/widget/InstallButton.md +3 -0
  51. package/docs/widget/ListTile.md +3 -0
  52. package/docs/widget/ListView.md +3 -0
  53. package/docs/widget/Maps.md +3 -0
  54. package/docs/widget/Markdown.md +3 -0
  55. package/docs/widget/MatrixRain.md +3 -0
  56. package/docs/widget/Pagination.md +3 -0
  57. package/docs/widget/ParallaxBox.md +3 -0
  58. package/docs/widget/ProgressBar.md +3 -0
  59. package/docs/widget/QRCode.md +3 -0
  60. package/docs/widget/Radio.md +4 -1
  61. package/docs/widget/Rating.md +3 -0
  62. package/docs/widget/Row.md +3 -0
  63. package/docs/widget/Skeleton.md +3 -0
  64. package/docs/widget/Slider.md +3 -0
  65. package/docs/widget/Stack.md +3 -0
  66. package/docs/widget/Stepper.md +3 -0
  67. package/docs/widget/Switch.md +3 -0
  68. package/docs/widget/Tabs.md +5 -5
  69. package/docs/widget/Text.md +3 -0
  70. package/docs/widget/Tooltip.md +3 -0
  71. package/docs/widget/TreeView.md +3 -0
  72. package/docs/widget/Video.md +3 -0
  73. package/package.json +2 -2
  74. package/src/animations/AnimatedBox.js +4 -1
  75. package/src/animations/MatrixRain.js +3 -3
  76. package/src/index.d.ts +64 -8
  77. package/src/index.js +1 -0
  78. package/src/navigations/AppBar.js +7 -12
  79. package/src/navigations/BottomNavigation.js +8 -13
  80. package/src/navigations/CollapsibleSideBar.js +30 -8
  81. package/src/navigations/Drawer.js +16 -14
  82. package/src/navigations/DrawerItem.js +3 -1
  83. package/src/navigations/Scaffold.js +26 -12
  84. package/src/navigations/Tabs.js +104 -59
  85. package/src/tools/color.js +54 -0
  86. package/src/tools/index.js +9 -1
  87. package/src/tools/scale.js +41 -0
  88. package/src/tools/shadow.js +20 -0
  89. package/src/utils/index.js +1 -0
  90. package/src/utils/readableInk.js +19 -0
  91. package/src/utils/syntaxHighlight.js +78 -10
  92. package/src/utils/themes.js +217 -54
  93. package/src/utils/units.js +33 -11
  94. package/src/widget-factory/effects.js +56 -5
  95. package/src/widget-factory/processProps.js +10 -1
  96. package/src/widget-factory/remTool.js +15 -29
  97. package/src/widget-factory/stackPosition.js +18 -3
  98. package/src/widget-factory/translateProps.js +28 -0
  99. package/src/widgets/Accordion.js +20 -23
  100. package/src/widgets/AdMob.js +7 -2
  101. package/src/widgets/Avatar.js +18 -12
  102. package/src/widgets/Badge.js +3 -2
  103. package/src/widgets/BottomSheet.js +14 -13
  104. package/src/widgets/Button.js +69 -30
  105. package/src/widgets/Card.js +6 -2
  106. package/src/widgets/Carousel.js +12 -10
  107. package/src/widgets/Chart.js +28 -5
  108. package/src/widgets/Checkbox.js +76 -18
  109. package/src/widgets/Chip.js +31 -10
  110. package/src/widgets/CircularBar.js +7 -7
  111. package/src/widgets/CircularChart.js +26 -8
  112. package/src/widgets/CodeViewer.js +93 -20
  113. package/src/widgets/DataTable.js +17 -11
  114. package/src/widgets/DraggBox.js +2 -1
  115. package/src/widgets/Dropdown.js +26 -13
  116. package/src/widgets/DroppBox.js +4 -2
  117. package/src/widgets/FloatingActionButton.js +13 -7
  118. package/src/widgets/Icon.js +3 -1
  119. package/src/widgets/Input.js +30 -23
  120. package/src/widgets/Inspector.js +54 -1
  121. package/src/widgets/InstallButton.js +6 -4
  122. package/src/widgets/ListTile.js +4 -3
  123. package/src/widgets/ListView.js +30 -12
  124. package/src/widgets/Markdown.js +37 -26
  125. package/src/widgets/Modal.js +14 -11
  126. package/src/widgets/Pagination.js +16 -11
  127. package/src/widgets/ProgressBar.js +6 -4
  128. package/src/widgets/Radio.js +73 -15
  129. package/src/widgets/Rating.js +24 -6
  130. package/src/widgets/Skeleton.js +31 -31
  131. package/src/widgets/Slider.js +29 -18
  132. package/src/widgets/SnackBar.js +35 -39
  133. package/src/widgets/Stepper.js +13 -9
  134. package/src/widgets/Switch.js +59 -18
  135. package/src/widgets/Tooltip.js +12 -10
  136. 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
+ };