flet-box 1.0.9 → 1.1.1
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/bin/commands/checkUi.js +327 -46
- package/bin/commands/create.js +48 -19
- package/bin/commands/createScreen.js +1 -1
- package/bin/commands/runBundle.js +1 -1
- package/bin/utils/colors.js +0 -8
- package/bin/utils/helpers.js +0 -5
- package/bin/utils/images/jpeg.js +2 -3
- package/bin/utils/skills.js +59 -0
- package/bin/utils/templates.js +196 -575
- package/docs/README.md +3 -1
- package/docs/cli/README.md +18 -1
- package/docs/guides/README.md +6 -1
- package/docs/guides/accessibility.md +282 -0
- package/docs/guides/clean-ui.md +84 -21
- package/docs/guides/minification-and-protection.md +1 -1
- package/docs/guides/mobile-and-platforms.md +51 -0
- package/docs/guides/utilities.md +116 -4
- package/docs/guides/widget-structure.md +30 -0
- package/docs/tools/README.md +1 -1
- package/docs/tools/animation.md +134 -11
- package/docs/tools/device.md +75 -3
- package/docs/tools/text-and-time.md +116 -6
- package/docs/widget/Accordion.md +2 -0
- package/docs/widget/AnimatedBox.md +46 -20
- package/docs/widget/AnimatedText.md +13 -7
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/COMMON_PROPS.md +26 -3
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +7 -1
- package/docs/widget/CircularChart.md +2 -0
- package/docs/widget/Drawer.md +1 -0
- package/docs/widget/GridView.md +4 -1
- package/docs/widget/Input.md +2 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/Markdown.md +4 -0
- package/docs/widget/Rating.md +2 -0
- package/docs/widget/Slider.md +2 -0
- package/docs/widget/Stack.md +6 -5
- package/docs/widget/Stepper.md +2 -0
- package/docs/widget/Tooltip.md +3 -1
- package/package.json +4 -2
- package/skills/fletbox-design/SKILL.md +949 -0
- package/skills/fletbox-memory/SKILL.md +134 -0
- package/skills/fletbox-motion/SKILL.md +266 -0
- package/skills/fletbox-rules/SKILL.md +293 -0
- package/src/animations/AnimatedBox.js +321 -226
- package/src/animations/MatrixRain.js +7 -1
- package/src/animations/ParallaxBox.js +13 -10
- package/src/animations/animate.js +269 -106
- package/src/animations/color.js +91 -0
- package/src/animations/easings.js +275 -0
- package/src/animations/index.js +21 -0
- package/src/animations/motion.js +204 -0
- package/src/animations/transitions.js +327 -0
- package/src/core/runApp.js +0 -6
- package/src/helpers/boxEdges.js +69 -0
- package/src/helpers/html.js +39 -0
- package/src/index.d.ts +377 -25
- package/src/index.js +77 -0
- package/src/navigations/AdaptiveScaffold.js +6 -2
- package/src/navigations/AppBar.js +16 -7
- package/src/navigations/BottomNavigation.js +24 -1
- package/src/navigations/CollapsibleSideBar.js +0 -1
- package/src/navigations/Drawer.js +32 -1
- package/src/navigations/DrawerItem.js +1 -1
- package/src/navigations/Router.js +0 -1
- package/src/navigations/Scaffold.js +9 -3
- package/src/navigations/Tabs.js +104 -31
- package/src/services/RamStore.js +1 -2
- package/src/tools/breakpoints.js +113 -0
- package/src/tools/createList.js +0 -2
- package/src/tools/fonts.js +99 -0
- package/src/tools/index.js +74 -1
- package/src/tools/margin.js +5 -63
- package/src/tools/padding.js +5 -63
- package/src/tools/print.js +0 -8
- package/src/tools/scale.js +9 -2
- package/src/tools/time.js +56 -15
- package/src/tools/useState.js +1 -54
- package/src/utils/fieldFont.js +77 -0
- package/src/utils/focusRing.js +73 -0
- package/src/utils/focusScope.js +113 -0
- package/src/utils/markdownParser.js +7 -9
- package/src/utils/readableInk.js +7 -1
- package/src/utils/stackLayout.js +55 -0
- package/src/utils/styleInjector.js +25 -0
- package/src/utils/syntaxHighlight.js +1 -17
- package/src/utils/themes.js +31 -0
- package/src/utils/visualEffects.js +49 -47
- package/src/widget-factory/createWidget.js +16 -3
- package/src/widget-factory/effects.js +76 -3
- package/src/widget-factory/reactiveValue.js +0 -7
- package/src/widget-factory/tools.js +26 -143
- package/src/widget-factory/translateProps.js +5 -0
- package/src/widget-factory/widgetFactory.js +1 -1
- package/src/widgets/Accordion.js +70 -12
- package/src/widgets/AdMob.js +1 -1
- package/src/widgets/AdSense.js +1 -1
- package/src/widgets/AlertDialog.js +11 -9
- package/src/widgets/Avatar.js +10 -1
- package/src/widgets/Badge.js +7 -1
- package/src/widgets/BottomSheet.js +39 -5
- package/src/widgets/Chart.js +41 -9
- package/src/widgets/Checkbox.js +59 -9
- package/src/widgets/Chip.js +13 -1
- package/src/widgets/CircularBar.js +17 -5
- package/src/widgets/CircularChart.js +24 -2
- package/src/widgets/CodeViewer.js +5 -3
- package/src/widgets/Dropdown.js +223 -20
- package/src/widgets/GridView.js +17 -1
- package/src/widgets/Icon.js +21 -0
- package/src/widgets/Input.js +75 -44
- package/src/widgets/InstallButton.js +8 -2
- package/src/widgets/ListTile.js +6 -5
- package/src/widgets/ListView.js +21 -7
- package/src/widgets/Markdown.js +25 -44
- package/src/widgets/Modal.js +43 -6
- package/src/widgets/Pagination.js +35 -3
- package/src/widgets/ProgressBar.js +18 -0
- package/src/widgets/Radio.js +7 -0
- package/src/widgets/Rating.js +104 -16
- package/src/widgets/Skeleton.js +9 -5
- package/src/widgets/Slider.js +87 -7
- package/src/widgets/SnackBar.js +30 -1
- package/src/widgets/Stack.js +25 -13
- package/src/widgets/Stepper.js +112 -64
- package/src/widgets/Switch.js +10 -1
- package/src/widgets/Tooltip.js +93 -10
- package/src/widgets/TreeView.js +5 -5
- package/src/create-index.sh +0 -237
- package/src/widget-factory/pixTool.js +0 -40
- package/src/widget-factory/stringTool.js +0 -26
package/bin/commands/checkUi.js
CHANGED
|
@@ -109,13 +109,55 @@ export const WARN_CODES = new Set([
|
|
|
109
109
|
"UNCONSTRAINED_FORM_WIDTH",
|
|
110
110
|
]);
|
|
111
111
|
|
|
112
|
-
const INFO_CODES = new Set([
|
|
112
|
+
export const INFO_CODES = new Set([
|
|
113
113
|
"SMALL_PRESET",
|
|
114
114
|
"CROWDED_FORM",
|
|
115
115
|
"LIST_WITHOUT_STATE",
|
|
116
116
|
"MULTIPLE_PRIMARY_ACTIONS",
|
|
117
117
|
]);
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Which design rule each code proves, and the number the finding prints.
|
|
121
|
+
*
|
|
122
|
+
* The gate is the machine half of a contract whose human half is
|
|
123
|
+
* `skills/fletbox-design/SKILL.md`: 38 numbered rules across two parts, of which
|
|
124
|
+
* this table cites the enforceable 00-24 (25-37 are the visual atlas, judgement
|
|
125
|
+
* no static pass can prove). A finding that says `OFF_SCALE_SPACING` tells the reader the
|
|
126
|
+
* value is wrong; a finding that says `OFF_SCALE_SPACING — rule 07` tells them to
|
|
127
|
+
* open rule 07 and learn *why*, which is the only way the next screen is right
|
|
128
|
+
* without being told twice. `scripts/check-skills.mjs` fails if a code is missing
|
|
129
|
+
* here, if a number here is not a heading in the skill, or if the skill does not
|
|
130
|
+
* name that code inside the rule it is filed under — so this table and that file
|
|
131
|
+
* cannot drift apart.
|
|
132
|
+
*/
|
|
133
|
+
export const CODE_RULE = {
|
|
134
|
+
RAW_COLOR: "03",
|
|
135
|
+
OFF_SCALE_SPACING: "07",
|
|
136
|
+
OFF_SCALE_RADIUS: "03",
|
|
137
|
+
OFF_SCALE_TYPE: "08",
|
|
138
|
+
SMALL_TAP_TARGET: "03",
|
|
139
|
+
LOW_CONTRAST: "11",
|
|
140
|
+
UNLABELED_FIELD: "13",
|
|
141
|
+
UNLABELED_TOGGLE: "11",
|
|
142
|
+
DESTRUCTIVE_COLOR: "14",
|
|
143
|
+
UNREADABLE: "00",
|
|
144
|
+
LOW_CONTRAST_SOFT: "11",
|
|
145
|
+
LOW_CONTRAST_UI: "11",
|
|
146
|
+
LOW_CONTRAST_ON_SURFACE: "11",
|
|
147
|
+
HAND_MIXED_COLOR: "03",
|
|
148
|
+
DEAD_ELEVATION: "10",
|
|
149
|
+
IMAGE_NO_ALT: "11",
|
|
150
|
+
ICON_ONLY_CONTROL: "11",
|
|
151
|
+
CENTERED_BODY_TEXT: "08",
|
|
152
|
+
GENERIC_ACTION_LABEL: "14",
|
|
153
|
+
CONFIRM_ORDER: "14",
|
|
154
|
+
UNCONSTRAINED_FORM_WIDTH: "13",
|
|
155
|
+
SMALL_PRESET: "03",
|
|
156
|
+
CROWDED_FORM: "06",
|
|
157
|
+
LIST_WITHOUT_STATE: "12",
|
|
158
|
+
MULTIPLE_PRIMARY_ACTIONS: "04",
|
|
159
|
+
};
|
|
160
|
+
|
|
119
161
|
/**
|
|
120
162
|
* The legal home for a hex value is the app's own token file. Anything else in
|
|
121
163
|
* `src/` that types a color by hand is the drift this rule exists to catch.
|
|
@@ -349,7 +391,9 @@ export const entriesOf = (code, open, close) => {
|
|
|
349
391
|
};
|
|
350
392
|
|
|
351
393
|
/**
|
|
352
|
-
* Numeric literals written in a value, skipping the ones inside strings.
|
|
394
|
+
* Numeric literals written in a value, skipping the ones inside strings. This is
|
|
395
|
+
* a lexical scan, not an evaluation: use `measuredNumbers` before judging a
|
|
396
|
+
* value against a scale, or `base * 1.5` is read as a 1.5 size.
|
|
353
397
|
*
|
|
354
398
|
* @param {string} value
|
|
355
399
|
* @returns {Array<{number: number, offset: number}>}
|
|
@@ -374,6 +418,143 @@ export const numbersIn = (value) => {
|
|
|
374
418
|
return found;
|
|
375
419
|
};
|
|
376
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Tokenise an arithmetic expression made only of number literals operators and
|
|
423
|
+
* parentheses. Anything else — an identifier, a member access, a call, a
|
|
424
|
+
* ternary, an object or array — returns null, because its value is decided at
|
|
425
|
+
* runtime and a static pass that guessed it would report a number the screen
|
|
426
|
+
* never paints.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} text
|
|
429
|
+
* @returns {Array<{type: string, value?: number}> | null}
|
|
430
|
+
*/
|
|
431
|
+
const arithmeticTokens = (text) => {
|
|
432
|
+
const tokens = [];
|
|
433
|
+
let i = 0;
|
|
434
|
+
while (i < text.length) {
|
|
435
|
+
const ch = text[i];
|
|
436
|
+
if (/\s/.test(ch)) {
|
|
437
|
+
i++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(text[i + 1] ?? ""))) {
|
|
441
|
+
const match = /^\d*\.?\d+(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
442
|
+
tokens.push({ type: "number", value: Number(match[0]) });
|
|
443
|
+
i += match[0].length;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (/[A-Za-z_$]/.test(ch)) return null;
|
|
447
|
+
if ("+-*/%()".includes(ch)) {
|
|
448
|
+
tokens.push({ type: ch });
|
|
449
|
+
i++;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
return tokens;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Evaluate a token list by recursive descent. Null means "not provable", which
|
|
459
|
+
* is never the same answer as "off the scale".
|
|
460
|
+
*
|
|
461
|
+
* @param {Array<{type: string, value?: number}>} tokens
|
|
462
|
+
* @returns {number | null}
|
|
463
|
+
*/
|
|
464
|
+
const foldArithmetic = (tokens) => {
|
|
465
|
+
let i = 0;
|
|
466
|
+
const bad = () => {
|
|
467
|
+
throw new Error("unmeasurable");
|
|
468
|
+
};
|
|
469
|
+
const primary = () => {
|
|
470
|
+
const token = tokens[i++];
|
|
471
|
+
if (!token) bad();
|
|
472
|
+
if (token.type === "number") return token.value;
|
|
473
|
+
if (token.type === "(") {
|
|
474
|
+
const value = expression();
|
|
475
|
+
if (tokens[i++]?.type !== ")") bad();
|
|
476
|
+
return value;
|
|
477
|
+
}
|
|
478
|
+
return bad();
|
|
479
|
+
};
|
|
480
|
+
const unary = () => {
|
|
481
|
+
if (tokens[i]?.type === "-") {
|
|
482
|
+
i++;
|
|
483
|
+
return -unary();
|
|
484
|
+
}
|
|
485
|
+
if (tokens[i]?.type === "+") {
|
|
486
|
+
i++;
|
|
487
|
+
return unary();
|
|
488
|
+
}
|
|
489
|
+
return primary();
|
|
490
|
+
};
|
|
491
|
+
const term = () => {
|
|
492
|
+
let value = unary();
|
|
493
|
+
while (["*", "/", "%"].includes(tokens[i]?.type)) {
|
|
494
|
+
const op = tokens[i++].type;
|
|
495
|
+
const right = unary();
|
|
496
|
+
if (op === "*") value *= right;
|
|
497
|
+
else if (right === 0) bad();
|
|
498
|
+
else value = op === "/" ? value / right : value % right;
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
};
|
|
502
|
+
const expression = () => {
|
|
503
|
+
let value = term();
|
|
504
|
+
while (["+", "-"].includes(tokens[i]?.type)) {
|
|
505
|
+
const op = tokens[i++].type;
|
|
506
|
+
value = op === "+" ? value + term() : value - term();
|
|
507
|
+
}
|
|
508
|
+
return value;
|
|
509
|
+
};
|
|
510
|
+
try {
|
|
511
|
+
if (!tokens.length) return null;
|
|
512
|
+
const value = expression();
|
|
513
|
+
if (i !== tokens.length || !Number.isFinite(value)) return null;
|
|
514
|
+
return value;
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The numbers a prop value provably paints, and whether the pass can say at all.
|
|
522
|
+
*
|
|
523
|
+
* A value is measured when it is a literal (`24`), arithmetic over literals
|
|
524
|
+
* (`8 * 3`), a quoted pixel shorthand (`"12px 20px"`), or any other literal
|
|
525
|
+
* string (a preset name has no numbers and is therefore on no scale to break).
|
|
526
|
+
* It is unmeasured when its value depends on an identifier, a ternary, a call or
|
|
527
|
+
* a member access — `size: base * 1.5`, `sz.h - 8`, `padding: size > 20 ? … : 0`.
|
|
528
|
+
* The distinction is the point: a lexical scan reads the `1.5` inside
|
|
529
|
+
* `base * 1.5` as the size and reports a value the screen never renders.
|
|
530
|
+
*
|
|
531
|
+
* @param {string} value raw prop value
|
|
532
|
+
* @returns {{measured: boolean, numbers: number[]}}
|
|
533
|
+
*/
|
|
534
|
+
export const measuredNumbers = (value) => {
|
|
535
|
+
const text = value.trim();
|
|
536
|
+
if (!text) return { measured: true, numbers: [] };
|
|
537
|
+
const quote = text[0];
|
|
538
|
+
if (isQuote(quote)) {
|
|
539
|
+
const end = skipString(text, 0);
|
|
540
|
+
if (end !== text.length) return { measured: false, numbers: [] };
|
|
541
|
+
const body = text.slice(1, end - 1);
|
|
542
|
+
if (/\\|\$\{/.test(body)) return { measured: false, numbers: [] };
|
|
543
|
+
if (/\b(?:var|calc|clamp|min|max|rgb|rgba|hsl|hsla)\s*\(|%/.test(body)) {
|
|
544
|
+
return { measured: false, numbers: [] };
|
|
545
|
+
}
|
|
546
|
+
const numbers = [...body.matchAll(/-?\d+(?:\.\d+)?px|\b0\b/g)].map((match) =>
|
|
547
|
+
match[0].endsWith("px") ? Number(match[0].slice(0, -2)) : 0,
|
|
548
|
+
);
|
|
549
|
+
return { measured: true, numbers };
|
|
550
|
+
}
|
|
551
|
+
const tokens = arithmeticTokens(text);
|
|
552
|
+
if (!tokens) return { measured: false, numbers: [] };
|
|
553
|
+
const folded = foldArithmetic(tokens);
|
|
554
|
+
if (folded === null) return { measured: false, numbers: [] };
|
|
555
|
+
return { measured: true, numbers: [folded] };
|
|
556
|
+
};
|
|
557
|
+
|
|
377
558
|
/**
|
|
378
559
|
* `#rgb`/`#rrggbb`/`#rrggbbaa` or a functional color typed as a literal.
|
|
379
560
|
*
|
|
@@ -654,6 +835,68 @@ const finding = (ctx, line, code, message, detail) => ({
|
|
|
654
835
|
* @param {object} ctx resolved package data
|
|
655
836
|
* @returns {object[]} findings
|
|
656
837
|
*/
|
|
838
|
+
/**
|
|
839
|
+
* Is the `{` at `brace` a function body? Looks back over the parameter list and
|
|
840
|
+
* asks what closes it: `function`, an arrow, or a method name. Control blocks
|
|
841
|
+
* (`if`, `for`, `switch`, `catch`, `try`, `else`) and object literals — including
|
|
842
|
+
* a widget's own `Widget({ … })` — are not function bodies.
|
|
843
|
+
*
|
|
844
|
+
* @param {string} code
|
|
845
|
+
* @param {number} brace index of `{`
|
|
846
|
+
* @returns {boolean}
|
|
847
|
+
*/
|
|
848
|
+
const opensFunction = (code, brace) => {
|
|
849
|
+
let i = brace - 1;
|
|
850
|
+
let depth = 0;
|
|
851
|
+
while (i >= 0) {
|
|
852
|
+
const ch = code[i];
|
|
853
|
+
if (ch === ")" || ch === "]") depth++;
|
|
854
|
+
else if (ch === "(" || ch === "[") {
|
|
855
|
+
if (depth === 0) break;
|
|
856
|
+
depth--;
|
|
857
|
+
} else if (depth === 0 && (ch === ";" || ch === "{" || ch === "}" || ch === "\n")) {
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
i--;
|
|
861
|
+
}
|
|
862
|
+
const head = code.slice(i + 1, brace).trim();
|
|
863
|
+
if (/^(?:async\s+)?function\b/.test(head)) return true;
|
|
864
|
+
if (/(?:^|[\s;}])(?:async\s+)?(?:\([^]*?\)|[A-Za-z_$][\w$]*)\s*=>$/.test(head)) return true;
|
|
865
|
+
const method = /^([A-Za-z_$][\w$]*)\s*\([^]*\)$/.exec(head);
|
|
866
|
+
return Boolean(
|
|
867
|
+
method &&
|
|
868
|
+
!["if", "for", "while", "switch", "catch", "with", "do", "else", "try"].includes(
|
|
869
|
+
method[1],
|
|
870
|
+
),
|
|
871
|
+
);
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* The brace range of every function body in a file.
|
|
876
|
+
*
|
|
877
|
+
* @param {string} code
|
|
878
|
+
* @returns {Array<[number, number]>} `[open, close]` pairs
|
|
879
|
+
*/
|
|
880
|
+
export const functionBodies = (code) => {
|
|
881
|
+
const bodies = [];
|
|
882
|
+
const open = [];
|
|
883
|
+
for (let i = 0; i < code.length; i++) {
|
|
884
|
+
const ch = code[i];
|
|
885
|
+
if (isQuote(ch)) {
|
|
886
|
+
i = skipString(code, i) - 1;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (ch === "{") {
|
|
890
|
+
open.push(i);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (ch !== "}") continue;
|
|
894
|
+
const start = open.pop();
|
|
895
|
+
if (start !== undefined && opensFunction(code, start)) bodies.push([start, i]);
|
|
896
|
+
}
|
|
897
|
+
return bodies;
|
|
898
|
+
};
|
|
899
|
+
|
|
657
900
|
const checkFile = (file, ctx) => {
|
|
658
901
|
const findings = [];
|
|
659
902
|
const src = fs.readFileSync(file, "utf8");
|
|
@@ -675,6 +918,7 @@ const checkFile = (file, ctx) => {
|
|
|
675
918
|
}
|
|
676
919
|
return { ...call, props };
|
|
677
920
|
});
|
|
921
|
+
const bodies = functionBodies(code);
|
|
678
922
|
|
|
679
923
|
/**
|
|
680
924
|
* The nearest enclosing call that says it paints a plate. Its props are handed
|
|
@@ -730,23 +974,27 @@ const checkFile = (file, ctx) => {
|
|
|
730
974
|
);
|
|
731
975
|
};
|
|
732
976
|
|
|
733
|
-
const namedByTooltip = (call) =>
|
|
734
|
-
enclosing(call).some((other) => other.name === "Tooltip" && other.props.has("text"));
|
|
735
|
-
|
|
736
977
|
/**
|
|
737
978
|
* A glyph control that carries its own accessible name is named, even when this
|
|
738
979
|
* pass cannot see the wrapper: an app that builds the control inside a helper
|
|
739
|
-
* and
|
|
740
|
-
* call site, and a lexical scan stops at the helper's own braces. `aria-*` is
|
|
741
|
-
* routed passthrough (translateProps.js:948), so the name really does reach the
|
|
980
|
+
* and names the *result* at the call site has a `child: iconButton("share")` at
|
|
981
|
+
* the call site, and a lexical scan stops at the helper's own braces. `aria-*` is
|
|
982
|
+
* a routed passthrough (translateProps.js:948), so the name really does reach the
|
|
742
983
|
* DOM — which is the sentence the finding asks for.
|
|
984
|
+
*
|
|
985
|
+
* A `Tooltip` is deliberately not on this list. It points `aria-describedby` at
|
|
986
|
+
* the bubble, and a description is not a name: a screen reader still announces
|
|
987
|
+
* "button" with no word for what it does.
|
|
743
988
|
*/
|
|
744
989
|
const namedByAria = (call) =>
|
|
745
990
|
[...call.props.keys()].some((key) => /^aria-(label|labelledby)$/i.test(key));
|
|
746
991
|
|
|
747
|
-
const isFilled = (call) =>
|
|
748
|
-
call.name
|
|
749
|
-
|
|
992
|
+
const isFilled = (call) => {
|
|
993
|
+
if (call.name !== "Button") return false;
|
|
994
|
+
const variant = call.props.get("variant")?.value.trim();
|
|
995
|
+
if (variant === undefined) return true;
|
|
996
|
+
return /^(['"`])filled\1$/.test(variant);
|
|
997
|
+
};
|
|
750
998
|
|
|
751
999
|
/** A width that caps the block: a number or a percentage below full bleed. */
|
|
752
1000
|
const capsWidth = (call) => {
|
|
@@ -770,7 +1018,8 @@ const checkFile = (file, ctx) => {
|
|
|
770
1018
|
if (SKIP_VALUE_KEYS.has(key)) continue;
|
|
771
1019
|
const kind = ctx.propKinds.get(key);
|
|
772
1020
|
const valueLine = lineOf(offsets, entry.valueStart);
|
|
773
|
-
const
|
|
1021
|
+
const measured = measuredNumbers(entry.value);
|
|
1022
|
+
const numbers = measured.numbers;
|
|
774
1023
|
|
|
775
1024
|
if (kind === "color" || COLOR_FALLBACK_KEYS.has(key)) {
|
|
776
1025
|
const literal = colorLiteralIn(entry.value);
|
|
@@ -793,45 +1042,35 @@ const checkFile = (file, ctx) => {
|
|
|
793
1042
|
|
|
794
1043
|
if (kind === "spacing") {
|
|
795
1044
|
for (const n of numbers) {
|
|
796
|
-
if (SPACING_SCALE.has(Math.abs(n
|
|
1045
|
+
if (SPACING_SCALE.has(Math.abs(n))) continue;
|
|
797
1046
|
push(
|
|
798
1047
|
valueLine,
|
|
799
1048
|
"OFF_SCALE_SPACING",
|
|
800
|
-
`${call.name}.${key} = ${n
|
|
1049
|
+
`${call.name}.${key} = ${n} is not on the spacing scale.`,
|
|
801
1050
|
"Use 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96. The scale is what makes two screens look like one app.",
|
|
802
1051
|
);
|
|
803
1052
|
}
|
|
804
1053
|
// A CSS shorthand still has to obey the scale: `padding: '12px 20px'` is
|
|
805
|
-
// the same mistake with a unit glued on
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
}
|
|
1054
|
+
// the same mistake with a unit glued on, and `measuredNumbers` already
|
|
1055
|
+
// read it — an unmeasurable one (`calc`, `var`, a percentage) reports no
|
|
1056
|
+
// numbers instead of the literals it happens to contain.
|
|
818
1057
|
} else if (kind === "radius") {
|
|
819
1058
|
for (const n of numbers) {
|
|
820
|
-
if (RADIUS_SCALE.has(Math.abs(n
|
|
1059
|
+
if (RADIUS_SCALE.has(Math.abs(n))) continue;
|
|
821
1060
|
push(
|
|
822
1061
|
valueLine,
|
|
823
1062
|
"OFF_SCALE_RADIUS",
|
|
824
|
-
`${call.name}.${key} = ${n
|
|
825
|
-
"Use 4 · 8 · 12 · 16 · 999 (a pill). A radius between them is a shape no other card in this app has.",
|
|
1063
|
+
`${call.name}.${key} = ${n} is not a scale radius.`,
|
|
1064
|
+
"Use 4 · 8 · 12 · 16 · 20 · 24 · 999 (a pill). A radius between them is a shape no other card in this app has.",
|
|
826
1065
|
);
|
|
827
1066
|
}
|
|
828
1067
|
} else if (kind === "type" && (key === "fontSize" || call.name === "Text")) {
|
|
829
1068
|
for (const n of numbers) {
|
|
830
|
-
if (TYPE_SCALE.has(Math.abs(n
|
|
1069
|
+
if (TYPE_SCALE.has(Math.abs(n))) continue;
|
|
831
1070
|
push(
|
|
832
1071
|
valueLine,
|
|
833
1072
|
"OFF_SCALE_TYPE",
|
|
834
|
-
`${call.name}.${key} = ${n
|
|
1073
|
+
`${call.name}.${key} = ${n} is not a scale type size.`,
|
|
835
1074
|
"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
1075
|
);
|
|
837
1076
|
}
|
|
@@ -839,23 +1078,36 @@ const checkFile = (file, ctx) => {
|
|
|
839
1078
|
|
|
840
1079
|
if (tappable && (key === "height" || key === "minHeight")) {
|
|
841
1080
|
for (const n of numbers) {
|
|
842
|
-
if (n
|
|
1081
|
+
if (n <= 0 || n >= TAP_MINIMUM) continue;
|
|
843
1082
|
push(
|
|
844
1083
|
valueLine,
|
|
845
1084
|
"SMALL_TAP_TARGET",
|
|
846
|
-
`${call.name} is pressable but only ${n
|
|
1085
|
+
`${call.name} is pressable but only ${n}px tall.`,
|
|
847
1086
|
`Fitts: a finger needs ${TAP_MINIMUM}x${TAP_MINIMUM}. Get there with \`height\`, not with padding.`,
|
|
848
1087
|
);
|
|
849
1088
|
}
|
|
850
1089
|
}
|
|
851
1090
|
|
|
852
1091
|
if (tappable && ICON_WIDGETS.includes(call.name) && key === "size") {
|
|
1092
|
+
// A glyph's target is its box, not its font size: `size: 16, padding: 12`
|
|
1093
|
+
// is 40x40 and legal, while `size: 16` alone is the glyph and nothing else.
|
|
1094
|
+
// A padding this pass cannot resolve makes the target unmeasured.
|
|
1095
|
+
const padded = props.has("paddingVertical")
|
|
1096
|
+
? measuredNumbers(props.get("paddingVertical").value)
|
|
1097
|
+
: props.has("padding")
|
|
1098
|
+
? measuredNumbers(props.get("padding").value)
|
|
1099
|
+
: { measured: true, numbers: [0] };
|
|
1100
|
+
const vertical = padded.measured ? Math.abs(padded.numbers[0] ?? 0) : null;
|
|
853
1101
|
for (const n of numbers) {
|
|
854
|
-
if (
|
|
1102
|
+
if (vertical === null) break;
|
|
1103
|
+
const target = n + vertical * 2;
|
|
1104
|
+
if (target >= TAP_MINIMUM || props.has("height") || props.has("minHeight")) break;
|
|
855
1105
|
push(
|
|
856
1106
|
valueLine,
|
|
857
1107
|
"SMALL_TAP_TARGET",
|
|
858
|
-
`${call.name} is pressable at size ${n
|
|
1108
|
+
`${call.name} is pressable at size ${n}${
|
|
1109
|
+
vertical ? ` with ${vertical} of padding` : ""
|
|
1110
|
+
}, so its whole target is ${target}px.`,
|
|
859
1111
|
`Wrap it in the ${TAP_MINIMUM}px box it needs, or use \`Button({ icon, onPress })\` and keep the glyph at 20.`,
|
|
860
1112
|
);
|
|
861
1113
|
}
|
|
@@ -915,14 +1167,13 @@ const checkFile = (file, ctx) => {
|
|
|
915
1167
|
ICON_WIDGETS.includes(call.name) &&
|
|
916
1168
|
tappable &&
|
|
917
1169
|
!labeledBySibling(call) &&
|
|
918
|
-
!namedByTooltip(call) &&
|
|
919
1170
|
!namedByAria(call)
|
|
920
1171
|
) {
|
|
921
1172
|
push(
|
|
922
1173
|
line,
|
|
923
1174
|
"ICON_ONLY_CONTROL",
|
|
924
1175
|
`${call.name} is pressable and carries no name.`,
|
|
925
|
-
|
|
1176
|
+
'A glyph with no words is a guess, and a screen reader says nothing at all. A `Tooltip` is a description (`aria-describedby`), not a name: put a `Text` beside the control or name it with `"aria-label": "Share"`.',
|
|
926
1177
|
);
|
|
927
1178
|
}
|
|
928
1179
|
if (
|
|
@@ -930,14 +1181,13 @@ const checkFile = (file, ctx) => {
|
|
|
930
1181
|
["icon", "iconLeft", "iconRight"].some((key) => props.has(key)) &&
|
|
931
1182
|
!props.has("text") &&
|
|
932
1183
|
!labeledBySibling(call) &&
|
|
933
|
-
!namedByTooltip(call) &&
|
|
934
1184
|
!namedByAria(call)
|
|
935
1185
|
) {
|
|
936
1186
|
push(
|
|
937
1187
|
line,
|
|
938
1188
|
"ICON_ONLY_CONTROL",
|
|
939
1189
|
"Button shows an icon and no label.",
|
|
940
|
-
|
|
1190
|
+
'Give it `text`, or name it with `"aria-label": "Share"`. A tooltip explains the control, it does not name it.',
|
|
941
1191
|
);
|
|
942
1192
|
}
|
|
943
1193
|
|
|
@@ -1035,13 +1285,35 @@ const checkFile = (file, ctx) => {
|
|
|
1035
1285
|
}
|
|
1036
1286
|
}
|
|
1037
1287
|
|
|
1038
|
-
// Rule 04 — one
|
|
1288
|
+
// Rule 04 — one view, one primary action. Emphasis is a budget, not a style.
|
|
1289
|
+
// The view is the function body a screen renders, not the file: a components
|
|
1290
|
+
// module exports several screens, and two buttons in two components are never
|
|
1291
|
+
// on the same screen. A `variant` this pass cannot resolve counts as neither,
|
|
1292
|
+
// since the button may be outlined at runtime.
|
|
1039
1293
|
const primaries = calls.filter(isFilled);
|
|
1040
|
-
|
|
1294
|
+
const scopeOf = (call) => {
|
|
1295
|
+
let scope = null;
|
|
1296
|
+
for (const [start, end] of bodies) {
|
|
1297
|
+
if (call.open > start && call.close < end && (scope === null || start > scope)) {
|
|
1298
|
+
scope = start;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (scope !== null) return `fn:${scope}`;
|
|
1302
|
+
const root = enclosing(call).at(-1);
|
|
1303
|
+
return root ? `root:${root.open}` : "file";
|
|
1304
|
+
};
|
|
1305
|
+
const views = new Map();
|
|
1306
|
+
for (const call of primaries) {
|
|
1307
|
+
const scope = scopeOf(call);
|
|
1308
|
+
if (!views.has(scope)) views.set(scope, []);
|
|
1309
|
+
views.get(scope).push(call);
|
|
1310
|
+
}
|
|
1311
|
+
for (const group of views.values()) {
|
|
1312
|
+
if (group.length < 2) continue;
|
|
1041
1313
|
push(
|
|
1042
|
-
|
|
1314
|
+
lineOf(offsets, group[0].open),
|
|
1043
1315
|
"MULTIPLE_PRIMARY_ACTIONS",
|
|
1044
|
-
`${
|
|
1316
|
+
`${group.length} filled buttons in this view (lines ${group
|
|
1045
1317
|
.map((call) => lineOf(offsets, call.open))
|
|
1046
1318
|
.join(", ")}).`,
|
|
1047
1319
|
"Everything filled is competing with everything else. Keep one filled, make the next `variant: \"outlined\"`, and drop the rest to `variant: \"text\"`.",
|
|
@@ -1319,7 +1591,15 @@ export const checkUi = async (argv = [], io = {}) => {
|
|
|
1319
1591
|
|
|
1320
1592
|
if (asJson) {
|
|
1321
1593
|
print(
|
|
1322
|
-
JSON.stringify(
|
|
1594
|
+
JSON.stringify(
|
|
1595
|
+
{
|
|
1596
|
+
package: api.pkg.version,
|
|
1597
|
+
scanned: files.length,
|
|
1598
|
+
findings: findings.map((item) => ({ rule: CODE_RULE[item.code] || null, ...item })),
|
|
1599
|
+
},
|
|
1600
|
+
null,
|
|
1601
|
+
2,
|
|
1602
|
+
),
|
|
1323
1603
|
);
|
|
1324
1604
|
} else {
|
|
1325
1605
|
print(
|
|
@@ -1338,10 +1618,11 @@ export const checkUi = async (argv = [], io = {}) => {
|
|
|
1338
1618
|
byCode.get(item.code).push(item);
|
|
1339
1619
|
}
|
|
1340
1620
|
for (const [code, list] of byCode) {
|
|
1621
|
+
const rule = CODE_RULE[code];
|
|
1341
1622
|
print(
|
|
1342
1623
|
section(
|
|
1343
1624
|
"•",
|
|
1344
|
-
`${code} — ${list.length} finding(s)`,
|
|
1625
|
+
`${code}${rule ? ` — rule ${rule}` : ""} — ${list.length} finding(s)`,
|
|
1345
1626
|
ERROR_CODES.has(code) ? "#f87171" : INFO_CODES.has(code) ? "#94a3b8" : "#fbbf24",
|
|
1346
1627
|
),
|
|
1347
1628
|
);
|
package/bin/commands/create.js
CHANGED
|
@@ -19,8 +19,16 @@ import * as templates from "../utils/templates.js";
|
|
|
19
19
|
import { parseGithubRepo } from "../utils/bundleConfig.js";
|
|
20
20
|
import { checkApi } from "../commands/checkApi.js";
|
|
21
21
|
import { unpack } from "../commands/unpack.js";
|
|
22
|
+
import { skillNames, readSkill } from "../utils/skills.js";
|
|
22
23
|
import { c, banner, gradient } from "../utils/colors.js";
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Skills that every screen needs, whatever the task. Their Cursor twin rule loads
|
|
27
|
+
* with the file instead of waiting to be picked by description; the rest are
|
|
28
|
+
* read when their description matches what the agent is about to do.
|
|
29
|
+
*/
|
|
30
|
+
const ALWAYS_ON_SKILLS = new Set(["fletbox-rules", "fletbox-design"]);
|
|
31
|
+
|
|
24
32
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
33
|
const __dirname = path.dirname(__filename);
|
|
26
34
|
|
|
@@ -216,8 +224,8 @@ export const createProject = async (projectName, rawArgs = []) => {
|
|
|
216
224
|
".fletbox/api",
|
|
217
225
|
".fletbox/memory/notes",
|
|
218
226
|
".claude",
|
|
219
|
-
".claude/skills
|
|
220
|
-
".qoder/skills
|
|
227
|
+
".claude/skills",
|
|
228
|
+
".qoder/skills",
|
|
221
229
|
".cursor/rules",
|
|
222
230
|
];
|
|
223
231
|
const extraDirs =
|
|
@@ -241,6 +249,10 @@ export const createProject = async (projectName, rawArgs = []) => {
|
|
|
241
249
|
path.join(projectPath, "package.json"),
|
|
242
250
|
templates.packageJson(finalName),
|
|
243
251
|
);
|
|
252
|
+
createFile(
|
|
253
|
+
path.join(projectPath, "jsconfig.json"),
|
|
254
|
+
templates.jsconfig(),
|
|
255
|
+
);
|
|
244
256
|
createFile(
|
|
245
257
|
path.join(projectPath, "fletbox.config.json"),
|
|
246
258
|
templates.fletboxConfig(otaUrl || undefined),
|
|
@@ -437,23 +449,36 @@ export const createProject = async (projectName, rawArgs = []) => {
|
|
|
437
449
|
templates.cursorRules(),
|
|
438
450
|
);
|
|
439
451
|
|
|
440
|
-
// The
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
452
|
+
// The agent skills: `skills/` at the package root is the only source, one
|
|
453
|
+
// kebab-case folder per subject (rules, design, motion, memory). The app gets
|
|
454
|
+
// the same text twice over: once as `skills/` at its own root — the folder any
|
|
455
|
+
// assistant or person reads first, and the one AGENTS.md §0 indexes — and once
|
|
456
|
+
// mirrored into `.claude/skills/` and `.qoder/skills/`, because Claude Code and
|
|
457
|
+
// Qoder load a skill by frontmatter description from those two roots and offer it
|
|
458
|
+
// without being told. Cursor has no skills folder, so each skill also ships as a
|
|
459
|
+
// pointer rule in `.cursor/rules/`. One text, three loaders, never three edits.
|
|
460
|
+
const skillList = skillNames();
|
|
461
|
+
if (skillList.length === 0) {
|
|
462
|
+
console.log(
|
|
463
|
+
c(
|
|
464
|
+
"yellow",
|
|
465
|
+
"⚠️ This flet-box package ships no skills/ folder — the app will have no design or motion contract.",
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
for (const name of skillList) {
|
|
470
|
+
const text = readSkill(name);
|
|
471
|
+
createFile(path.join(projectPath, "skills", name, "SKILL.md"), text);
|
|
472
|
+
createFile(
|
|
473
|
+
path.join(projectPath, ".claude/skills", name, "SKILL.md"),
|
|
474
|
+
text,
|
|
475
|
+
);
|
|
476
|
+
createFile(path.join(projectPath, ".qoder/skills", name, "SKILL.md"), text);
|
|
477
|
+
createFile(
|
|
478
|
+
path.join(projectPath, ".cursor/rules", `${name}.mdc`),
|
|
479
|
+
templates.skillCursorRule(name, { alwaysApply: ALWAYS_ON_SKILLS.has(name) }),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
457
482
|
createFile(
|
|
458
483
|
path.join(projectPath, "scripts/check-ui.mjs"),
|
|
459
484
|
templates.checkUiScript(finalName),
|
|
@@ -467,6 +492,10 @@ export const createProject = async (projectName, rawArgs = []) => {
|
|
|
467
492
|
path.join(projectPath, ".fletbox/memory/notes/baseline.md"),
|
|
468
493
|
templates.memoryBaselineMd(finalName, template, pkgVersion),
|
|
469
494
|
);
|
|
495
|
+
createFile(
|
|
496
|
+
path.join(projectPath, ".fletbox/memory/notes/current-status.md"),
|
|
497
|
+
templates.memoryStatusMd(finalName, template),
|
|
498
|
+
);
|
|
470
499
|
createFile(
|
|
471
500
|
path.join(projectPath, "scripts/check-app.mjs"),
|
|
472
501
|
templates.checkAppScript(finalName),
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
|
-
import { c,
|
|
13
|
+
import { c, gradient, rainbow, section } from "../utils/colors.js";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Returns the source code for a new FletBox screen module.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createBundle } from "./createBundle.js";
|
|
12
12
|
import { runDevServer } from "./runServer.js";
|
|
13
|
-
import { c, banner,
|
|
13
|
+
import { c, banner, dot } from "../utils/colors.js";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Builds the project bundle and serves it with a static dev server.
|
package/bin/utils/colors.js
CHANGED
|
@@ -173,9 +173,6 @@ export const panel = (title, subtitle = "", { minW = 62 } = {}) => {
|
|
|
173
173
|
export const banner = (title, subtitle = "", opts = {}) =>
|
|
174
174
|
panel(title, subtitle, opts);
|
|
175
175
|
|
|
176
|
-
export const badge = (icon, text, color = "brightCyan") =>
|
|
177
|
-
` ${c(color, icon + " " + text)}`;
|
|
178
|
-
|
|
179
176
|
// Status dot (green by default, red/amber for failures/warnings)
|
|
180
177
|
export const dot = (color = "#34d399") => hex("●", color);
|
|
181
178
|
|
|
@@ -207,9 +204,4 @@ export const setupCtrlC = () => {
|
|
|
207
204
|
console.log(`\n\n${rainbow("👋 Bye!")}\n`);
|
|
208
205
|
process.exit(0);
|
|
209
206
|
});
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
// Show help message for Ctrl+C
|
|
213
|
-
export const showExitHint = () => {
|
|
214
|
-
console.log(`${c("gray", "Press Ctrl+C to exit")}`);
|
|
215
207
|
};
|