flet-box 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +90 -11
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +20 -6
  5. package/bin/commands/checkUi.js +1369 -0
  6. package/bin/commands/create.js +86 -12
  7. package/bin/commands/createBundle.js +75 -1
  8. package/bin/commands/images.js +349 -0
  9. package/bin/commands/pruneAssets.js +170 -0
  10. package/bin/commands/release.js +34 -1
  11. package/bin/commands/runCloudflare.js +47 -9
  12. package/bin/commands/runServer.js +91 -25
  13. package/bin/commands/toPng.js +346 -0
  14. package/bin/commands/unpack.js +1173 -0
  15. package/bin/ota/loader.js +90 -0
  16. package/bin/utils/appSources.js +101 -0
  17. package/bin/utils/bundleConfig.js +166 -0
  18. package/bin/utils/dtsProps.js +147 -0
  19. package/bin/utils/imageTools.js +271 -0
  20. package/bin/utils/images/bmp.js +99 -0
  21. package/bin/utils/images/gif.js +244 -0
  22. package/bin/utils/images/index.js +163 -0
  23. package/bin/utils/images/jpeg.js +643 -0
  24. package/bin/utils/images/png.js +486 -0
  25. package/bin/utils/images/unsupported.js +29 -0
  26. package/bin/utils/neutralise.js +109 -6
  27. package/bin/utils/pwaShell.js +137 -0
  28. package/bin/utils/templates.js +1653 -471
  29. package/docs/README.md +2 -0
  30. package/docs/cli/README.md +144 -1
  31. package/docs/guides/README.md +2 -0
  32. package/docs/guides/clean-ui.md +211 -0
  33. package/docs/guides/minification-and-protection.md +1 -1
  34. package/docs/guides/online-updates.md +105 -9
  35. package/docs/tools/README.md +1 -1
  36. package/docs/tools/animation.md +1 -1
  37. package/docs/tools/markdown.md +3 -3
  38. package/docs/tools/theme.md +41 -1
  39. package/docs/widget/Accordion.md +4 -0
  40. package/docs/widget/AdMob.md +5 -1
  41. package/docs/widget/AdSense.md +4 -0
  42. package/docs/widget/AnimatedBox.md +7 -3
  43. package/docs/widget/AnimatedText.md +4 -0
  44. package/docs/widget/Audio.md +5 -1
  45. package/docs/widget/Avatar.md +15 -3
  46. package/docs/widget/Badge.md +4 -0
  47. package/docs/widget/Button.md +4 -0
  48. package/docs/widget/COMMON_PROPS.md +52 -0
  49. package/docs/widget/Card.md +4 -0
  50. package/docs/widget/Carousel.md +4 -0
  51. package/docs/widget/Chart.md +4 -0
  52. package/docs/widget/Checkbox.md +4 -0
  53. package/docs/widget/Chip.md +4 -0
  54. package/docs/widget/CircularBar.md +4 -0
  55. package/docs/widget/CircularChart.md +4 -0
  56. package/docs/widget/CodeViewer.md +4 -0
  57. package/docs/widget/Column.md +4 -0
  58. package/docs/widget/Container.md +4 -0
  59. package/docs/widget/DataTable.md +4 -0
  60. package/docs/widget/Divider.md +4 -0
  61. package/docs/widget/DraggBox.md +6 -2
  62. package/docs/widget/Dropdown.md +6 -0
  63. package/docs/widget/DroppBox.md +5 -1
  64. package/docs/widget/FloatingActionButton.md +4 -0
  65. package/docs/widget/GridView.md +4 -0
  66. package/docs/widget/Icon.md +4 -0
  67. package/docs/widget/Image.md +14 -3
  68. package/docs/widget/Input.md +4 -0
  69. package/docs/widget/Inspector.md +2 -2
  70. package/docs/widget/InstallButton.md +4 -0
  71. package/docs/widget/ListTile.md +4 -0
  72. package/docs/widget/ListView.md +4 -0
  73. package/docs/widget/Maps.md +191 -0
  74. package/docs/widget/Markdown.md +4 -0
  75. package/docs/widget/MatrixRain.md +4 -0
  76. package/docs/widget/Pagination.md +4 -0
  77. package/docs/widget/ParallaxBox.md +4 -0
  78. package/docs/widget/ProgressBar.md +4 -0
  79. package/docs/widget/QRCode.md +4 -0
  80. package/docs/widget/README.md +12 -10
  81. package/docs/widget/Radio.md +5 -1
  82. package/docs/widget/Rating.md +4 -0
  83. package/docs/widget/Row.md +4 -0
  84. package/docs/widget/Skeleton.md +4 -0
  85. package/docs/widget/Slider.md +4 -0
  86. package/docs/widget/Stack.md +4 -0
  87. package/docs/widget/Stepper.md +4 -0
  88. package/docs/widget/Switch.md +4 -0
  89. package/docs/widget/Tabs.md +5 -5
  90. package/docs/widget/Text.md +4 -0
  91. package/docs/widget/Tooltip.md +14 -13
  92. package/docs/widget/TreeView.md +4 -0
  93. package/docs/widget/Video.md +6 -2
  94. package/package.json +2 -2
  95. package/src/animations/AnimatedBox.js +4 -1
  96. package/src/animations/MatrixRain.js +3 -3
  97. package/src/core/hmr-client.js +129 -34
  98. package/src/index.d.ts +195 -7
  99. package/src/index.js +2 -0
  100. package/src/navigations/AppBar.js +22 -16
  101. package/src/navigations/BottomNavigation.js +8 -13
  102. package/src/navigations/CollapsibleSideBar.js +30 -8
  103. package/src/navigations/Drawer.js +25 -15
  104. package/src/navigations/DrawerItem.js +3 -1
  105. package/src/navigations/Scaffold.js +26 -12
  106. package/src/navigations/Tabs.js +104 -59
  107. package/src/tools/color.js +54 -0
  108. package/src/tools/index.js +9 -1
  109. package/src/tools/scale.js +41 -0
  110. package/src/tools/shadow.js +20 -0
  111. package/src/utils/index.js +1 -0
  112. package/src/utils/readableInk.js +19 -0
  113. package/src/utils/syntaxHighlight.js +78 -10
  114. package/src/utils/themes.js +217 -54
  115. package/src/utils/units.js +33 -11
  116. package/src/widget-factory/effects.js +56 -5
  117. package/src/widget-factory/processProps.js +10 -1
  118. package/src/widget-factory/remTool.js +15 -29
  119. package/src/widget-factory/stackPosition.js +18 -3
  120. package/src/widget-factory/translateProps.js +60 -1
  121. package/src/widgets/Accordion.js +20 -23
  122. package/src/widgets/AdMob.js +7 -2
  123. package/src/widgets/Avatar.js +236 -133
  124. package/src/widgets/Badge.js +3 -2
  125. package/src/widgets/BottomSheet.js +14 -13
  126. package/src/widgets/Button.js +69 -30
  127. package/src/widgets/Card.js +6 -2
  128. package/src/widgets/Carousel.js +12 -10
  129. package/src/widgets/Chart.js +28 -5
  130. package/src/widgets/Checkbox.js +76 -18
  131. package/src/widgets/Chip.js +31 -10
  132. package/src/widgets/CircularBar.js +7 -7
  133. package/src/widgets/CircularChart.js +26 -8
  134. package/src/widgets/CodeViewer.js +93 -20
  135. package/src/widgets/Container.js +13 -0
  136. package/src/widgets/DataTable.js +17 -11
  137. package/src/widgets/DraggBox.js +2 -1
  138. package/src/widgets/Dropdown.js +59 -14
  139. package/src/widgets/DroppBox.js +4 -2
  140. package/src/widgets/FloatingActionButton.js +13 -7
  141. package/src/widgets/Icon.js +3 -1
  142. package/src/widgets/Image.js +102 -3
  143. package/src/widgets/Input.js +30 -23
  144. package/src/widgets/Inspector.js +54 -1
  145. package/src/widgets/InstallButton.js +6 -4
  146. package/src/widgets/ListTile.js +4 -3
  147. package/src/widgets/ListView.js +30 -12
  148. package/src/widgets/Maps.js +1495 -0
  149. package/src/widgets/Markdown.js +37 -26
  150. package/src/widgets/Modal.js +14 -11
  151. package/src/widgets/Pagination.js +16 -11
  152. package/src/widgets/ProgressBar.js +6 -4
  153. package/src/widgets/Radio.js +73 -15
  154. package/src/widgets/Rating.js +24 -6
  155. package/src/widgets/Skeleton.js +31 -31
  156. package/src/widgets/Slider.js +29 -18
  157. package/src/widgets/SnackBar.js +35 -39
  158. package/src/widgets/Stepper.js +13 -9
  159. package/src/widgets/Switch.js +59 -18
  160. package/src/widgets/Tooltip.js +24 -12
  161. package/src/widgets/TreeView.js +27 -15
  162. package/src/widgets/index.js +1 -0
@@ -40,6 +40,8 @@ import { initRouter, getCurrentRoute, subscribe } from "./Router.js";
40
40
  * - `updateLeftNavBar(cfg)` / `updateRightNavBar(cfg)` — replace sidebar content.
41
41
  * - `setLeftNavBarWidth(w)` / `setRightNavBarWidth(w)` — resize a sidebar at runtime.
42
42
  */
43
+ import { toREM } from "../utils/units.js";
44
+
43
45
  export const Scaffold = (props) => {
44
46
  const {
45
47
  appBar: originalAppBar,
@@ -106,11 +108,15 @@ export const Scaffold = (props) => {
106
108
  * Normalises a prop value to an HTMLElement.
107
109
  * Accepts an HTMLElement directly, a factory function, or null/undefined.
108
110
  *
109
- * @param {HTMLElement|Function|null} item
111
+ * A boolean is the documented "show / hide this slot" value, never a widget,
112
+ * so it resolves to an empty slot instead of reaching appendChild() and
113
+ * throwing on a non-Node.
114
+ *
115
+ * @param {HTMLElement|Function|boolean|null} item
110
116
  * @returns {HTMLElement|null}
111
117
  */
112
118
  const toElement = (item) => {
113
- if (!item) return null;
119
+ if (!item || typeof item === "boolean") return null;
114
120
  if (item instanceof HTMLElement) return item;
115
121
  if (typeof item === "function") return item();
116
122
  return item;
@@ -283,7 +289,7 @@ export const Scaffold = (props) => {
283
289
  if (widget) {
284
290
  leftNavBarContainer.appendChild(widget);
285
291
  if (mainContentContainer)
286
- mainContentContainer.style.width = `calc(100% - ${leftNavBarWidth}px)`;
292
+ mainContentContainer.style.width = `calc(100% - ${toREM(leftNavBarWidth)})`;
287
293
  }
288
294
  };
289
295
 
@@ -359,9 +365,17 @@ export const Scaffold = (props) => {
359
365
  if (closeDrawerOnNavigate && drawerInstance && drawerInstance.close) {
360
366
  drawerInstance.close();
361
367
  }
362
- } else if (body) {
363
- const bodyWidget = makeFullSize(toElement(body));
364
- if (bodyWidget) mainContentContainer.appendChild(bodyWidget);
368
+ } else {
369
+ if (body) {
370
+ const bodyWidget = makeFullSize(toElement(body));
371
+ if (bodyWidget) mainContentContainer.appendChild(bodyWidget);
372
+ }
373
+ // Static mode mounts the shell zones too. They used to be router-only, so
374
+ // the documented `Scaffold({ appBar, body })` example rendered the body
375
+ // with no top bar, bottom bar or FAB at all.
376
+ updateAppBar(originalAppBar !== false);
377
+ updateBottomBar(originalBottomBar !== false);
378
+ updateFab(originalFab !== false);
365
379
  }
366
380
  };
367
381
 
@@ -389,7 +403,7 @@ export const Scaffold = (props) => {
389
403
 
390
404
  if (finalLeftNavBar) {
391
405
  leftNavBarContainer = document.createElement("div");
392
- leftNavBarContainer.style.width = `${leftNavBarWidth}px`;
406
+ leftNavBarContainer.style.width = toREM(leftNavBarWidth);
393
407
  leftNavBarContainer.style.flexShrink = "0";
394
408
  leftNavBarContainer.style.overflow = "auto";
395
409
  leftNavBarContainer.style.height = "100%";
@@ -409,7 +423,7 @@ export const Scaffold = (props) => {
409
423
 
410
424
  if (finalRightNavBar) {
411
425
  rightNavBarContainer = document.createElement("div");
412
- rightNavBarContainer.style.width = `${rightNavBarWidth}px`;
426
+ rightNavBarContainer.style.width = toREM(rightNavBarWidth);
413
427
  rightNavBarContainer.style.flexShrink = "0";
414
428
  rightNavBarContainer.style.overflow = "auto";
415
429
  rightNavBarContainer.style.height = "100%";
@@ -425,8 +439,8 @@ export const Scaffold = (props) => {
425
439
  // FAB slot — absolutely positioned so it floats above content.
426
440
  fabContainer = document.createElement("div");
427
441
  fabContainer.style.position = "absolute";
428
- fabContainer.style.bottom = "16px";
429
- fabContainer.style.right = "16px";
442
+ fabContainer.style.bottom = toREM(16);
443
+ fabContainer.style.right = toREM(16);
430
444
  fabContainer.style.zIndex = "10";
431
445
  container.appendChild(fabContainer);
432
446
 
@@ -528,7 +542,7 @@ export const Scaffold = (props) => {
528
542
  */
529
543
  container.setLeftNavBarWidth = (w) => {
530
544
  if (leftNavBarContainer)
531
- leftNavBarContainer.style.width = typeof w === "number" ? `${w}px` : w;
545
+ leftNavBarContainer.style.width = toREM(w);
532
546
  };
533
547
 
534
548
  /**
@@ -538,7 +552,7 @@ export const Scaffold = (props) => {
538
552
  */
539
553
  container.setRightNavBarWidth = (w) => {
540
554
  if (rightNavBarContainer)
541
- rightNavBarContainer.style.width = typeof w === "number" ? `${w}px` : w;
555
+ rightNavBarContainer.style.width = toREM(w);
542
556
  };
543
557
 
544
558
  return container;
@@ -2,9 +2,11 @@
2
2
  import { WidgetFactory } from "../widget-factory/index.js";
3
3
  import { colors } from "../utils/themes.js";
4
4
  import { Container } from "../widgets/Container.js";
5
+ import { Column } from "../widgets/Column.js";
5
6
  import { Row } from "../widgets/Row.js";
6
7
  import { Text } from "../widgets/Text.js";
7
8
  import { Icon } from "../widgets/Icon.js";
9
+ import { toREM } from "../utils/units.js";
8
10
 
9
11
  /**
10
12
  * @typedef {Object} TabDefinition
@@ -66,14 +68,16 @@ export const Tabs = (props) => {
66
68
  variant = "underline",
67
69
  size = "medium",
68
70
  color = colors.primary,
69
- textColor = colors.text,
70
- // FIX: `colors.white` no existe en themes.js → caía a `undefined`
71
- // y el tab activo no se distinguía visualmente. Uso `colors.background`
72
- // (que es "#ffffff" en el tema claro y "#0f172a" en el oscuro, que es
73
- // lo correcto: contraste sobre el fondo del tab activo).
74
- activeTextColor = colors.background,
71
+ textColor = colors.textSecondary,
72
+ // `background` made the active label the page color, so it vanished on a
73
+ // transparent tab bar (2.4:1 in light, 1.3:1 in dark). Resolved per variant
74
+ // below: the slider sits on a secondary thumb and needs the ink color.
75
+ activeTextColor,
75
76
  bgColor = colors.gray100,
76
- buttonColor = colors.secondary,
77
+ // The thumb carries the accent, and its label is the ink color in both
78
+ // themes. `secondary` missed AA on the light thumb (4.23:1); primary is
79
+ // 6.65:1 with ink, 6.68:1 in dark.
80
+ buttonColor = color,
77
81
  alignment = "left",
78
82
  fullWidth = true,
79
83
  showDivider = true,
@@ -86,7 +90,7 @@ export const Tabs = (props) => {
86
90
  } = props;
87
91
 
88
92
  let currentIndex = activeIndex;
89
- let sliderIndicator = null;
93
+ let activeIndicator = null;
90
94
  let tabsWrapperRef = null;
91
95
  let tabButtonsRef = [];
92
96
 
@@ -99,6 +103,16 @@ export const Tabs = (props) => {
99
103
  };
100
104
  const sz = sizes[size] || sizes.medium;
101
105
 
106
+ const resolvedActiveTextColor =
107
+ activeTextColor ?? (variant === "slider" ? colors.onPrimary : colors.text);
108
+
109
+ const tabBackground = (isActive) => {
110
+ if (!isActive) return "transparent";
111
+ if (variant === "pills") return `${color}1A`;
112
+ if (variant === "filled") return colors.card;
113
+ return "transparent";
114
+ };
115
+
102
116
  /** Extract display label from a tab definition or plain string. */
103
117
  const getLabel = (t) =>
104
118
  typeof t === "object" ? t.label || t.title : String(t);
@@ -157,43 +171,64 @@ export const Tabs = (props) => {
157
171
  const badge = getBadge(idx);
158
172
  const isActive = currentIndex === idx;
159
173
 
160
- // Build the row of content inside each tab button.
161
- const content = [];
162
- if (showIcon && icon && iconPosition === "left") {
163
- content.push(
164
- Icon({
165
- name: icon,
166
- size: iconSize || sz.i,
167
- color: isActive ? activeTextColor : textColor,
168
- }),
169
- );
170
- }
171
- if (label) {
172
- content.push(
173
- Text({
174
+ // Build the content inside each tab button. Every documented value of
175
+ // `iconPosition` keeps the icon: "left" puts it before the label, "right"
176
+ // after it, and "top"/"bottom" stack it above/below the label row.
177
+ const iconWidget =
178
+ showIcon && icon
179
+ ? Icon({
180
+ name: icon,
181
+ size: iconSize || sz.i,
182
+ color: isActive ? resolvedActiveTextColor : textColor,
183
+ })
184
+ : null;
185
+ const labelWidget = label
186
+ ? Text({
174
187
  text: label,
175
188
  size: sz.f,
176
- color: isActive ? activeTextColor : textColor,
189
+ color: isActive ? resolvedActiveTextColor : textColor,
177
190
  weight: isActive ? "500" : "400",
178
- }),
179
- );
180
- }
181
- if (badge) {
182
- content.push(
183
- Container({
191
+ })
192
+ : null;
193
+ const badgeWidget = badge
194
+ ? Container({
184
195
  backgroundColor: colors.danger,
185
196
  borderRadius: 10,
186
197
  padding: "2px 6px",
187
198
  marginLeft: 4,
188
199
  child: Text({ text: String(badge), size: 10, color: "#fff" }),
189
- }),
190
- );
200
+ })
201
+ : null;
202
+
203
+ const line = [labelWidget, badgeWidget].filter(Boolean);
204
+ if (iconWidget && iconPosition !== "top" && iconPosition !== "bottom") {
205
+ if (iconPosition === "left") line.unshift(iconWidget);
206
+ else line.push(iconWidget);
191
207
  }
208
+ const labelRow = Row({
209
+ alignItems: "center",
210
+ justifyContent: "center",
211
+ gap: 4,
212
+ children: line,
213
+ });
214
+ const stacked =
215
+ iconWidget && (iconPosition === "top" || iconPosition === "bottom");
216
+ const content = stacked
217
+ ? Column({
218
+ alignItems: "center",
219
+ justifyContent: "center",
220
+ gap: 4,
221
+ children:
222
+ iconPosition === "top"
223
+ ? [iconWidget, labelRow]
224
+ : [labelRow, iconWidget],
225
+ })
226
+ : labelRow;
192
227
 
193
228
  const btn = Container({
194
229
  borderRadius: variant === "slider" ? 20 : variant === "pills" ? 20 : 0,
195
- backgroundColor: "transparent",
196
- color: isActive ? activeTextColor : textColor,
230
+ backgroundColor: tabBackground(isActive),
231
+ color: isActive ? resolvedActiveTextColor : textColor,
197
232
  display: "flex",
198
233
  alignItems: "center",
199
234
  justifyContent: "center",
@@ -220,12 +255,7 @@ export const Tabs = (props) => {
220
255
  }
221
256
  }
222
257
  },
223
- child: Row({
224
- alignItems: "center",
225
- justifyContent: "center",
226
- gap: 4,
227
- children: content,
228
- }),
258
+ child: content,
229
259
  });
230
260
 
231
261
  btn.onclick = () => {
@@ -242,20 +272,34 @@ export const Tabs = (props) => {
242
272
  tabButtonsRef = tabButtons;
243
273
 
244
274
  // The slider variant uses an absolutely positioned "thumb" div that
245
- // animates to the active tab's position via CSS transform.
246
- if (variant === "slider") {
247
- sliderIndicator = WidgetFactory({
275
+ // animates to the active tab's position via CSS transform. The default
276
+ // `underline` variant needs the same moving element: without it the active
277
+ // tab had no cue besides its label color.
278
+ const usesIndicator = variant === "slider" || variant === "underline";
279
+ if (usesIndicator) {
280
+ activeIndicator = WidgetFactory({
248
281
  tag: "div",
249
282
  position: "absolute",
250
- top: "4px",
251
- left: "4px",
252
- height: "calc(100% - 8px)",
253
- backgroundColor: buttonColor,
254
- borderRadius: 20,
283
+ ...(variant === "slider"
284
+ ? {
285
+ top: "4px",
286
+ left: "4px",
287
+ height: "calc(100% - 8px)",
288
+ backgroundColor: buttonColor,
289
+ borderRadius: 20,
290
+ }
291
+ : {
292
+ bottom: "0",
293
+ left: "0",
294
+ height: "2px",
295
+ backgroundColor: color,
296
+ borderRadius: "2px 2px 0 0",
297
+ }),
255
298
  transition: "transform 0.3s ease, width 0.3s ease",
299
+ pointerEvents: "none",
256
300
  zIndex: 1,
257
301
  });
258
- tabsWrapper.appendChild(sliderIndicator);
302
+ tabsWrapper.appendChild(activeIndicator);
259
303
  }
260
304
 
261
305
  tabBar.appendChild(tabsWrapper);
@@ -263,7 +307,7 @@ export const Tabs = (props) => {
263
307
 
264
308
  const contentContainer = Container({
265
309
  display: "flex",
266
- padding: `${sz.cp}px 0`,
310
+ padding: `${toREM(sz.cp)} 0`,
267
311
  width: "100%",
268
312
  overflow: "auto",
269
313
  });
@@ -284,13 +328,13 @@ export const Tabs = (props) => {
284
328
  * Reads live DOM rectangles so it stays accurate after layout changes.
285
329
  */
286
330
  const updateSliderPosition = () => {
287
- if (!sliderIndicator || !tabButtonsRef[currentIndex]) return;
331
+ if (!activeIndicator || !tabButtonsRef[currentIndex]) return;
288
332
  const btn = tabButtonsRef[currentIndex];
289
333
  const wrapperRect = tabsWrapperRef.getBoundingClientRect();
290
334
  const btnRect = btn.getBoundingClientRect();
291
335
  const left = btnRect.left - wrapperRect.left;
292
- sliderIndicator.style.width = `${btnRect.width}px`;
293
- sliderIndicator.style.transform = `translateX(${left}px)`;
336
+ activeIndicator.style.width = `${btnRect.width}px`;
337
+ activeIndicator.style.transform = `translateX(${left}px)`;
294
338
  };
295
339
 
296
340
  /**
@@ -308,14 +352,15 @@ export const Tabs = (props) => {
308
352
 
309
353
  const textSpan = btn.querySelector("span:not(.material-icons)");
310
354
  if (textSpan) {
311
- textSpan.style.color = isActive ? activeTextColor : textColor;
355
+ textSpan.style.color = isActive ? resolvedActiveTextColor : textColor;
312
356
  textSpan.style.fontWeight = isActive ? "500" : "400";
313
357
  }
314
358
  const iconEl = btn.querySelector(".material-icons");
315
359
  if (iconEl) {
316
- iconEl.style.color = isActive ? activeTextColor : textColor;
360
+ iconEl.style.color = isActive ? resolvedActiveTextColor : textColor;
317
361
  }
318
- btn.style.color = isActive ? activeTextColor : textColor;
362
+ btn.style.color = isActive ? resolvedActiveTextColor : textColor;
363
+ btn.style.backgroundColor = tabBackground(isActive);
319
364
  });
320
365
 
321
366
  updateSliderPosition();
@@ -341,17 +386,17 @@ export const Tabs = (props) => {
341
386
  const handleResize = () => {
342
387
  clearTimeout(resizeTimeout);
343
388
  resizeTimeout = setTimeout(() => {
344
- if (sliderIndicator && tabButtonsRef[currentIndex]) {
389
+ if (activeIndicator && tabButtonsRef[currentIndex]) {
345
390
  updateSliderPosition();
346
391
  }
347
392
  }, 100);
348
393
  };
349
394
  window.addEventListener("resize", handleResize);
350
395
 
351
- // Also observe the tab bar element itself so the thumb repositions when
396
+ // Also observe the tab bar element itself so the indicator repositions when
352
397
  // the container resizes independently of the window (e.g. sidebar toggle).
353
- // Only the slider variant has a thumb, so skip it for the other variants.
354
- if (variant === "slider" && typeof ResizeObserver !== "undefined") {
398
+ // Only slider and underline render an indicator, so skip it for the others.
399
+ if (usesIndicator && typeof ResizeObserver !== "undefined") {
355
400
  resizeObserver = new ResizeObserver(() => updateSliderPosition());
356
401
  if (tabsWrapperRef) resizeObserver.observe(tabsWrapperRef);
357
402
  }
@@ -291,6 +291,60 @@ class Color {
291
291
  return this.isDark() ? "#ffffff" : "#000000";
292
292
  }
293
293
 
294
+ /**
295
+ * Relative luminance of the color, as WCAG 2.1 defines it.
296
+ *
297
+ * Each channel is sRGB-decoded (the linear branch below 0.04045 is the one
298
+ * that matters for dark colors, where `isDark()`'s BT.601 luma is a poor
299
+ * proxy) and weighted 0.2126 / 0.7152 / 0.0722. Alpha is ignored: this is the
300
+ * luminance of the color itself, so a translucent color must be composited
301
+ * over its background before being measured.
302
+ *
303
+ * @returns {number} 0 for black, 1 for white
304
+ */
305
+ luminance() {
306
+ const channel = (value) => {
307
+ const c = value / 255;
308
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
309
+ };
310
+ return (
311
+ 0.2126 * channel(this.r) + 0.7152 * channel(this.g) + 0.0722 * channel(this.b)
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Contrast ratio between this color and another, in WCAG terms.
317
+ *
318
+ * Returns the ratio of the lighter luminance to the darker one, from 1
319
+ * (identical) to 21 (black on white). This is what the readability thresholds
320
+ * are written against: 4.5 for body text, 3 for large text (>= 24px, or 19px
321
+ * bold) and for a control's own outline against the surface it sits on.
322
+ *
323
+ * @param {string|Color} other - The second color (any form `color()` parses).
324
+ * @returns {number} Contrast ratio, >= 1
325
+ * @example
326
+ * color('#64748b').contrastRatio('#ffffff'); // 4.76 — the textSecondary token, passes
327
+ * color('#94a3b8').contrastRatio('#ffffff'); // 2.56 — the textDisabled token, fails
328
+ */
329
+ contrastRatio(other) {
330
+ const a = this.luminance();
331
+ const b = other instanceof Color ? other.luminance() : color(other).luminance();
332
+ const light = Math.max(a, b);
333
+ const dark = Math.min(a, b);
334
+ return (light + 0.05) / (dark + 0.05);
335
+ }
336
+
337
+ /**
338
+ * Whether this color and another clear a WCAG contrast threshold.
339
+ *
340
+ * @param {string|Color} other - The color it is read against.
341
+ * @param {number} [minimum=4.5] - The ratio required: 4.5 body text, 3 large text.
342
+ * @returns {boolean} `true` when the pair is at or above `minimum`
343
+ */
344
+ meetsContrast(other, minimum = 4.5) {
345
+ return this.contrastRatio(other) >= minimum;
346
+ }
347
+
294
348
  /**
295
349
  * Invert the color by complementing each RGB channel.
296
350
  *
@@ -20,7 +20,15 @@ export { default as padding } from "./padding.js";
20
20
  export { print } from "./print.js";
21
21
  export { random } from "./random.js";
22
22
  export { default as rgba } from "./rgba.js";
23
- export { default as shadow } from "./shadow.js";
23
+ export {
24
+ SPACING_SCALE,
25
+ RADIUS_SCALE,
26
+ TYPE_SCALE,
27
+ TAP_MINIMUM,
28
+ PILL,
29
+ LABEL_TEXT,
30
+ } from "./scale.js";
31
+ export { default as shadow, SHADOW_LEVELS } from "./shadow.js";
24
32
  export {
25
33
  capitalize,
26
34
  capitalizeWords,
@@ -0,0 +1,41 @@
1
+ // scale.js - The framework's design scales: the numbers every widget default is drawn from.
2
+
3
+ /**
4
+ * The clean-UI contract, expressed as data instead of prose.
5
+ *
6
+ * These are the same scales `flet-box check-ui` enforces against an app's own
7
+ * source. They live here because a gate that only reads the app cannot make the
8
+ * app clean: it can reject a `padding: 21`, but the widget defaults the app never
9
+ * typed already decide the rhythm, the radius and the tap size of every screen.
10
+ * A default below the Fitts floor is a rule the framework breaks on the user's
11
+ * behalf, and no amount of app-side linting can undo it.
12
+ *
13
+ * Units are framework units, not CSS pixels. A numeric length prop goes through
14
+ * the factory's `toREM` (`src/utils/units.js`), so `40` is emitted as `2.5rem`:
15
+ * 40px at the 16px base, and *bigger* when the reader raises their browser font.
16
+ * That is the point — a tap target written in rem obeys the accessibility
17
+ * setting, while the 44px the guidelines quote is a fixed-pixel floor that
18
+ * shrinks in relative terms the moment the type grows.
19
+ *
20
+ * Depth is the exception: `SHADOW_LEVELS` ships raw `px` inside the box-shadow
21
+ * string on purpose, because a shadow that scales with the reader's font size
22
+ * stops matching the surface it lifts.
23
+ */
24
+
25
+ /** A radius this large clamps to half the box: a pill, whatever the widget's size. */
26
+ export const PILL = 999;
27
+
28
+ /** The 8-based rhythm, with the two steps a real layout needs (18 for field text, 96 for hero gutters). */
29
+ export const SPACING_SCALE = new Set([0, 4, 8, 12, 16, 18, 24, 32, 40, 48, 64, 96]);
30
+
31
+ /** Corner radii. `999` is a pill, `0` a flush edge — both are decisions, not omissions. */
32
+ export const RADIUS_SCALE = new Set([0, 4, 8, 12, 16, PILL]);
33
+
34
+ /** Type sizes. Two steps per jump: a 15 next to a 16 reads as a mistake, not as hierarchy. */
35
+ export const TYPE_SCALE = new Set([12, 14, 16, 18, 24, 32, 40, 48]);
36
+
37
+ /** Fitts: the smallest pressable box a finger reliably hits. */
38
+ export const TAP_MINIMUM = 40;
39
+
40
+ /** The reading size a control's own label may not fall under. */
41
+ export const LABEL_TEXT = 14;
@@ -20,4 +20,24 @@
20
20
  const shadow = (x, y, blur, spread, color) =>
21
21
  `${x}px ${y}px ${blur}px ${spread}px ${color}`;
22
22
 
23
+ /**
24
+ * The framework's depth scale, shared by every widget that lifts off the page.
25
+ *
26
+ * The engine routes `elevation` straight into `style.boxShadow`, so a bare
27
+ * number is an invalid declaration the browser drops without a word: a widget
28
+ * that forwards `elevation` instead of one of these strings looks configured and
29
+ * paints flat. A widget that wants a numeric depth reads the level here and
30
+ * passes the resulting string.
31
+ *
32
+ * `0` is deliberately `none` — a flush surface is a decision, not a missing one.
33
+ */
34
+ export const SHADOW_LEVELS = {
35
+ 0: "none",
36
+ 1: "0 1px 3px rgba(0,0,0,0.12)",
37
+ 2: "0 3px 6px rgba(0,0,0,0.16)",
38
+ 3: "0 6px 12px rgba(0,0,0,0.2)",
39
+ 4: "0 10px 20px rgba(0,0,0,0.25)",
40
+ 5: "0 15px 30px rgba(0,0,0,0.3)",
41
+ };
42
+
23
43
  export default shadow;
@@ -25,6 +25,7 @@
25
25
  export {
26
26
  colors,
27
27
  setTheme,
28
+ registerPalette,
28
29
  getTheme,
29
30
  toggleTheme,
30
31
  subscribeTheme,
@@ -0,0 +1,19 @@
1
+ import { color } from "../tools/color.js";
2
+
3
+ const SOLID_COLOR =
4
+ /^(#[0-9a-f]{3,8}|rgba?\(\s*\d|hsla?\(\s*\d|black|white|red|green|blue|yellow|cyan|magenta|gray|darkgray|lightgray)$/i;
5
+
6
+ /**
7
+ * Black or white, whichever reads better on `background` by WCAG contrast.
8
+ * Falls back when the fill is not a solid color this code can measure (a
9
+ * gradient, a CSS variable) — guessing there is worse than the caller's choice.
10
+ */
11
+ export const readableInk = (background, fallback) => {
12
+ if (typeof background !== "string" || !SOLID_COLOR.test(background)) {
13
+ return fallback;
14
+ }
15
+ const bg = color(background);
16
+ return bg.contrastRatio("#ffffff") >= bg.contrastRatio("#000000")
17
+ ? "#ffffff"
18
+ : "#000000";
19
+ };