flet-box 1.1.0 → 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.
Files changed (130) hide show
  1. package/bin/commands/checkUi.js +276 -45
  2. package/bin/commands/create.js +4 -0
  3. package/bin/commands/createScreen.js +1 -1
  4. package/bin/commands/runBundle.js +1 -1
  5. package/bin/utils/colors.js +0 -8
  6. package/bin/utils/helpers.js +0 -5
  7. package/bin/utils/images/jpeg.js +2 -3
  8. package/bin/utils/templates.js +78 -73
  9. package/docs/README.md +2 -0
  10. package/docs/cli/README.md +18 -1
  11. package/docs/guides/README.md +5 -2
  12. package/docs/guides/accessibility.md +282 -0
  13. package/docs/guides/clean-ui.md +53 -8
  14. package/docs/guides/minification-and-protection.md +1 -1
  15. package/docs/guides/mobile-and-platforms.md +51 -0
  16. package/docs/guides/utilities.md +116 -4
  17. package/docs/guides/widget-structure.md +30 -0
  18. package/docs/tools/README.md +1 -1
  19. package/docs/tools/animation.md +134 -11
  20. package/docs/tools/device.md +75 -3
  21. package/docs/tools/text-and-time.md +116 -6
  22. package/docs/widget/Accordion.md +2 -0
  23. package/docs/widget/AnimatedBox.md +46 -20
  24. package/docs/widget/AnimatedText.md +13 -7
  25. package/docs/widget/Badge.md +1 -0
  26. package/docs/widget/COMMON_PROPS.md +26 -3
  27. package/docs/widget/Chart.md +1 -0
  28. package/docs/widget/Checkbox.md +7 -1
  29. package/docs/widget/CircularChart.md +2 -0
  30. package/docs/widget/Drawer.md +1 -0
  31. package/docs/widget/GridView.md +4 -1
  32. package/docs/widget/Input.md +2 -0
  33. package/docs/widget/InstallButton.md +1 -0
  34. package/docs/widget/Markdown.md +4 -0
  35. package/docs/widget/Rating.md +2 -0
  36. package/docs/widget/Slider.md +2 -0
  37. package/docs/widget/Stack.md +6 -5
  38. package/docs/widget/Stepper.md +2 -0
  39. package/docs/widget/Tooltip.md +3 -1
  40. package/package.json +3 -2
  41. package/skills/fletbox-design/SKILL.md +390 -14
  42. package/skills/fletbox-motion/SKILL.md +145 -47
  43. package/skills/fletbox-rules/SKILL.md +140 -1
  44. package/src/animations/AnimatedBox.js +321 -226
  45. package/src/animations/MatrixRain.js +7 -1
  46. package/src/animations/ParallaxBox.js +13 -10
  47. package/src/animations/animate.js +269 -106
  48. package/src/animations/color.js +91 -0
  49. package/src/animations/easings.js +275 -0
  50. package/src/animations/index.js +21 -0
  51. package/src/animations/motion.js +204 -0
  52. package/src/animations/transitions.js +327 -0
  53. package/src/core/runApp.js +0 -6
  54. package/src/helpers/boxEdges.js +69 -0
  55. package/src/helpers/html.js +39 -0
  56. package/src/index.d.ts +377 -25
  57. package/src/index.js +77 -0
  58. package/src/navigations/AdaptiveScaffold.js +6 -2
  59. package/src/navigations/AppBar.js +16 -7
  60. package/src/navigations/BottomNavigation.js +24 -1
  61. package/src/navigations/CollapsibleSideBar.js +0 -1
  62. package/src/navigations/Drawer.js +32 -1
  63. package/src/navigations/DrawerItem.js +1 -1
  64. package/src/navigations/Router.js +0 -1
  65. package/src/navigations/Scaffold.js +9 -3
  66. package/src/navigations/Tabs.js +104 -31
  67. package/src/services/RamStore.js +1 -2
  68. package/src/tools/breakpoints.js +113 -0
  69. package/src/tools/createList.js +0 -2
  70. package/src/tools/fonts.js +99 -0
  71. package/src/tools/index.js +74 -1
  72. package/src/tools/margin.js +5 -63
  73. package/src/tools/padding.js +5 -63
  74. package/src/tools/print.js +0 -8
  75. package/src/tools/scale.js +9 -2
  76. package/src/tools/time.js +56 -15
  77. package/src/tools/useState.js +1 -54
  78. package/src/utils/fieldFont.js +77 -0
  79. package/src/utils/focusRing.js +73 -0
  80. package/src/utils/focusScope.js +113 -0
  81. package/src/utils/markdownParser.js +7 -9
  82. package/src/utils/readableInk.js +7 -1
  83. package/src/utils/stackLayout.js +55 -0
  84. package/src/utils/styleInjector.js +25 -0
  85. package/src/utils/syntaxHighlight.js +1 -17
  86. package/src/utils/themes.js +15 -0
  87. package/src/utils/visualEffects.js +49 -47
  88. package/src/widget-factory/createWidget.js +16 -3
  89. package/src/widget-factory/effects.js +76 -3
  90. package/src/widget-factory/reactiveValue.js +0 -7
  91. package/src/widget-factory/tools.js +26 -143
  92. package/src/widget-factory/translateProps.js +5 -0
  93. package/src/widget-factory/widgetFactory.js +1 -1
  94. package/src/widgets/Accordion.js +70 -12
  95. package/src/widgets/AdMob.js +1 -1
  96. package/src/widgets/AdSense.js +1 -1
  97. package/src/widgets/AlertDialog.js +11 -9
  98. package/src/widgets/Avatar.js +10 -1
  99. package/src/widgets/Badge.js +7 -1
  100. package/src/widgets/BottomSheet.js +39 -5
  101. package/src/widgets/Chart.js +41 -9
  102. package/src/widgets/Checkbox.js +59 -9
  103. package/src/widgets/Chip.js +13 -1
  104. package/src/widgets/CircularBar.js +17 -5
  105. package/src/widgets/CircularChart.js +24 -2
  106. package/src/widgets/CodeViewer.js +5 -3
  107. package/src/widgets/Dropdown.js +223 -20
  108. package/src/widgets/GridView.js +17 -1
  109. package/src/widgets/Icon.js +21 -0
  110. package/src/widgets/Input.js +75 -44
  111. package/src/widgets/InstallButton.js +8 -2
  112. package/src/widgets/ListTile.js +6 -5
  113. package/src/widgets/ListView.js +21 -7
  114. package/src/widgets/Markdown.js +25 -44
  115. package/src/widgets/Modal.js +43 -6
  116. package/src/widgets/Pagination.js +35 -3
  117. package/src/widgets/ProgressBar.js +18 -0
  118. package/src/widgets/Radio.js +7 -0
  119. package/src/widgets/Rating.js +104 -16
  120. package/src/widgets/Skeleton.js +9 -5
  121. package/src/widgets/Slider.js +87 -7
  122. package/src/widgets/SnackBar.js +30 -1
  123. package/src/widgets/Stack.js +25 -13
  124. package/src/widgets/Stepper.js +112 -64
  125. package/src/widgets/Switch.js +10 -1
  126. package/src/widgets/Tooltip.js +93 -10
  127. package/src/widgets/TreeView.js +5 -5
  128. package/src/create-index.sh +0 -237
  129. package/src/widget-factory/pixTool.js +0 -40
  130. package/src/widget-factory/stringTool.js +0 -26
@@ -120,8 +120,9 @@ export const INFO_CODES = new Set([
120
120
  * Which design rule each code proves, and the number the finding prints.
121
121
  *
122
122
  * The gate is the machine half of a contract whose human half is
123
- * `skills/fletbox-design/SKILL.md`: 25 numbered rules, read by the agent that
124
- * builds the screen. A finding that says `OFF_SCALE_SPACING` tells the reader the
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
125
126
  * value is wrong; a finding that says `OFF_SCALE_SPACING — rule 07` tells them to
126
127
  * open rule 07 and learn *why*, which is the only way the next screen is right
127
128
  * without being told twice. `scripts/check-skills.mjs` fails if a code is missing
@@ -390,7 +391,9 @@ export const entriesOf = (code, open, close) => {
390
391
  };
391
392
 
392
393
  /**
393
- * 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.
394
397
  *
395
398
  * @param {string} value
396
399
  * @returns {Array<{number: number, offset: number}>}
@@ -415,6 +418,143 @@ export const numbersIn = (value) => {
415
418
  return found;
416
419
  };
417
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
+
418
558
  /**
419
559
  * `#rgb`/`#rrggbb`/`#rrggbbaa` or a functional color typed as a literal.
420
560
  *
@@ -695,6 +835,68 @@ const finding = (ctx, line, code, message, detail) => ({
695
835
  * @param {object} ctx resolved package data
696
836
  * @returns {object[]} findings
697
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
+
698
900
  const checkFile = (file, ctx) => {
699
901
  const findings = [];
700
902
  const src = fs.readFileSync(file, "utf8");
@@ -716,6 +918,7 @@ const checkFile = (file, ctx) => {
716
918
  }
717
919
  return { ...call, props };
718
920
  });
921
+ const bodies = functionBodies(code);
719
922
 
720
923
  /**
721
924
  * The nearest enclosing call that says it paints a plate. Its props are handed
@@ -771,23 +974,27 @@ const checkFile = (file, ctx) => {
771
974
  );
772
975
  };
773
976
 
774
- const namedByTooltip = (call) =>
775
- enclosing(call).some((other) => other.name === "Tooltip" && other.props.has("text"));
776
-
777
977
  /**
778
978
  * A glyph control that carries its own accessible name is named, even when this
779
979
  * pass cannot see the wrapper: an app that builds the control inside a helper
780
- * and wraps the *result* in a Tooltip has a `child: iconButton("share")` at the
781
- * call site, and a lexical scan stops at the helper's own braces. `aria-*` is a
782
- * 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
783
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.
784
988
  */
785
989
  const namedByAria = (call) =>
786
990
  [...call.props.keys()].some((key) => /^aria-(label|labelledby)$/i.test(key));
787
991
 
788
- const isFilled = (call) =>
789
- call.name === "Button" &&
790
- !/['"`](outlined|text)['"`]/.test(call.props.get("variant")?.value ?? "");
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
+ };
791
998
 
792
999
  /** A width that caps the block: a number or a percentage below full bleed. */
793
1000
  const capsWidth = (call) => {
@@ -811,7 +1018,8 @@ const checkFile = (file, ctx) => {
811
1018
  if (SKIP_VALUE_KEYS.has(key)) continue;
812
1019
  const kind = ctx.propKinds.get(key);
813
1020
  const valueLine = lineOf(offsets, entry.valueStart);
814
- const numbers = numbersIn(entry.value);
1021
+ const measured = measuredNumbers(entry.value);
1022
+ const numbers = measured.numbers;
815
1023
 
816
1024
  if (kind === "color" || COLOR_FALLBACK_KEYS.has(key)) {
817
1025
  const literal = colorLiteralIn(entry.value);
@@ -834,45 +1042,35 @@ const checkFile = (file, ctx) => {
834
1042
 
835
1043
  if (kind === "spacing") {
836
1044
  for (const n of numbers) {
837
- if (SPACING_SCALE.has(Math.abs(n.number))) continue;
1045
+ if (SPACING_SCALE.has(Math.abs(n))) continue;
838
1046
  push(
839
1047
  valueLine,
840
1048
  "OFF_SCALE_SPACING",
841
- `${call.name}.${key} = ${n.number} is not on the spacing scale.`,
1049
+ `${call.name}.${key} = ${n} is not on the spacing scale.`,
842
1050
  "Use 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96. The scale is what makes two screens look like one app.",
843
1051
  );
844
1052
  }
845
1053
  // A CSS shorthand still has to obey the scale: `padding: '12px 20px'` is
846
- // the same mistake with a unit glued on.
847
- if (!numbers.length) {
848
- for (const match of entry.value.matchAll(/\d+(?:\.\d+)?px/g)) {
849
- const value = Number(match[0].slice(0, -2));
850
- if (SPACING_SCALE.has(value)) continue;
851
- push(
852
- valueLine,
853
- "OFF_SCALE_SPACING",
854
- `${call.name}.${key} = '${match[0]}' is not on the spacing scale.`,
855
- "Use 4 · 8 · 12 · 16 · 18 · 24 · 32 · 40 · 48 · 64 · 96, or the plain number the factory prefers.",
856
- );
857
- }
858
- }
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.
859
1057
  } else if (kind === "radius") {
860
1058
  for (const n of numbers) {
861
- if (RADIUS_SCALE.has(Math.abs(n.number))) continue;
1059
+ if (RADIUS_SCALE.has(Math.abs(n))) continue;
862
1060
  push(
863
1061
  valueLine,
864
1062
  "OFF_SCALE_RADIUS",
865
- `${call.name}.${key} = ${n.number} is not a scale radius.`,
866
- "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.",
867
1065
  );
868
1066
  }
869
1067
  } else if (kind === "type" && (key === "fontSize" || call.name === "Text")) {
870
1068
  for (const n of numbers) {
871
- if (TYPE_SCALE.has(Math.abs(n.number))) continue;
1069
+ if (TYPE_SCALE.has(Math.abs(n))) continue;
872
1070
  push(
873
1071
  valueLine,
874
1072
  "OFF_SCALE_TYPE",
875
- `${call.name}.${key} = ${n.number} is not a scale type size.`,
1073
+ `${call.name}.${key} = ${n} is not a scale type size.`,
876
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.",
877
1075
  );
878
1076
  }
@@ -880,23 +1078,36 @@ const checkFile = (file, ctx) => {
880
1078
 
881
1079
  if (tappable && (key === "height" || key === "minHeight")) {
882
1080
  for (const n of numbers) {
883
- if (n.number <= 0 || n.number >= TAP_MINIMUM) continue;
1081
+ if (n <= 0 || n >= TAP_MINIMUM) continue;
884
1082
  push(
885
1083
  valueLine,
886
1084
  "SMALL_TAP_TARGET",
887
- `${call.name} is pressable but only ${n.number}px tall.`,
1085
+ `${call.name} is pressable but only ${n}px tall.`,
888
1086
  `Fitts: a finger needs ${TAP_MINIMUM}x${TAP_MINIMUM}. Get there with \`height\`, not with padding.`,
889
1087
  );
890
1088
  }
891
1089
  }
892
1090
 
893
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;
894
1101
  for (const n of numbers) {
895
- if (n.number >= TAP_MINIMUM || props.has("height")) continue;
1102
+ if (vertical === null) break;
1103
+ const target = n + vertical * 2;
1104
+ if (target >= TAP_MINIMUM || props.has("height") || props.has("minHeight")) break;
896
1105
  push(
897
1106
  valueLine,
898
1107
  "SMALL_TAP_TARGET",
899
- `${call.name} is pressable at size ${n.number}, so its whole target is ${n.number}px.`,
1108
+ `${call.name} is pressable at size ${n}${
1109
+ vertical ? ` with ${vertical} of padding` : ""
1110
+ }, so its whole target is ${target}px.`,
900
1111
  `Wrap it in the ${TAP_MINIMUM}px box it needs, or use \`Button({ icon, onPress })\` and keep the glyph at 20.`,
901
1112
  );
902
1113
  }
@@ -956,14 +1167,13 @@ const checkFile = (file, ctx) => {
956
1167
  ICON_WIDGETS.includes(call.name) &&
957
1168
  tappable &&
958
1169
  !labeledBySibling(call) &&
959
- !namedByTooltip(call) &&
960
1170
  !namedByAria(call)
961
1171
  ) {
962
1172
  push(
963
1173
  line,
964
1174
  "ICON_ONLY_CONTROL",
965
1175
  `${call.name} is pressable and carries no name.`,
966
- "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.",
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"`.',
967
1177
  );
968
1178
  }
969
1179
  if (
@@ -971,14 +1181,13 @@ const checkFile = (file, ctx) => {
971
1181
  ["icon", "iconLeft", "iconRight"].some((key) => props.has(key)) &&
972
1182
  !props.has("text") &&
973
1183
  !labeledBySibling(call) &&
974
- !namedByTooltip(call) &&
975
1184
  !namedByAria(call)
976
1185
  ) {
977
1186
  push(
978
1187
  line,
979
1188
  "ICON_ONLY_CONTROL",
980
1189
  "Button shows an icon and no label.",
981
- "Give it `text`, or wrap it in `Tooltip({ text, child })`. Every person reads an unnamed glyph slightly differently.",
1190
+ 'Give it `text`, or name it with `"aria-label": "Share"`. A tooltip explains the control, it does not name it.',
982
1191
  );
983
1192
  }
984
1193
 
@@ -1076,13 +1285,35 @@ const checkFile = (file, ctx) => {
1076
1285
  }
1077
1286
  }
1078
1287
 
1079
- // Rule 04 — one screen, one primary action. Emphasis is a budget, not a style.
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.
1080
1293
  const primaries = calls.filter(isFilled);
1081
- if (primaries.length > 1) {
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;
1082
1313
  push(
1083
- 1,
1314
+ lineOf(offsets, group[0].open),
1084
1315
  "MULTIPLE_PRIMARY_ACTIONS",
1085
- `${primaries.length} filled buttons in this file (lines ${primaries
1316
+ `${group.length} filled buttons in this view (lines ${group
1086
1317
  .map((call) => lineOf(offsets, call.open))
1087
1318
  .join(", ")}).`,
1088
1319
  "Everything filled is competing with everything else. Keep one filled, make the next `variant: \"outlined\"`, and drop the rest to `variant: \"text\"`.",
@@ -249,6 +249,10 @@ export const createProject = async (projectName, rawArgs = []) => {
249
249
  path.join(projectPath, "package.json"),
250
250
  templates.packageJson(finalName),
251
251
  );
252
+ createFile(
253
+ path.join(projectPath, "jsconfig.json"),
254
+ templates.jsconfig(),
255
+ );
252
256
  createFile(
253
257
  path.join(projectPath, "fletbox.config.json"),
254
258
  templates.fletboxConfig(otaUrl || undefined),
@@ -10,7 +10,7 @@
10
10
 
11
11
  import fs from "fs";
12
12
  import path from "path";
13
- import { c, banner, gradient, rainbow, section } from "../utils/colors.js";
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, gradient, dot } from "../utils/colors.js";
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.
@@ -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
  };
@@ -73,8 +73,3 @@ export const execCmd = (cmd) => {
73
73
  return false;
74
74
  }
75
75
  };
76
-
77
- // Fixed: Now returns actual package path
78
- export const getPackagePath = () => {
79
- return path.join(process.cwd(), "node_modules", "flet-box");
80
- };
@@ -338,7 +338,7 @@ export const decodeJpeg = (buf) => {
338
338
  const decodeScan = (scan, ctx) => {
339
339
  const { buf, components, width, height, maxH, maxV, block, scratch } = ctx;
340
340
  const { restartInterval, tables: { quant, dc, ac } } = scan;
341
- const specs = readScanHeader(scan.payload, components.length);
341
+ const specs = readScanHeader(scan.payload);
342
342
  if (specs.length !== components.length && specs.length !== 1) {
343
343
  throw new Error(`JPEG scan covers ${specs.length} of ${components.length} components, which is neither interleaved nor single-component`);
344
344
  }
@@ -558,11 +558,10 @@ const readDht = (payload, dc, ac) => {
558
558
  * scan.
559
559
  *
560
560
  * @param {Buffer} payload
561
- * @param {number} componentCount
562
561
  * @returns {{ id: number, tables: number }[]} Component specifiers, in scan order.
563
562
  * @throws {UnsupportedImage} For a progressive or refined scan.
564
563
  */
565
- const readScanHeader = (payload, componentCount) => {
564
+ const readScanHeader = (payload) => {
566
565
  const count = payload[0];
567
566
  if (payload.length < 4 + count * 2) throw new Error("JPEG scan header is truncated");
568
567
  const specs = [];