flet-box 1.0.7 → 1.0.9

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 (139) hide show
  1. package/README.md +3 -0
  2. package/bin/cli.js +13 -0
  3. package/bin/commands/checkApi.js +13 -1
  4. package/bin/commands/checkUi.js +1369 -0
  5. package/bin/commands/create.js +49 -5
  6. package/bin/commands/createBundle.js +21 -0
  7. package/bin/commands/runServer.js +11 -2
  8. package/bin/utils/pwaShell.js +137 -0
  9. package/bin/utils/templates.js +1398 -443
  10. package/docs/README.md +4 -0
  11. package/docs/cli/README.md +63 -2
  12. package/docs/guides/README.md +2 -0
  13. package/docs/guides/clean-ui.md +211 -0
  14. package/docs/guides/minification-and-protection.md +1 -1
  15. package/docs/guides/mobile-and-platforms.md +31 -0
  16. package/docs/guides/online-updates.md +48 -0
  17. package/docs/tools/README.md +1 -1
  18. package/docs/tools/animation.md +1 -1
  19. package/docs/tools/markdown.md +3 -3
  20. package/docs/tools/theme.md +42 -1
  21. package/docs/widget/Accordion.md +3 -0
  22. package/docs/widget/AdMob.md +4 -1
  23. package/docs/widget/AdSense.md +3 -0
  24. package/docs/widget/AnimatedBox.md +6 -3
  25. package/docs/widget/AnimatedText.md +3 -0
  26. package/docs/widget/Audio.md +3 -0
  27. package/docs/widget/Avatar.md +3 -0
  28. package/docs/widget/Badge.md +3 -0
  29. package/docs/widget/Button.md +3 -0
  30. package/docs/widget/COMMON_PROPS.md +52 -0
  31. package/docs/widget/Card.md +3 -0
  32. package/docs/widget/Carousel.md +3 -0
  33. package/docs/widget/Chart.md +3 -0
  34. package/docs/widget/Checkbox.md +3 -0
  35. package/docs/widget/Chip.md +3 -0
  36. package/docs/widget/CircularBar.md +3 -0
  37. package/docs/widget/CircularChart.md +3 -0
  38. package/docs/widget/CodeViewer.md +3 -0
  39. package/docs/widget/Column.md +3 -0
  40. package/docs/widget/Container.md +3 -0
  41. package/docs/widget/DataTable.md +3 -0
  42. package/docs/widget/Divider.md +3 -0
  43. package/docs/widget/DraggBox.md +3 -0
  44. package/docs/widget/Dropdown.md +3 -0
  45. package/docs/widget/DroppBox.md +3 -0
  46. package/docs/widget/FloatingActionButton.md +3 -0
  47. package/docs/widget/GridView.md +3 -0
  48. package/docs/widget/Icon.md +3 -0
  49. package/docs/widget/Image.md +3 -0
  50. package/docs/widget/Input.md +3 -0
  51. package/docs/widget/Inspector.md +2 -2
  52. package/docs/widget/InstallButton.md +3 -0
  53. package/docs/widget/ListTile.md +3 -0
  54. package/docs/widget/ListView.md +3 -0
  55. package/docs/widget/Maps.md +3 -0
  56. package/docs/widget/Markdown.md +3 -0
  57. package/docs/widget/MatrixRain.md +3 -0
  58. package/docs/widget/Pagination.md +3 -0
  59. package/docs/widget/ParallaxBox.md +3 -0
  60. package/docs/widget/ProgressBar.md +3 -0
  61. package/docs/widget/QRCode.md +3 -0
  62. package/docs/widget/README.md +2 -0
  63. package/docs/widget/Radio.md +4 -1
  64. package/docs/widget/Rating.md +3 -0
  65. package/docs/widget/Row.md +3 -0
  66. package/docs/widget/Skeleton.md +3 -0
  67. package/docs/widget/Slider.md +3 -0
  68. package/docs/widget/Stack.md +3 -0
  69. package/docs/widget/Stepper.md +3 -0
  70. package/docs/widget/Switch.md +3 -0
  71. package/docs/widget/Tabs.md +5 -5
  72. package/docs/widget/Text.md +3 -0
  73. package/docs/widget/Tooltip.md +3 -0
  74. package/docs/widget/TreeView.md +3 -0
  75. package/docs/widget/Video.md +3 -0
  76. package/package.json +2 -2
  77. package/src/animations/AnimatedBox.js +4 -1
  78. package/src/animations/MatrixRain.js +3 -3
  79. package/src/index.d.ts +64 -8
  80. package/src/index.js +1 -0
  81. package/src/navigations/AppBar.js +7 -12
  82. package/src/navigations/BottomNavigation.js +8 -13
  83. package/src/navigations/CollapsibleSideBar.js +30 -8
  84. package/src/navigations/Drawer.js +16 -14
  85. package/src/navigations/DrawerItem.js +3 -1
  86. package/src/navigations/Scaffold.js +26 -12
  87. package/src/navigations/Tabs.js +104 -59
  88. package/src/tools/color.js +54 -0
  89. package/src/tools/index.js +9 -1
  90. package/src/tools/scale.js +41 -0
  91. package/src/tools/shadow.js +20 -0
  92. package/src/utils/index.js +1 -0
  93. package/src/utils/readableInk.js +19 -0
  94. package/src/utils/syntaxHighlight.js +78 -10
  95. package/src/utils/themes.js +265 -58
  96. package/src/utils/units.js +33 -11
  97. package/src/widget-factory/effects.js +56 -5
  98. package/src/widget-factory/processProps.js +10 -1
  99. package/src/widget-factory/remTool.js +15 -29
  100. package/src/widget-factory/stackPosition.js +18 -3
  101. package/src/widget-factory/translateProps.js +28 -0
  102. package/src/widgets/Accordion.js +20 -23
  103. package/src/widgets/AdMob.js +7 -2
  104. package/src/widgets/Avatar.js +18 -12
  105. package/src/widgets/Badge.js +3 -2
  106. package/src/widgets/BottomSheet.js +14 -13
  107. package/src/widgets/Button.js +69 -30
  108. package/src/widgets/Card.js +6 -2
  109. package/src/widgets/Carousel.js +12 -10
  110. package/src/widgets/Chart.js +28 -5
  111. package/src/widgets/Checkbox.js +76 -18
  112. package/src/widgets/Chip.js +31 -10
  113. package/src/widgets/CircularBar.js +7 -7
  114. package/src/widgets/CircularChart.js +26 -8
  115. package/src/widgets/CodeViewer.js +93 -20
  116. package/src/widgets/DataTable.js +17 -11
  117. package/src/widgets/DraggBox.js +2 -1
  118. package/src/widgets/Dropdown.js +26 -13
  119. package/src/widgets/DroppBox.js +4 -2
  120. package/src/widgets/FloatingActionButton.js +13 -7
  121. package/src/widgets/Icon.js +3 -1
  122. package/src/widgets/Input.js +30 -23
  123. package/src/widgets/Inspector.js +54 -1
  124. package/src/widgets/InstallButton.js +6 -4
  125. package/src/widgets/ListTile.js +4 -3
  126. package/src/widgets/ListView.js +30 -12
  127. package/src/widgets/Markdown.js +37 -26
  128. package/src/widgets/Modal.js +14 -11
  129. package/src/widgets/Pagination.js +16 -11
  130. package/src/widgets/ProgressBar.js +6 -4
  131. package/src/widgets/Radio.js +73 -15
  132. package/src/widgets/Rating.js +24 -6
  133. package/src/widgets/Skeleton.js +31 -31
  134. package/src/widgets/Slider.js +29 -18
  135. package/src/widgets/SnackBar.js +35 -39
  136. package/src/widgets/Stepper.js +13 -9
  137. package/src/widgets/Switch.js +59 -18
  138. package/src/widgets/Tooltip.js +12 -10
  139. package/src/widgets/TreeView.js +27 -15
@@ -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
+ };
@@ -1,4 +1,6 @@
1
1
  // utils/syntaxHighlight.js
2
+ import { color } from "../tools/color.js";
3
+ import { getTheme } from "./themes.js";
2
4
 
3
5
  // ========== FLETBOX COLORS ==========
4
6
  const fletboxColors = {
@@ -118,22 +120,83 @@ const TokenType = {
118
120
  PUNCTUATION: "punctuation",
119
121
  FLETBOX_COLOR: "fletbox-color",
120
122
  FLETBOX_WIDGET: "fletbox-widget",
123
+ /** Anything the tokenizer did not classify, e.g. `=`, `=>`, `+`. */
124
+ TEXT: "text",
121
125
  };
122
126
 
123
127
  // ========== COLORS PER TOKEN ==========
124
- export const highlightColors = {
128
+ // Two palettes, chosen by the surface the code sits on. One Dark's values were
129
+ // designed for a dark pane and measure 1.6–2.3:1 on the light theme's #F4F4F5
130
+ // code block — the green string and the amber widget were the worst offenders.
131
+ // The light map is GitHub's light scale, which clears AA on that grey.
132
+ const DARK_HIGHLIGHT_COLORS = {
125
133
  [TokenType.KEYWORD]: "#c678dd", // purple
126
134
  [TokenType.STRING]: "#98c379", // green
127
135
  [TokenType.NUMBER]: "#d19a66", // orange
128
- [TokenType.COMMENT]: "#5c6370", // gray
136
+ // One Dark's #5C6370 measures 2.73:1 on the dark code block; a comment still
137
+ // has to be read, so the grey is the one GitHub paints comments with.
138
+ [TokenType.COMMENT]: "#8b949e",
129
139
  [TokenType.PROPERTY]: "#e06c75", // red
130
140
  [TokenType.FUNCTION]: "#61afef", // blue
131
141
  [TokenType.BRACKET]: "#abb2bf", // light gray
132
142
  [TokenType.PUNCTUATION]: "#abb2bf", // light gray
133
143
  [TokenType.FLETBOX_COLOR]: "#56b6c2", // cyan
134
144
  [TokenType.FLETBOX_WIDGET]: "#e5c07b", // yellow
145
+ // Raw characters used to inherit the page ink, which vanishes when the pane
146
+ // is painted in the opposite theme. They now carry the pane's own ink.
147
+ [TokenType.TEXT]: "#fafafa",
148
+ /** Muted ink for line numbers and similar chrome (not a token colour). */
149
+ muted: "#a1a1aa",
135
150
  };
136
151
 
152
+ const LIGHT_HIGHLIGHT_COLORS = {
153
+ [TokenType.KEYWORD]: "#cf222e", // red
154
+ [TokenType.STRING]: "#0a3069", // navy
155
+ [TokenType.NUMBER]: "#0550ae", // blue
156
+ [TokenType.COMMENT]: "#57606a", // gray
157
+ [TokenType.PROPERTY]: "#953800", // brown
158
+ [TokenType.FUNCTION]: "#8250df", // purple
159
+ [TokenType.BRACKET]: "#57606a", // gray
160
+ [TokenType.PUNCTUATION]: "#57606a", // gray
161
+ [TokenType.FLETBOX_COLOR]: "#0e7490", // teal
162
+ [TokenType.FLETBOX_WIDGET]: "#116329", // green
163
+ [TokenType.TEXT]: "#09090b",
164
+ muted: "#52525b",
165
+ };
166
+
167
+ export const highlightPalettes = {
168
+ light: LIGHT_HIGHLIGHT_COLORS,
169
+ dark: DARK_HIGHLIGHT_COLORS,
170
+ };
171
+
172
+ /**
173
+ * The map the highlighter exported before it became theme-aware. It is the dark
174
+ * palette, and it is still what a caller receives when it passes none.
175
+ */
176
+ export const highlightColors = DARK_HIGHLIGHT_COLORS;
177
+
178
+ const SOLID_BACKGROUND = /^(#[0-9a-f]{3,8}|rgba?\(\s*\d)/i;
179
+
180
+ /**
181
+ * Picks the syntax palette for the surface the code is painted on, by that
182
+ * surface's own lightness. A dark block on the light theme still gets readable
183
+ * spans, and the widget default (`colors.gray100`) lands on the right map in
184
+ * both themes. Falls back to the active theme when the background is not a
185
+ * solid colour this module can measure (a gradient, a CSS variable) — guessing
186
+ * there is worse than the theme's answer.
187
+ * @param {string} [background] - CSS background colour of the code block.
188
+ * @returns {Record<string, string>} Token colour map.
189
+ */
190
+ export const syntaxPaletteFor = (background) => {
191
+ if (typeof background === "string" && SOLID_BACKGROUND.test(background)) {
192
+ return color(background).isDark()
193
+ ? DARK_HIGHLIGHT_COLORS
194
+ : LIGHT_HIGHLIGHT_COLORS;
195
+ }
196
+ return highlightPalettes[getTheme()] || DARK_HIGHLIGHT_COLORS;
197
+ };
198
+
199
+
137
200
  // ========== DETECT FLETBOX COLORS ==========
138
201
  /**
139
202
  * Returns `true` when the given word represents a FletBox color token.
@@ -284,7 +347,7 @@ export const tokenize = (code) => {
284
347
  }
285
348
 
286
349
  // ========== ANY OTHER CHARACTER ==========
287
- tokens.push({ type: "text", value: char });
350
+ tokens.push({ type: TokenType.TEXT, value: char });
288
351
  i++;
289
352
  }
290
353
 
@@ -294,12 +357,17 @@ export const tokenize = (code) => {
294
357
  // ========== GENERATE HTML WITH COLORS ==========
295
358
  /**
296
359
  * Tokenizes `code` and wraps each token in a `<span>` with an inline `color`
297
- * style matching {@link highlightColors}. Whitespace and unrecognized tokens
298
- * are emitted without a wrapper.
360
+ * style from `palette`. Whitespace and unrecognized tokens are emitted without
361
+ * a wrapper.
299
362
  * @param {string} code - Source code to highlight.
363
+ * @param {Record<string, string>} [palette] - Token colour map; defaults to the
364
+ * palette of the active theme.
300
365
  * @returns {string} HTML string safe for insertion into `innerHTML`.
301
366
  */
302
- export const generateHighlightedHtml = (code) => {
367
+ export const generateHighlightedHtml = (
368
+ code,
369
+ palette = syntaxPaletteFor(),
370
+ ) => {
303
371
  const tokens = tokenize(code);
304
372
  let html = "";
305
373
 
@@ -307,9 +375,9 @@ export const generateHighlightedHtml = (code) => {
307
375
  if (token.type === "whitespace") {
308
376
  html += token.value;
309
377
  } else {
310
- const color = highlightColors[token.type];
311
- if (color) {
312
- html += `<span style="color: ${color};">${escapeHtml(token.value)}</span>`;
378
+ const tokenColor = palette[token.type];
379
+ if (tokenColor) {
380
+ html += `<span style="color: ${tokenColor};">${escapeHtml(token.value)}</span>`;
313
381
  } else {
314
382
  html += escapeHtml(token.value);
315
383
  }
@@ -336,4 +404,4 @@ const escapeHtml = (text) => {
336
404
  .replace(/'/g, "&#39;");
337
405
  };
338
406
 
339
- export default { tokenize, generateHighlightedHtml, highlightColors };
407
+ export default { tokenize, generateHighlightedHtml, highlightColors, highlightPalettes, syntaxPaletteFor };