flet-box 1.1.1 → 1.1.3

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 (156) hide show
  1. package/AGENTS_FOR_CODE_GEN.md +284 -0
  2. package/CONTRIBUTING.md +238 -0
  3. package/README.md +17 -13
  4. package/bin/cli.js +88 -1
  5. package/bin/commands/auditFix.js +181 -27
  6. package/bin/commands/checkApi.js +14 -3
  7. package/bin/commands/checkStructure.js +368 -0
  8. package/bin/commands/checkSyntax.js +293 -0
  9. package/bin/commands/checkUi.js +666 -14
  10. package/bin/commands/create.js +18 -1
  11. package/bin/commands/createBundle.js +20 -8
  12. package/bin/commands/metrics.js +448 -0
  13. package/bin/commands/release.js +18 -0
  14. package/bin/commands/report.js +849 -0
  15. package/bin/commands/tools.js +76 -0
  16. package/bin/commands/unpack.js +39 -11
  17. package/bin/commands/verify.js +879 -0
  18. package/bin/mcp.js +280 -0
  19. package/bin/utils/bundleConfig.js +47 -3
  20. package/bin/utils/contract.js +27 -0
  21. package/bin/utils/dtsProps.js +5 -1
  22. package/bin/utils/migrationGuide.js +146 -0
  23. package/bin/utils/moduleExports.js +9 -5
  24. package/bin/utils/templates.js +248 -30
  25. package/bin/utils/toolkit.js +191 -0
  26. package/docs/README.md +26 -1
  27. package/docs/arquitectura.md +1 -1
  28. package/docs/cli/README.md +201 -11
  29. package/docs/guides/README.md +6 -0
  30. package/docs/guides/accessibility.md +2 -1
  31. package/docs/guides/ai-toolkit.md +166 -0
  32. package/docs/guides/compatibility.md +1 -1
  33. package/docs/guides/first-app.md +6 -7
  34. package/docs/guides/migration.md +92 -0
  35. package/docs/guides/minification-and-protection.md +2 -1
  36. package/docs/guides/mobile-and-platforms.md +1 -1
  37. package/docs/guides/online-updates.md +20 -6
  38. package/docs/server/deployment.md +1 -1
  39. package/docs/tools/styling.md +2 -2
  40. package/docs/tools/theme.md +139 -4
  41. package/docs/widget/Accordion.md +74 -8
  42. package/docs/widget/AdMob.md +68 -3
  43. package/docs/widget/AdSense.md +67 -2
  44. package/docs/widget/AlertDialog.md +1 -1
  45. package/docs/widget/AnimatedBox.md +80 -39
  46. package/docs/widget/AnimatedText.md +74 -18
  47. package/docs/widget/Audio.md +73 -14
  48. package/docs/widget/Avatar.md +69 -4
  49. package/docs/widget/Badge.md +68 -3
  50. package/docs/widget/BottomSheet.md +2 -2
  51. package/docs/widget/Button.md +69 -3
  52. package/docs/widget/Card.md +69 -4
  53. package/docs/widget/Carousel.md +67 -2
  54. package/docs/widget/Chart.md +67 -2
  55. package/docs/widget/Checkbox.md +74 -8
  56. package/docs/widget/Chip.md +68 -3
  57. package/docs/widget/CircularBar.md +74 -15
  58. package/docs/widget/CircularChart.md +71 -10
  59. package/docs/widget/CodeViewer.md +67 -2
  60. package/docs/widget/Column.md +68 -3
  61. package/docs/widget/Container.md +68 -3
  62. package/docs/widget/DataTable.md +67 -2
  63. package/docs/widget/Divider.md +67 -2
  64. package/docs/widget/DraggBox.md +67 -2
  65. package/docs/widget/Dropdown.md +76 -10
  66. package/docs/widget/DroppBox.md +67 -2
  67. package/docs/widget/FloatingActionButton.md +68 -3
  68. package/docs/widget/GridView.md +67 -2
  69. package/docs/widget/Icon.md +71 -5
  70. package/docs/widget/Image.md +68 -3
  71. package/docs/widget/Input.md +68 -3
  72. package/docs/widget/Inspector.md +2 -2
  73. package/docs/widget/InstallButton.md +73 -13
  74. package/docs/widget/ListTile.md +69 -3
  75. package/docs/widget/ListView.md +67 -2
  76. package/docs/widget/Maps.md +68 -3
  77. package/docs/widget/Markdown.md +74 -15
  78. package/docs/widget/MatrixRain.md +72 -11
  79. package/docs/widget/Menu.md +209 -0
  80. package/docs/widget/Modal.md +3 -3
  81. package/docs/widget/Pagination.md +68 -3
  82. package/docs/widget/ParallaxBox.md +72 -12
  83. package/docs/widget/ProgressBar.md +69 -4
  84. package/docs/widget/QRCode.md +67 -2
  85. package/docs/widget/README.md +54 -50
  86. package/docs/widget/Radio.md +76 -10
  87. package/docs/widget/Rating.md +70 -6
  88. package/docs/widget/Row.md +68 -3
  89. package/docs/widget/Skeleton.md +68 -3
  90. package/docs/widget/Slider.md +75 -9
  91. package/docs/widget/SnackBar.md +2 -2
  92. package/docs/widget/Stack.md +68 -3
  93. package/docs/widget/Stepper.md +68 -3
  94. package/docs/widget/Switch.md +74 -8
  95. package/docs/widget/Text.md +71 -4
  96. package/docs/widget/ThemePicker.md +197 -0
  97. package/docs/widget/Tooltip.md +72 -7
  98. package/docs/widget/TreeView.md +70 -10
  99. package/docs/widget/Video.md +71 -10
  100. package/package.json +10 -3
  101. package/skills/fletbox-design/SKILL.md +89 -5
  102. package/skills/fletbox-motion/SKILL.md +1 -1
  103. package/skills/fletbox-rules/SKILL.md +56 -4
  104. package/src/core/runApp.js +12 -0
  105. package/src/index.d.ts +374 -101
  106. package/src/index.js +9 -0
  107. package/src/navigations/AdaptiveScaffold.js +2 -2
  108. package/src/navigations/BottomNavigation.js +1 -1
  109. package/src/navigations/Scaffold.js +18 -6
  110. package/src/tools/useState.js +11 -4
  111. package/src/utils/colors.js +275 -0
  112. package/src/utils/errorBoundary.js +288 -0
  113. package/src/utils/index.js +10 -2
  114. package/src/utils/stackPosition.js +39 -24
  115. package/src/utils/syntaxHighlight.js +4 -1
  116. package/src/utils/themes.js +279 -3
  117. package/src/widget-factory/addChildren.js +17 -10
  118. package/src/widget-factory/assignProps.js +18 -6
  119. package/src/widget-factory/createWidget.js +7 -0
  120. package/src/widget-factory/deprecations.js +77 -0
  121. package/src/widget-factory/translateProps.js +11 -1
  122. package/src/widget-factory/warnings.js +1 -27
  123. package/src/widget-factory/widgetFactory.js +59 -17
  124. package/src/widgets/Accordion.js +21 -7
  125. package/src/widgets/Badge.js +11 -2
  126. package/src/widgets/Button.js +15 -3
  127. package/src/widgets/Carousel.js +12 -4
  128. package/src/widgets/Chart.js +7 -2
  129. package/src/widgets/Checkbox.js +1 -2
  130. package/src/widgets/Chip.js +24 -10
  131. package/src/widgets/CircularBar.js +9 -3
  132. package/src/widgets/CircularChart.js +5 -3
  133. package/src/widgets/Container.js +6 -0
  134. package/src/widgets/Divider.js +3 -1
  135. package/src/widgets/DraggBox.js +9 -0
  136. package/src/widgets/Dropdown.js +3 -3
  137. package/src/widgets/DroppBox.js +5 -0
  138. package/src/widgets/FloatingActionButton.js +1 -1
  139. package/src/widgets/Icon.js +5 -1
  140. package/src/widgets/InstallButton.js +4 -2
  141. package/src/widgets/Markdown.js +171 -103
  142. package/src/widgets/Menu.js +340 -0
  143. package/src/widgets/Modal.js +7 -1
  144. package/src/widgets/Pagination.js +10 -5
  145. package/src/widgets/ProgressBar.js +5 -4
  146. package/src/widgets/QRCode.js +4 -0
  147. package/src/widgets/Radio.js +1 -2
  148. package/src/widgets/Rating.js +9 -0
  149. package/src/widgets/Slider.js +4 -1
  150. package/src/widgets/SnackBar.js +2 -2
  151. package/src/widgets/Switch.js +1 -2
  152. package/src/widgets/Text.js +9 -5
  153. package/src/widgets/ThemePicker.js +234 -0
  154. package/src/widgets/TreeView.js +2 -1
  155. package/src/widgets/index.js +2 -0
  156. package/docs/CONTRIBUTING.md +0 -156
@@ -7,9 +7,16 @@ import { makeReactive } from "./reactivity.js";
7
7
  import { processProps } from "./processProps.js";
8
8
  import { assignProps } from "./assignProps.js";
9
9
  import { stackPosition } from "./stackPosition.js";
10
- import { addChildren, removePropChildren } from "./addChildren.js";
10
+ import { addChildren, takePropChildren } from "./addChildren.js";
11
11
  import { reactiveKeyOf } from "./reactiveValue.js";
12
12
  import { registerWidget } from "../tools/useState.js";
13
+ import {
14
+ FAILURE_ATTR,
15
+ clearFailure,
16
+ paintFailure,
17
+ renderFailure,
18
+ reportFailure,
19
+ } from "../utils/errorBoundary.js";
13
20
 
14
21
  /**
15
22
  * Auto-binds reactive props to their state key.
@@ -117,22 +124,29 @@ export function WidgetFactory(tag, customProps = {}) {
117
124
  // ============================================================
118
125
  // 4. APPLY INITIAL PROPS
119
126
  // ============================================================
120
- const initialProps = processProps(finalProps, finalTag, widgetName);
121
- assignProps(widget, initialProps);
122
- bindReactiveProps(widget, finalProps);
123
- stackPosition(widget, finalProps);
127
+ // A throw inside a widget must not take the screen with it. The widget
128
+ // paints a placeholder in its own place — the rest of the tree keeps
129
+ // working — and the error is reported with this widget's name.
130
+ try {
131
+ const initialProps = processProps(finalProps, finalTag, widgetName);
132
+ assignProps(widget, initialProps);
133
+ bindReactiveProps(widget, finalProps);
134
+ stackPosition(widget, finalProps);
124
135
 
125
- if (finalProps.child !== undefined) {
126
- addChildren(widget, finalProps.child);
127
- }
128
- if (finalProps.children !== undefined) {
129
- addChildren(widget, finalProps.children);
130
- }
136
+ if (finalProps.child !== undefined) {
137
+ addChildren(widget, finalProps.child);
138
+ }
139
+ if (finalProps.children !== undefined) {
140
+ addChildren(widget, finalProps.children);
141
+ }
131
142
 
132
- applyEffects(widget);
143
+ applyEffects(widget);
133
144
 
134
- if (finalProps.ref && typeof finalProps.ref === "function") {
135
- finalProps.ref(widget);
145
+ if (finalProps.ref && typeof finalProps.ref === "function") {
146
+ finalProps.ref(widget);
147
+ }
148
+ } catch (error) {
149
+ renderFailure(widget, error, { widgetName, phase: "render" });
136
150
  }
137
151
 
138
152
  // ============================================================
@@ -160,6 +174,12 @@ export function WidgetFactory(tag, customProps = {}) {
160
174
 
161
175
  updateDepth++;
162
176
  try {
177
+ // A previous failure painted a placeholder. This update may be the retry
178
+ // that fixes it, so the placeholder goes before the real content comes
179
+ // back.
180
+ const hadFailure = Boolean(widget._failureBox);
181
+ clearFailure(widget);
182
+
163
183
  // Normalize: never allow child AND children simultaneously
164
184
  const normalized = { ...newProps };
165
185
  // Reserved: `widgetName` labels the widget for Inspector and must never
@@ -190,9 +210,22 @@ export function WidgetFactory(tag, customProps = {}) {
190
210
  stackPosition(widget, merged);
191
211
 
192
212
  if (hasChild || hasChildren) {
193
- removePropChildren(widget);
194
- if (hasChild) addChildren(widget, newProps.child);
195
- if (hasChildren) addChildren(widget, newProps.children);
213
+ // The old content comes out first, but it is kept: if attaching the new
214
+ // content throws, the widget goes back to what it had instead of being
215
+ // left empty.
216
+ const previous = takePropChildren(widget);
217
+ try {
218
+ if (hasChild) addChildren(widget, newProps.child);
219
+ if (hasChildren) addChildren(widget, newProps.children);
220
+ } catch (error) {
221
+ addChildren(widget, previous);
222
+ throw error;
223
+ }
224
+ } else if (hadFailure) {
225
+ // The build failed before the children were attached, so this update is
226
+ // the retry: they go in now.
227
+ if (merged.child !== undefined) addChildren(widget, merged.child);
228
+ if (merged.children !== undefined) addChildren(widget, merged.children);
196
229
  }
197
230
 
198
231
  applyEffects(widget);
@@ -200,6 +233,15 @@ export function WidgetFactory(tag, customProps = {}) {
200
233
  if (newProps.ref && typeof newProps.ref === "function") {
201
234
  newProps.ref(widget);
202
235
  }
236
+ } catch (error) {
237
+ // The widget keeps the content it had: a failed refresh is stale, not
238
+ // empty, and the screen must not blank over it.
239
+ const failure = reportFailure(error, { widgetName, phase: "update" });
240
+ widget.setAttribute(FAILURE_ATTR, "update");
241
+ // A widget with nothing in it is a hole; one that says it failed is not.
242
+ if (!widget.firstChild) {
243
+ paintFailure(widget, failure, { widgetName, phase: "update" });
244
+ }
203
245
  } finally {
204
246
  updateDepth--;
205
247
  }
@@ -38,7 +38,7 @@ const ACCORDION_PROP_KEYS = [
38
38
  * @param {HTMLElement|HTMLElement[]} props.children - Content rendered inside the expanded panel.
39
39
  * @param {boolean} [props.expanded=false] - Whether the accordion starts in the expanded state.
40
40
  * @param {Function} [props.onPress] - Callback fired when the expanded state changes. Receives `(isExpanded: boolean)`.
41
- * @param {Function} [props.onToggle] - @deprecated Use `onPress` instead. Kept as alias for backward compatibility.
41
+ * @param {Function} [props.onToggle] - @deprecated Use `onPress` instead.
42
42
  * @param {'contained'|'outlined'|'ghost'} [props.variant='contained'] - Visual style variant.
43
43
  * @param {number} [props.borderRadius=8] - Corner radius in pixels.
44
44
  * @param {string} [props.bgColor=colors.primaryBg] - Background color of the container (used in 'contained' variant).
@@ -70,6 +70,7 @@ export const Accordion = (props) => {
70
70
  title,
71
71
  children,
72
72
  expanded = false,
73
+ /** @deprecated Use `onPress` instead — it receives the same boolean. */
73
74
  onToggle,
74
75
  onPress,
75
76
  variant = "contained", // 'contained', 'outlined', 'ghost'
@@ -154,6 +155,10 @@ export const Accordion = (props) => {
154
155
  let iconElement = null;
155
156
  let resizeObserver = null;
156
157
  let isAnimating = false;
158
+ // Pending animation timer ids — cancelled in onUnmount so the callbacks never
159
+ // fire on a widget that has already left the DOM.
160
+ let animTimer1 = null;
161
+ let animTimer2 = null;
157
162
 
158
163
  // ========== UI UPDATE FUNCTIONS ==========
159
164
 
@@ -317,13 +322,15 @@ export const Accordion = (props) => {
317
322
 
318
323
  // Animate from 0 → measured height → "auto" (to stay responsive to content changes)
319
324
  setHeight("0px");
320
- setTimeout(() => {
325
+ animTimer1 = setTimeout(() => {
326
+ animTimer1 = null;
321
327
  setHeight(targetHeight);
322
- setTimeout(() => {
328
+ animTimer2 = setTimeout(() => {
329
+ animTimer2 = null;
323
330
  setHeight("auto", true);
324
331
  contentWrapper.style.overflow = "visible";
325
332
  isAnimating = false;
326
- const expandCb = onToggle || onPress;
333
+ const expandCb = onPress || onToggle;
327
334
  if (triggerCallback && expandCb) expandCb(true);
328
335
  updateUI();
329
336
  }, currentAnimationDuration);
@@ -351,15 +358,17 @@ export const Accordion = (props) => {
351
358
  contentWrapper.style.overflow = "hidden";
352
359
  setHeight(currentHeight);
353
360
 
354
- setTimeout(() => {
361
+ animTimer1 = setTimeout(() => {
362
+ animTimer1 = null;
355
363
  setHeight("0px");
356
364
  isExpanded = false;
357
- setTimeout(() => {
365
+ animTimer2 = setTimeout(() => {
366
+ animTimer2 = null;
358
367
  contentWrapper.style.display = "none";
359
368
  contentWrapper.style.overflow = "";
360
369
  contentInner.style.display = "";
361
370
  isAnimating = false;
362
- const collapseCb = onToggle || onPress;
371
+ const collapseCb = onPress || onToggle;
363
372
  if (triggerCallback && collapseCb) collapseCb(false);
364
373
  updateUI();
365
374
  }, currentAnimationDuration);
@@ -608,6 +617,11 @@ export const Accordion = (props) => {
608
617
  * Registered through `onUnmount` so it runs on every teardown path.
609
618
  */
610
619
  container.onUnmount(() => {
620
+ // Cancel any in-flight animation timers so the callbacks never fire on a
621
+ // widget that has already left the DOM.
622
+ if (animTimer1 !== null) { clearTimeout(animTimer1); animTimer1 = null; }
623
+ if (animTimer2 !== null) { clearTimeout(animTimer2); animTimer2 = null; }
624
+ isAnimating = false;
611
625
  if (resizeObserver) resizeObserver.disconnect();
612
626
  if (contentWrapper && contentWrapper._cleanup) contentWrapper._cleanup();
613
627
  });
@@ -37,6 +37,12 @@ import { toREM } from "../utils/units.js";
37
37
  * @returns {HTMLElement} The wrapped Stack element, or the child element directly if the badge is hidden.
38
38
  */
39
39
  export const Badge = (props) => {
40
+ // Track whether the caller explicitly passed these color props, BEFORE
41
+ // defaults are applied — the theme subscription should only fire when the
42
+ // caller left the colors to the theme.
43
+ const callerPassedBgColor = props.bgColor !== undefined;
44
+ const callerPassedColor = props.color !== undefined;
45
+
40
46
  const {
41
47
  value,
42
48
  child,
@@ -134,8 +140,11 @@ export const Badge = (props) => {
134
140
  });
135
141
 
136
142
  // Subscribe to theme changes to keep the badge color in sync when no explicit
137
- // bgColor/color was provided by the caller
138
- if (!bgColor && !color) {
143
+ // bgColor/color was provided by the caller.
144
+ // FIX [BODY-1]: compare against the caller's original intent, not the
145
+ // post-default values — `bgColor` and `color` both have defaults, so
146
+ // `!bgColor && !color` was always false and the subscription never ran.
147
+ if (!callerPassedBgColor && !callerPassedColor) {
139
148
  unsubscribeTheme = subscribeTheme(() => {
140
149
  badgeElement.style.backgroundColor = colors.danger;
141
150
  });
@@ -109,9 +109,17 @@ function buildButtonChildren(text, textColor, fontSize, gap, iconElements) {
109
109
  const middleRow = [];
110
110
  if (iconElements.left) middleRow.push(iconElements.left);
111
111
  if (text) {
112
- middleRow.push(
113
- Text({ text, color: textColor, size: fontSize, weight: "bold" }),
114
- );
112
+ const label = Text({ text, color: textColor, size: fontSize, weight: "bold" });
113
+ // A button label is a label, not a paragraph. Left loose, a long one wraps
114
+ // to three lines inside a fixed-height pill: the text outgrows the padding
115
+ // box and the capsule's rounded ends cut into the words. One line with an
116
+ // ellipsis keeps the shape; the full string stays the accessible name. A
117
+ // block of copy that needs to wrap is a card or a list tile, not a button.
118
+ label.style.whiteSpace = "nowrap";
119
+ label.style.overflow = "hidden";
120
+ label.style.textOverflow = "ellipsis";
121
+ label.style.minWidth = "0";
122
+ middleRow.push(label);
115
123
  }
116
124
  if (iconElements.right) middleRow.push(iconElements.right);
117
125
 
@@ -124,6 +132,10 @@ function buildButtonChildren(text, textColor, fontSize, gap, iconElements) {
124
132
  alignItems: "center",
125
133
  justifyContent: "center",
126
134
  gap,
135
+ // Without this the row refuses to shrink below the label's intrinsic
136
+ // width and the ellipsis never engages inside a constrained button.
137
+ minWidth: 0,
138
+ maxWidth: "100%",
127
139
  child: middleRow,
128
140
  }),
129
141
  );
@@ -279,11 +279,16 @@ export const Carousel = (props) => {
279
279
  updatePosition(false);
280
280
  updateDots();
281
281
 
282
- // Autoplay setup
282
+ // Autoplay setup — start from onMount, not from construction.
283
+ // If the widget is built but never inserted into the DOM, setInterval would
284
+ // run forever because lifecycle's orphan-prune never fires unmount on a node
285
+ // that was never mounted. Hover listeners must follow the same path.
283
286
  if (autoPlay) {
284
- startAutoPlay();
285
- container.addEventListener("mouseenter", stopAutoPlay);
286
- container.addEventListener("mouseleave", startAutoPlay);
287
+ container.onMount(() => {
288
+ startAutoPlay();
289
+ container.addEventListener("mouseenter", stopAutoPlay);
290
+ container.addEventListener("mouseleave", startAutoPlay);
291
+ });
287
292
  }
288
293
 
289
294
  // Public methods
@@ -295,6 +300,9 @@ export const Carousel = (props) => {
295
300
  // Cleanup
296
301
  container.onUnmount(() => {
297
302
  stopAutoPlay();
303
+ // Remove hover listeners regardless of whether onMount ran
304
+ container.removeEventListener("mouseenter", stopAutoPlay);
305
+ container.removeEventListener("mouseleave", startAutoPlay);
298
306
  });
299
307
 
300
308
  return container;
@@ -104,7 +104,9 @@ export const Chart = (props) => {
104
104
  ...rest
105
105
  } = props;
106
106
 
107
- if (!data.length) return null;
107
+ // FIX [CHART-2]: returning null for empty data prevents updateData() from
108
+ // ever working — the container doesn't exist. Render an empty canvas instead.
109
+ // if (!data.length) return null;
108
110
 
109
111
  let canvasRef = null;
110
112
  let context = null;
@@ -230,7 +232,10 @@ export const Chart = (props) => {
230
232
  ctx.fillStyle = barColor;
231
233
  ctx.fillRect(x, Math.min(y, baselineY), barWidth, barHeight);
232
234
 
233
- if (showValues && labels[index]) {
235
+ // FIX [CHART-2]: `showValues` was gated on `labels[index]` — so values
236
+ // were never drawn when no labels array was provided. Show values
237
+ // independently of labels.
238
+ if (showValues) {
234
239
  ctx.fillStyle = textColor;
235
240
  ctx.font = "8px sans-serif";
236
241
  ctx.fillText(value, x + barWidth / 2 - 5, y - 2);
@@ -11,6 +11,7 @@ import { TAP_MINIMUM, LABEL_TEXT } from "../tools/index.js";
11
11
  export const Checkbox = (props) => {
12
12
  const {
13
13
  checked = false,
14
+ /** @deprecated Use `onPress` instead — it receives the same boolean. */
14
15
  onCheck,
15
16
  onPress: onPressProp,
16
17
  disabled = false,
@@ -22,8 +23,6 @@ export const Checkbox = (props) => {
22
23
  ...rest
23
24
  } = props;
24
25
 
25
- /** @deprecated Use onPress instead */
26
- // onCheck kept as alias for backward compatibility
27
26
  const onPress = onPressProp || onCheck;
28
27
 
29
28
  let internalChecked = checked;
@@ -10,6 +10,7 @@ import { Row } from "./Row.js";
10
10
  import { Text } from "./Text.js";
11
11
  import { Icon } from "./Icon.js";
12
12
  import { colors } from "../utils/themes.js";
13
+ import { readableInk } from "../utils/readableInk.js";
13
14
  import { PILL, TAP_MINIMUM } from "../tools/index.js";
14
15
 
15
16
  /**
@@ -40,6 +41,12 @@ export const Chip = (props) => {
40
41
  onDelete,
41
42
  variant = "filled",
42
43
  color = colors.primary,
44
+ // The chip's own plate. It has to be taken out of `props` here: left in, it
45
+ // travels through `...rest` onto the outer wrapper — and the wrapper is the
46
+ // 40-unit tap target with no radius, so a chip that named its background
47
+ // painted a rectangle of it behind the pill.
48
+ bgColor,
49
+ backgroundColor,
43
50
  textColor,
44
51
  borderColor,
45
52
  borderRadius = PILL,
@@ -67,19 +74,25 @@ export const Chip = (props) => {
67
74
  ? { paddingVertical: padding, paddingHorizontal: padding * 2 }
68
75
  : { padding };
69
76
 
70
- // Derive background, text, and border colors from the variant
71
- let bgColor, txtColor, brdColor;
77
+ // Derive background, text, and border colors from the variant. A filled chip
78
+ // takes the plate its caller named; an outlined one keeps the variant's
79
+ // contract and stays transparent, the same answer `Button` gives when a
80
+ // `bgColor` is handed to `variant: "outlined"`. Either way the name is consumed
81
+ // here, so it never reaches the wrapper and paints a square plate.
82
+ const plate = bgColor || backgroundColor;
83
+ let pillColor, txtColor, brdColor;
72
84
 
73
85
  if (variant === "outlined") {
74
- bgColor = "transparent";
86
+ pillColor = "transparent";
75
87
  txtColor = textColor || color;
76
88
  brdColor = borderColor || color;
77
89
  } else {
78
- bgColor = color;
79
- // The label sits on the primary plate, so its ink is the theme's, not a
80
- // literal white: in dark mode a primary chip is near-white and white words
81
- // would vanish.
82
- txtColor = textColor || colors.onPrimary;
90
+ pillColor = plate || color;
91
+ // The label sits on the plate, so its ink follows the plate. On the default
92
+ // primary that is the theme's ink — in dark mode a primary chip is near-white
93
+ // and white words would vanish — and once the caller names a pale plate,
94
+ // picking the readable ink is what stops the label disappearing again.
95
+ txtColor = textColor || (plate ? readableInk(plate, colors.text) : colors.onPrimary);
83
96
  brdColor = "transparent";
84
97
  }
85
98
 
@@ -131,7 +144,7 @@ export const Chip = (props) => {
131
144
  const chipContent = Row({
132
145
  alignItems: "center",
133
146
  gap: gap,
134
- backgroundColor: bgColor,
147
+ backgroundColor: pillColor,
135
148
  border: variant === "outlined" ? `1px solid ${brdColor}` : "none",
136
149
  borderRadius: borderRadius,
137
150
  ...chipPadding,
@@ -175,7 +188,8 @@ export const Chip = (props) => {
175
188
  if (variant === "filled") {
176
189
  chip.style.opacity = "1";
177
190
  } else if (chipContent) {
178
- chipContent.style.backgroundColor = "transparent";
191
+ // Leaving restores the chip's own plate, which is not always transparent.
192
+ chipContent.style.backgroundColor = pillColor;
179
193
  }
180
194
  });
181
195
  }
@@ -125,9 +125,11 @@ export const CircularBar = (props) => {
125
125
 
126
126
  const getFormattedValue = () => {
127
127
  if (customValueFormatter) return customValueFormatter(currentValue, max);
128
+ // Guard: max === 0 would produce Infinity or NaN in every branch.
129
+ const safeMax = max > 0 ? max : 1;
128
130
  if (valueFormat === "percent") {
129
131
  const percent =
130
- Math.round((currentValue / max) * 100 * Math.pow(10, valueDecimals)) /
132
+ Math.round((currentValue / safeMax) * 100 * Math.pow(10, valueDecimals)) /
131
133
  Math.pow(10, valueDecimals);
132
134
  return `${valuePrefix}${percent}${valueSuffix}`;
133
135
  }
@@ -243,7 +245,9 @@ export const CircularBar = (props) => {
243
245
  const drawStatic = () => {
244
246
  if (!ctx || !canvas) return;
245
247
  currentValue = value;
246
- const angle = (value / max) * Math.PI * 2;
248
+ // Guard: max === 0 would produce Infinity radians → canvas draws nothing.
249
+ const safeMax = max > 0 ? max : 1;
250
+ const angle = (value / safeMax) * Math.PI * 2;
247
251
  ctx.clearRect(0, 0, currentWidth, currentWidth);
248
252
  drawBackground();
249
253
  drawProgress(angle);
@@ -261,7 +265,9 @@ export const CircularBar = (props) => {
261
265
  const elapsed = timestamp - startTime;
262
266
  const progress = Math.min(1, elapsed / animationDuration);
263
267
  currentValue = progress * value;
264
- const angle = (currentValue / max) * Math.PI * 2;
268
+ // Guard: max === 0 → Infinity radians.
269
+ const safeMax = max > 0 ? max : 1;
270
+ const angle = (currentValue / safeMax) * Math.PI * 2;
265
271
  ctx.clearRect(0, 0, currentWidth, currentWidth);
266
272
  drawBackground();
267
273
  drawProgress(angle);
@@ -289,9 +289,11 @@ export const CircularChart = (props) => {
289
289
  ctx.font = `${labelSize}px system-ui`;
290
290
  ctx.fillStyle = labelInk(color);
291
291
  ctx.shadowBlur = 0;
292
- const percent = Math.round(
293
- (slice.value / (slice.end - slice.start)) * 100,
294
- );
292
+ // FIX [CC-2]: percent was computed as value / (end-start) * 100, which
293
+ // divides by the slice's radian angle (~0.002 for small slices) and gives
294
+ // nonsense like 4775%. Divide by the total instead.
295
+ const total = data.reduce((s, d) => s + (d?.value || 0), 0);
296
+ const percent = total > 0 ? Math.round((slice.value / total) * 100) : 0;
295
297
  ctx.fillText(slice.original?.label || `${percent}%`, x, y);
296
298
  }
297
299
  };
@@ -13,6 +13,7 @@ export const Container = (props = {}) => {
13
13
  overflow = "auto",
14
14
  boxSizing = "border-box",
15
15
  bgColor = colors.surface,
16
+ expand,
16
17
  flex,
17
18
  flexGrow,
18
19
  flexShrink,
@@ -28,6 +29,10 @@ export const Container = (props = {}) => {
28
29
  ? { flexShrink: 0 }
29
30
  : { flex, flexGrow, flexShrink };
30
31
 
32
+ // FIX [CONTAINER-1]: `expand` was documented but never applied.
33
+ // It maps to `flex: 1` so the container fills available space in its parent.
34
+ const expandProps = expand ? { flex: 1, flexShrink: 1 } : {};
35
+
31
36
  return WidgetFactory({
32
37
  tag: "div",
33
38
  widgetName: "Container",
@@ -37,6 +42,7 @@ export const Container = (props = {}) => {
37
42
  boxSizing: boxSizing,
38
43
  backgroundColor: bgColor,
39
44
  ...flexDefaults,
45
+ ...expandProps,
40
46
  ...rest,
41
47
  });
42
48
  };
@@ -7,7 +7,9 @@ import { colors } from "../utils/themes.js";
7
7
 
8
8
  export const Divider = (props = {}) => {
9
9
  const {
10
- color = colors.surface,
10
+ // FIX [DIVIDER-1]: default was `colors.surface` — same color as the
11
+ // background — making every divider invisible unless explicitly colored.
12
+ color = colors.border,
11
13
  thickness = 1,
12
14
  margin = 16,
13
15
  orientation = "horizontal",
@@ -110,6 +110,15 @@ export const DraggBox = (props) => {
110
110
  if (onDragEnd) onDragEnd(e, data);
111
111
  });
112
112
 
113
+ // FIX [DRAGBOX-1]: if the widget is unmounted while a drag is in progress,
114
+ // `dragend` never fires and the clone stays in document.body forever.
115
+ wrapper.onUnmount(() => {
116
+ if (dragClone && dragClone.parentNode) {
117
+ dragClone.parentNode.removeChild(dragClone);
118
+ dragClone = null;
119
+ }
120
+ });
121
+
113
122
  return wrapper;
114
123
  };
115
124
 
@@ -60,8 +60,8 @@ export const Dropdown = (inputProps = {}) => {
60
60
  const {
61
61
  options = [],
62
62
  value = null,
63
+ /** @deprecated Use `onPress` instead — it receives the same value. */
63
64
  onChange,
64
- /** @deprecated Use onPress instead */
65
65
  onPress: onPressProp,
66
66
  placeholder = "Select...",
67
67
  disabled = false,
@@ -276,7 +276,7 @@ export const Dropdown = (inputProps = {}) => {
276
276
  alignItems: "center",
277
277
  gap: 8,
278
278
  // minWidth: 0 para que un texto largo ENCOJA y no empuje al icono: sin
279
- // él, el span no puede bajar de su ancho de contenido y el
279
+ // el span cannot shrink below its content width and the
280
280
  // expand_more sale del selector.
281
281
  flex: 1,
282
282
  minWidth: 0,
@@ -286,7 +286,7 @@ export const Dropdown = (inputProps = {}) => {
286
286
  // width "100%" por defecto, y con eso este bloque se apropiaba de casi
287
287
  // todo el ancho del selector: al no llevar justifyContent, el glifo
288
288
  // quedaba anclado a su borde izquierdo, pegado al texto, en vez de
289
- // apartado a la derecha. flexShrink 0 evita además que se apriete cuando
289
+ // the right side. flexShrink 0 additionally prevents squeezing when
290
290
  // el texto es largo.
291
291
  Row({
292
292
  alignItems: "center",
@@ -149,6 +149,11 @@ export const DroppBox = (props) => {
149
149
  if (disabled) return;
150
150
  e.preventDefault();
151
151
 
152
+ // FIX [DROPBOX-1]: `dragleave` fires when the pointer moves onto a child
153
+ // element inside the drop zone. `relatedTarget` is the element being entered
154
+ // next; if it is still inside this wrapper the drag hasn't really left.
155
+ if (wrapper.contains(e.relatedTarget)) return;
156
+
152
157
  if (isActive) {
153
158
  isActive = false;
154
159
  removeActiveStyles();
@@ -47,7 +47,7 @@ export const FloatingActionButton = (props) => {
47
47
  children.push(
48
48
  Text({
49
49
  // FIX: `value` es special y `applySpecial` solo lo aplica en inputs
50
- // → en un <span> no hacía nada y el label quedaba vacío.
50
+ // a span did nothing and the label stayed empty.
51
51
  text: label,
52
52
  size: 14,
53
53
  fontWeight: "500",
@@ -13,6 +13,10 @@ export const Icon = (props) => {
13
13
  name,
14
14
  size = 24,
15
15
  color = colors.textSecondary,
16
+ // FIX [ICON-1]: extract className from rest so it can be merged with
17
+ // `material-icons` instead of replacing it. An app that passes className
18
+ // to add a custom style would otherwise break the ligature entirely.
19
+ className: extraClassName,
16
20
  ...rest
17
21
  } = props;
18
22
  const iconName = icon || name || "";
@@ -35,7 +39,7 @@ export const Icon = (props) => {
35
39
  return WidgetFactory({
36
40
  tag: "span",
37
41
  widgetName: "Icon",
38
- className: "material-icons",
42
+ className: extraClassName ? `material-icons ${extraClassName}` : "material-icons",
39
43
  text: iconName, // ✅ CHANGED: text instead of textContent
40
44
  fontSize: toREM(size),
41
45
  color: color,
@@ -35,11 +35,13 @@ export const InstallButton = (props) => {
35
35
  if (deferredPrompt) {
36
36
  deferredPrompt.prompt();
37
37
  const { outcome } = await deferredPrompt.userChoice;
38
+ deferredPrompt = null;
38
39
  if (outcome === "accepted") {
39
- if (onInstalled) onInstalled();
40
+ // FIX [INSTALL-1]: do NOT call onInstalled here — the browser fires
41
+ // `appinstalled` right after acceptance, and handleAppInstalled calls it
42
+ // there. Calling it in both places caused the double-fire.
40
43
  hideButton();
41
44
  }
42
- deferredPrompt = null;
43
45
  }
44
46
  if (onClick) onClick();
45
47
  };