flet-box 1.1.0 → 1.1.2

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 (135) hide show
  1. package/README.md +16 -12
  2. package/bin/commands/checkUi.js +276 -45
  3. package/bin/commands/create.js +8 -1
  4. package/bin/commands/createBundle.js +18 -6
  5. package/bin/commands/createScreen.js +1 -1
  6. package/bin/commands/runBundle.js +1 -1
  7. package/bin/utils/bundleConfig.js +47 -3
  8. package/bin/utils/colors.js +0 -8
  9. package/bin/utils/helpers.js +0 -5
  10. package/bin/utils/images/jpeg.js +2 -3
  11. package/bin/utils/templates.js +101 -85
  12. package/docs/README.md +24 -1
  13. package/docs/cli/README.md +51 -10
  14. package/docs/guides/README.md +5 -2
  15. package/docs/guides/accessibility.md +282 -0
  16. package/docs/guides/clean-ui.md +53 -8
  17. package/docs/guides/first-app.md +6 -7
  18. package/docs/guides/minification-and-protection.md +1 -1
  19. package/docs/guides/mobile-and-platforms.md +51 -0
  20. package/docs/guides/online-updates.md +18 -6
  21. package/docs/guides/utilities.md +116 -4
  22. package/docs/guides/widget-structure.md +30 -0
  23. package/docs/tools/README.md +1 -1
  24. package/docs/tools/animation.md +134 -11
  25. package/docs/tools/device.md +75 -3
  26. package/docs/tools/text-and-time.md +116 -6
  27. package/docs/widget/Accordion.md +2 -0
  28. package/docs/widget/AnimatedBox.md +46 -20
  29. package/docs/widget/AnimatedText.md +13 -7
  30. package/docs/widget/Badge.md +1 -0
  31. package/docs/widget/COMMON_PROPS.md +26 -3
  32. package/docs/widget/Chart.md +1 -0
  33. package/docs/widget/Checkbox.md +7 -1
  34. package/docs/widget/CircularChart.md +2 -0
  35. package/docs/widget/Drawer.md +1 -0
  36. package/docs/widget/GridView.md +4 -1
  37. package/docs/widget/Input.md +2 -0
  38. package/docs/widget/InstallButton.md +1 -0
  39. package/docs/widget/Markdown.md +4 -0
  40. package/docs/widget/Rating.md +2 -0
  41. package/docs/widget/Slider.md +2 -0
  42. package/docs/widget/Stack.md +6 -5
  43. package/docs/widget/Stepper.md +2 -0
  44. package/docs/widget/Tooltip.md +3 -1
  45. package/package.json +3 -2
  46. package/skills/fletbox-design/SKILL.md +390 -14
  47. package/skills/fletbox-motion/SKILL.md +145 -47
  48. package/skills/fletbox-rules/SKILL.md +140 -1
  49. package/src/animations/AnimatedBox.js +321 -226
  50. package/src/animations/MatrixRain.js +7 -1
  51. package/src/animations/ParallaxBox.js +13 -10
  52. package/src/animations/animate.js +269 -106
  53. package/src/animations/color.js +91 -0
  54. package/src/animations/easings.js +275 -0
  55. package/src/animations/index.js +21 -0
  56. package/src/animations/motion.js +204 -0
  57. package/src/animations/transitions.js +327 -0
  58. package/src/core/runApp.js +0 -6
  59. package/src/helpers/boxEdges.js +69 -0
  60. package/src/helpers/html.js +39 -0
  61. package/src/index.d.ts +377 -25
  62. package/src/index.js +77 -0
  63. package/src/navigations/AdaptiveScaffold.js +6 -2
  64. package/src/navigations/AppBar.js +16 -7
  65. package/src/navigations/BottomNavigation.js +24 -1
  66. package/src/navigations/CollapsibleSideBar.js +0 -1
  67. package/src/navigations/Drawer.js +32 -1
  68. package/src/navigations/DrawerItem.js +1 -1
  69. package/src/navigations/Router.js +0 -1
  70. package/src/navigations/Scaffold.js +9 -3
  71. package/src/navigations/Tabs.js +104 -31
  72. package/src/services/RamStore.js +1 -2
  73. package/src/tools/breakpoints.js +113 -0
  74. package/src/tools/createList.js +0 -2
  75. package/src/tools/fonts.js +99 -0
  76. package/src/tools/index.js +74 -1
  77. package/src/tools/margin.js +5 -63
  78. package/src/tools/padding.js +5 -63
  79. package/src/tools/print.js +0 -8
  80. package/src/tools/scale.js +9 -2
  81. package/src/tools/time.js +56 -15
  82. package/src/tools/useState.js +1 -54
  83. package/src/utils/fieldFont.js +77 -0
  84. package/src/utils/focusRing.js +73 -0
  85. package/src/utils/focusScope.js +113 -0
  86. package/src/utils/markdownParser.js +7 -9
  87. package/src/utils/readableInk.js +7 -1
  88. package/src/utils/stackLayout.js +55 -0
  89. package/src/utils/styleInjector.js +25 -0
  90. package/src/utils/syntaxHighlight.js +1 -17
  91. package/src/utils/themes.js +15 -0
  92. package/src/utils/visualEffects.js +49 -47
  93. package/src/widget-factory/createWidget.js +16 -3
  94. package/src/widget-factory/effects.js +76 -3
  95. package/src/widget-factory/reactiveValue.js +0 -7
  96. package/src/widget-factory/tools.js +26 -143
  97. package/src/widget-factory/translateProps.js +5 -0
  98. package/src/widget-factory/widgetFactory.js +1 -1
  99. package/src/widgets/Accordion.js +70 -12
  100. package/src/widgets/AdMob.js +1 -1
  101. package/src/widgets/AdSense.js +1 -1
  102. package/src/widgets/AlertDialog.js +11 -9
  103. package/src/widgets/Avatar.js +10 -1
  104. package/src/widgets/Badge.js +7 -1
  105. package/src/widgets/BottomSheet.js +39 -5
  106. package/src/widgets/Chart.js +41 -9
  107. package/src/widgets/Checkbox.js +59 -9
  108. package/src/widgets/Chip.js +13 -1
  109. package/src/widgets/CircularBar.js +17 -5
  110. package/src/widgets/CircularChart.js +24 -2
  111. package/src/widgets/CodeViewer.js +5 -3
  112. package/src/widgets/Dropdown.js +223 -20
  113. package/src/widgets/GridView.js +17 -1
  114. package/src/widgets/Icon.js +21 -0
  115. package/src/widgets/Input.js +75 -44
  116. package/src/widgets/InstallButton.js +8 -2
  117. package/src/widgets/ListTile.js +6 -5
  118. package/src/widgets/ListView.js +21 -7
  119. package/src/widgets/Markdown.js +25 -44
  120. package/src/widgets/Modal.js +43 -6
  121. package/src/widgets/Pagination.js +35 -3
  122. package/src/widgets/ProgressBar.js +18 -0
  123. package/src/widgets/Radio.js +7 -0
  124. package/src/widgets/Rating.js +104 -16
  125. package/src/widgets/Skeleton.js +9 -5
  126. package/src/widgets/Slider.js +87 -7
  127. package/src/widgets/SnackBar.js +30 -1
  128. package/src/widgets/Stack.js +25 -13
  129. package/src/widgets/Stepper.js +112 -64
  130. package/src/widgets/Switch.js +10 -1
  131. package/src/widgets/Tooltip.js +93 -10
  132. package/src/widgets/TreeView.js +5 -5
  133. package/src/create-index.sh +0 -237
  134. package/src/widget-factory/pixTool.js +0 -40
  135. package/src/widget-factory/stringTool.js +0 -26
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @file fieldFont.js
3
+ * @description
4
+ * Keeps a focused text field from zooming the page on iOS.
5
+ *
6
+ * WebKit magnifies the whole viewport when a text control whose computed
7
+ * `font-size` is below 16px takes focus, so typing in a compiled iOS app made
8
+ * the screen grow and stay deformed. The design sizes here are framework units
9
+ * (12 / 14 / 16 for `small` / `medium` / `large`), and two of them sit under
10
+ * that threshold.
11
+ *
12
+ * The fix is a floor, not a new size: on iOS the field text is at least 16px
13
+ * while everything else keeps its rem value. The widget writes the length it
14
+ * paints with into `--fb-field-font`, so a reader who raises the browser font
15
+ * still gets a larger field (medium becomes 17.5px at a 20px root) — a flat
16
+ * `16px` would have shrunk that field instead of merely flooring it.
17
+ *
18
+ * The rule arrives in a stylesheet because an inline `font-size` outranks one,
19
+ * and it is scoped to `html[data-fb-ios]` so every other platform keeps the
20
+ * design's own sizes. That attribute also gives the regression harness a switch
21
+ * it can flip without an iPhone.
22
+ *
23
+ * @module fieldFont
24
+ */
25
+
26
+ /** `id` of the injected tag, so the guard reaches it after HMR or a `document.write`. */
27
+ const STYLE_ID = "fletbox-field-font";
28
+
29
+ /** WebKit's auto-zoom threshold: a focused control under 16px scales the page. */
30
+ const MIN_FIELD_PX = 16;
31
+
32
+ /**
33
+ * Whether these navigator values describe iOS.
34
+ *
35
+ * iPadOS 13 and later report a desktop Mac user agent, so the touch points are
36
+ * what separate an iPad from a MacBook: a Mac reports 0, an iPad reports 5.
37
+ *
38
+ * @param {string} [ua] - `navigator.userAgent`.
39
+ * @param {string} [platform] - `navigator.platform`.
40
+ * @param {number} [touchPoints] - `navigator.maxTouchPoints`.
41
+ * @returns {boolean}
42
+ */
43
+ export const looksLikeIOS = (ua = "", platform = "", touchPoints = 0) => {
44
+ if (/iPad|iPhone|iPod/.test(ua)) return true;
45
+ return platform === "MacIntel" && touchPoints > 1;
46
+ };
47
+
48
+ const runningOnIOS = () =>
49
+ typeof navigator !== "undefined" &&
50
+ looksLikeIOS(navigator.userAgent || "", navigator.platform || "", navigator.maxTouchPoints || 0);
51
+
52
+ const FIELD_FONT_RULE = `html[data-fb-ios] [data-fb-field] input,
53
+ html[data-fb-ios] [data-fb-field] textarea {
54
+ font-size: max(${MIN_FIELD_PX}px, var(--fb-field-font, 1rem)) !important;
55
+ }`;
56
+
57
+ /**
58
+ * Marks the document as iOS and injects the field floor, once.
59
+ *
60
+ * The attribute is re-checked on every call because it is what the rule keys
61
+ * on; the `<style>` tag is the part that must not be duplicated. No-ops outside
62
+ * a browser, so the package stays importable in Node.
63
+ *
64
+ * @returns {void}
65
+ */
66
+ export function ensureFieldFont() {
67
+ if (typeof document === "undefined") return;
68
+ if (runningOnIOS()) document.documentElement.setAttribute("data-fb-ios", "");
69
+ if (document.getElementById(STYLE_ID)) return;
70
+
71
+ const style = document.createElement("style");
72
+ style.id = STYLE_ID;
73
+ style.textContent = FIELD_FONT_RULE;
74
+ document.head.appendChild(style);
75
+ }
76
+
77
+ export default ensureFieldFont;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file focusRing.js
3
+ * @description
4
+ * The one focus indicator the framework paints, injected once per document.
5
+ *
6
+ * A ring cannot be an inline style: `:focus-visible` is a *state*, and a state
7
+ * only exists in a stylesheet. So this module plays the same role for the
8
+ * keyboard that `styleInjector.js` plays for `@keyframes` and `motion.js` plays
9
+ * for reduced motion — one owned `<style>` tag in `<head>`, inserted the first
10
+ * time a widget is created and never again.
11
+ *
12
+ * The color is `var(--color-focusRing)`, which `applyPalette` already writes for
13
+ * every theme token, so the ring moves with the theme without this module
14
+ * knowing anything about themes. The literal after the comma is the boot
15
+ * palette's value: it is what a page shows before the first `setTheme`.
16
+ *
17
+ * Two deliberate choices:
18
+ * - `:where(...)` keeps specificity at zero, so a widget or an app that wants a
19
+ * different ring overrides it with one class instead of an `!important`.
20
+ * - The widths are pixels, not rem, and belong with `SHADOW_LEVELS` and the
21
+ * hairline `borderWidth` on the list of honest pixels: a 1px ring is invisible
22
+ * and a 6px one is a blob, whatever the reader's font size is.
23
+ *
24
+ * @module focusRing
25
+ */
26
+
27
+ /** `id` of the injected tag, so the guard reaches it after HMR or a `document.write`. */
28
+ const STYLE_ID = "fletbox-focus-ring";
29
+
30
+ /**
31
+ * The rule. `[tabindex]:not([tabindex="-1"])` is what makes an element the
32
+ * engine turned pressable show up here; `-1` is an element that can be focused
33
+ * by script but is not a stop on the Tab path, and must not announce one.
34
+ *
35
+ * The second pair belongs to a composite field (`Input`). Its native `<input>`
36
+ * is the thing that holds focus, but it is not the thing a person sees: the
37
+ * field is a wrapper with a border, an icon and maybe a trailing button, and a
38
+ * ring drawn around the text box alone cuts across that control. So the ring
39
+ * moves to the wrapper — which is what `data-fb-field` marks — and the inner
40
+ * box is silenced by a rule, never by an inline `outline` an app could never
41
+ * override.
42
+ */
43
+ const FOCUS_RULE = `:where(a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
44
+ outline: 2px solid var(--color-focusRing, #1d4ed8);
45
+ outline-offset: 2px;
46
+ }
47
+ :where([data-fb-field]):has(> input:focus-visible) {
48
+ outline: 2px solid var(--color-focusRing, #1d4ed8);
49
+ outline-offset: 2px;
50
+ }
51
+ :where([data-fb-field]) > input:focus-visible {
52
+ outline: none;
53
+ }`;
54
+
55
+ /**
56
+ * Appends the focus rule to `<head>` unless it is already there.
57
+ *
58
+ * No-ops outside a browser, like every other document-touching utility here, so
59
+ * the package stays importable in Node.
60
+ *
61
+ * @returns {void}
62
+ */
63
+ export function ensureFocusRing() {
64
+ if (typeof document === "undefined") return;
65
+ if (document.getElementById(STYLE_ID)) return;
66
+
67
+ const style = document.createElement("style");
68
+ style.id = STYLE_ID;
69
+ style.textContent = FOCUS_RULE;
70
+ document.head.appendChild(style);
71
+ }
72
+
73
+ export default ensureFocusRing;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @file focusScope.js
3
+ * @description
4
+ * The keyboard half of a modal: while a panel is open, Tab walks around *inside*
5
+ * it, and when it closes focus goes back where it came from.
6
+ *
7
+ * Escape already exists in the three overlays — the mouse can dismiss them. What
8
+ * none of them did was hold the Tab key, so a keyboard user pressing Tab on an
9
+ * open Modal walked out of the dialog, behind the scrim, into the page they could
10
+ * no longer see or reach, and had no way back except tabbing through the whole
11
+ * document. That is the defect this module closes, and it is the reason the three
12
+ * overlays share one implementation instead of each writing its own listener.
13
+ *
14
+ * Deliberately **not** handled here: Escape. Every overlay already listens for it
15
+ * on `document`, and a second listener would fire the close twice — an `onClose`
16
+ * that runs twice is a state update that happens twice.
17
+ *
18
+ * @module focusScope
19
+ */
20
+
21
+ /**
22
+ * What the browser would let a person land on: HTML's own reachable elements,
23
+ * plus whatever the engine put on the Tab path. `tabindex="-1"` is excluded —
24
+ * that is the document's own words for "focusable by script, not a stop".
25
+ *
26
+ * @type {string}
27
+ */
28
+ export const FOCUSABLE_SELECTOR = [
29
+ "a[href]",
30
+ "button",
31
+ "input",
32
+ "select",
33
+ "textarea",
34
+ "summary",
35
+ '[tabindex]:not([tabindex="-1"])',
36
+ ].join(",");
37
+
38
+ /**
39
+ * Traps the Tab key inside `container` until the returned function is called.
40
+ *
41
+ * @param {HTMLElement} container - The open panel to keep the keyboard in.
42
+ * @returns {function(): void} Idempotent release: removes the listener and puts
43
+ * focus back on whatever was focused before the panel opened. Call it from the
44
+ * widget's `onUnmount` disposer like every other registered resource.
45
+ */
46
+ export function trapFocus(container) {
47
+ if (typeof document === "undefined" || !container) return () => {};
48
+
49
+ const previous = document.activeElement;
50
+ let released = false;
51
+
52
+ // `offsetParent` is null for a `position: fixed` element, which is what every
53
+ // overlay is, so the usual visibility test would filter out the whole panel.
54
+ // A box that renders has client rects, hidden or not.
55
+ const reachable = () =>
56
+ [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(
57
+ (el) => el.getClientRects().length > 0,
58
+ );
59
+
60
+ const onKeyDown = (event) => {
61
+ if (event.key !== "Tab") return;
62
+ const stops = reachable();
63
+ if (stops.length === 0) {
64
+ // Nothing to reach: keep the panel itself focused rather than let Tab
65
+ // escape to the page behind it.
66
+ event.preventDefault();
67
+ return;
68
+ }
69
+ const first = stops[0];
70
+ const last = stops[stops.length - 1];
71
+ const active = document.activeElement;
72
+
73
+ if (event.shiftKey && (active === first || active === container)) {
74
+ event.preventDefault();
75
+ last.focus();
76
+ } else if (!event.shiftKey && active === last) {
77
+ event.preventDefault();
78
+ first.focus();
79
+ }
80
+ };
81
+
82
+ container.addEventListener("keydown", onKeyDown);
83
+
84
+ // A panel with no control in it (an alert with two lines of text) still has to
85
+ // take the keyboard, or the trap has nothing to hold. `-1` is the honest value:
86
+ // it is focusable because the dialog is open, not a stop on the Tab path.
87
+ const stops = reachable();
88
+ if (stops.length > 0) {
89
+ stops[0].focus();
90
+ } else if (!container.hasAttribute("tabindex")) {
91
+ container.tabIndex = -1;
92
+ container._focusScopeMadeTabbable = true;
93
+ container.focus();
94
+ }
95
+
96
+ return () => {
97
+ if (released) return;
98
+ released = true;
99
+ container.removeEventListener("keydown", onKeyDown);
100
+ if (container._focusScopeMadeTabbable) {
101
+ container.removeAttribute("tabindex");
102
+ delete container._focusScopeMadeTabbable;
103
+ }
104
+ // Only hand focus back if the element it came from is still there: a route
105
+ // change that replaced the page while the dialog was open has nothing to go
106
+ // back to, and focusing a detached node is a silent no-op at best.
107
+ if (previous && previous.isConnected && typeof previous.focus === "function") {
108
+ previous.focus();
109
+ }
110
+ };
111
+ }
112
+
113
+ export default trapFocus;
@@ -6,6 +6,7 @@ import { Row } from "../widgets/Row.js";
6
6
  import { Icon } from "../widgets/Icon.js";
7
7
  import { CodeViewer } from "../widgets/CodeViewer.js";
8
8
  import { colors } from "./themes.js";
9
+ import { escapeHtml } from "../helpers/html.js";
9
10
 
10
11
  // ========== TABLES ==========
11
12
  const isTableLine = (line) => {
@@ -346,16 +347,13 @@ export const parseInlineMarkdown = (text) => text;
346
347
  /**
347
348
  * Escapes HTML special characters to prevent injection when inserting into innerHTML.
348
349
  * Converts `& < > " '` to their corresponding HTML entities.
349
- * @param {string} text - Raw text to escape.
350
- * @returns {string} HTML-safe string.
350
+ *
351
+ * Re-exported from the shared escaper so this module and the widgets that build
352
+ * HTML share one implementation; the name stays public here because
353
+ * `parseMarkdown` has always published it.
354
+ * @type {import("../helpers/html.js").escapeHtml}
351
355
  */
352
- export const escapeHtml = (text) =>
353
- String(text)
354
- .replace(/&/g, "&amp;")
355
- .replace(/</g, "&lt;")
356
- .replace(/>/g, "&gt;")
357
- .replace(/"/g, "&quot;")
358
- .replace(/'/g, "&#39;");
356
+ export { escapeHtml };
359
357
 
360
358
  // Default export
361
359
  export default {
@@ -1,7 +1,13 @@
1
1
  import { color } from "../tools/color.js";
2
2
 
3
+ // A fill this code can measure: a hex, an opaque `rgb()`/`hsl()`, or a named color.
4
+ // The `$` anchors the whole alternation, so each branch has to match the *entire*
5
+ // value — the `rgba?\(\s*\d` this file used to carry could only ever match the
6
+ // three-character string "rgb", which made every functional color fall through to
7
+ // the caller's default. An alpha'd fill stays unmeasured on purpose: what reads on
8
+ // it depends on what is behind it.
3
9
  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;
10
+ /^(#[0-9a-f]{3,8}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|hsl\(\s*[\d.]+(?:deg)?\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*\)|black|white|red|green|blue|yellow|cyan|magenta|gray|darkgray|lightgray)$/i;
5
11
 
6
12
  /**
7
13
  * Black or white, whichever reads better on `background` by WCAG contrast.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file stackLayout.js
3
+ * @description Makes a `Stack` paint its children in the same box.
4
+ *
5
+ * `Stack` is documented as the overlay container and `alignment` as the corner
6
+ * every child is anchored to. Neither was true: the children arrived in normal
7
+ * flow, so the second one landed under the first and the only way to actually
8
+ * layer them was for each child to declare a `position` of its own.
9
+ *
10
+ * The rule takes every child but the first out of the flow. The first stays in
11
+ * it because a Stack with no explicit `width`/`height` has to be sized by
12
+ * something: with every child floating the box collapses to 0px and the overlay
13
+ * disappears, which is the same bug wearing a different symptom.
14
+ *
15
+ * A floating child that names no edge is placed at the container's `alignment`
16
+ * point for free — an absolutely positioned child of a flex container takes its
17
+ * static position from that container's own `justify-content`/`align-items`, so
18
+ * the nine-point map in `Stack.js` anchors the layers without a second set of
19
+ * rules. A child that does name an edge (`top`, `right`, …) keeps its own
20
+ * declaration, and one that declares `position` inline outranks this rule, so
21
+ * opting back into the flow is possible.
22
+ *
23
+ * It lives in a stylesheet instead of on each child because `update({ children })`
24
+ * rebuilds them: styles written onto the nodes would leave with the nodes, and a
25
+ * rule reaches whatever arrives next.
26
+ *
27
+ * @module stackLayout
28
+ */
29
+
30
+ /** `id` of the injected tag, so a guard can count it after HMR. */
31
+ const STYLE_ID = "fletbox-stack";
32
+
33
+ /** One rule, no lengths: nothing here scales with the root font size. */
34
+ const STACK_RULE = `[data-fb-stack] > :not(:first-child) {
35
+ position: absolute;
36
+ }`;
37
+
38
+ /**
39
+ * Inject the overlay rule, once per document.
40
+ *
41
+ * No-ops outside a browser, so the package stays importable in Node.
42
+ *
43
+ * @returns {void}
44
+ */
45
+ export function ensureStackLayout() {
46
+ if (typeof document === "undefined") return;
47
+ if (document.getElementById(STYLE_ID)) return;
48
+
49
+ const style = document.createElement("style");
50
+ style.id = STYLE_ID;
51
+ style.textContent = STACK_RULE;
52
+ document.head.appendChild(style);
53
+ }
54
+
55
+ export default ensureStackLayout;
@@ -14,6 +14,31 @@
14
14
  /** Animation names already injected in this document. */
15
15
  const injected = new Set();
16
16
 
17
+ /**
18
+ * Stable name derived from a keyframe body.
19
+ *
20
+ * `@keyframes` is global, so two effects that build their body from their own
21
+ * options must not share a name: the first injection would win for both, and a
22
+ * glow in red would come out blue. Hashing the body gives every distinct
23
+ * animation its own name and lets identical ones reuse a single `<style>` — the
24
+ * same rule that keeps `AnimatedText` (one animation per letter) from appending
25
+ * a tag per letter.
26
+ *
27
+ * @param {string} prefix - Human-readable head, e.g. `"glow-pulse"`.
28
+ * @param {string} frames - The keyframe body that determines the motion.
29
+ * @returns {string} A CSS-safe identifier, e.g. `"glow-pulse-1a2b3c4d5e"`.
30
+ */
31
+ export const animationName = (prefix, frames) => {
32
+ let h1 = 0x811c9dc5;
33
+ let h2 = 0x01000193;
34
+ for (let i = 0; i < frames.length; i++) {
35
+ const c = frames.charCodeAt(i);
36
+ h1 = Math.imul(h1 ^ c, 0x01000193);
37
+ h2 = Math.imul(h2 + c, 0x85ebca6b) ^ (h2 >>> 13);
38
+ }
39
+ return `${prefix}-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
40
+ };
41
+
17
42
  /**
18
43
  * Injects `@keyframes <name> { <frames> }` into `<head>` the first time the
19
44
  * given name is requested. Subsequent calls for the same name are no-ops.
@@ -1,6 +1,7 @@
1
1
  // utils/syntaxHighlight.js
2
2
  import { color } from "../tools/color.js";
3
3
  import { getTheme } from "./themes.js";
4
+ import { escapeHtml } from "../helpers/html.js";
4
5
 
5
6
  // ========== FLETBOX COLORS ==========
6
7
  const fletboxColors = {
@@ -387,21 +388,4 @@ export const generateHighlightedHtml = (
387
388
  return html;
388
389
  };
389
390
 
390
- // ========== ESCAPE HTML ==========
391
- /**
392
- * Escapes HTML special characters so that token values can be safely inserted
393
- * into a `<span>` element's content.
394
- * @param {string} text - Raw token text.
395
- * @returns {string} HTML-safe string.
396
- * @private
397
- */
398
- const escapeHtml = (text) => {
399
- return text
400
- .replace(/&/g, "&amp;")
401
- .replace(/</g, "&lt;")
402
- .replace(/>/g, "&gt;")
403
- .replace(/"/g, "&quot;")
404
- .replace(/'/g, "&#39;");
405
- };
406
-
407
391
  export default { tokenize, generateHighlightedHtml, highlightColors, highlightPalettes, syntaxPaletteFor };
@@ -55,6 +55,14 @@ const DEFAULT_COLORS = {
55
55
  glass: "rgba(255, 255, 255, 0.66)",
56
56
  glassBorder: "rgba(255, 255, 255, 0.75)",
57
57
  border: "#e2e8f0",
58
+ // The ring around whatever the keyboard is on. It is a token for the same
59
+ // reason the rim is one: the theme has to be able to move it. It answers to
60
+ // WCAG 2.2's 3:1 against the surface it is drawn on, which `#1d4ed8` clears at
61
+ // 6.4:1 on this boot page and 6.4:1 on its `surface`. It is deliberately not
62
+ // `primary`: in both shipped palettes the brand plate is neutral zinc, so a
63
+ // brand-coloured ring would read as "this is selected" instead of "this is
64
+ // where my keys are".
65
+ focusRing: "#1d4ed8",
58
66
  gray50: "#f8fafc",
59
67
  gray100: "#f1f5f9",
60
68
  gray200: "#e2e8f0",
@@ -135,6 +143,10 @@ export const palettes = {
135
143
  textSecondary: "#52525b",
136
144
  textDisabled: "#a1a1aa",
137
145
  border: "#e4e4e7",
146
+ // The blue is this palette's own `infoStrong`, measured 6.70:1 on the page,
147
+ // 6.42:1 on `surface` and 6.10:1 on `primaryBg` — over the 3:1 a focus
148
+ // indicator needs wherever it lands, and never mistaken for the zinc plate.
149
+ focusRing: "#1d4ed8",
138
150
  gray50: "#fafafa",
139
151
  gray100: "#f4f4f5",
140
152
  gray200: "#e4e4e7",
@@ -211,6 +223,9 @@ export const palettes = {
211
223
  textSecondary: "#a1a1aa",
212
224
  textDisabled: "#52525b",
213
225
  border: "#27272a",
226
+ // Points the other way down the ladder like every other token here: this is
227
+ // dark's `infoStrong`, 11.03:1 on the page and 9.82:1 on a card.
228
+ focusRing: "#93c5fd",
214
229
  gray50: "#111111",
215
230
  gray100: "#1f1f1f",
216
231
  gray200: "#27272a",
@@ -4,21 +4,15 @@
4
4
  * @module utils/visualEffects
5
5
  */
6
6
 
7
- // Cache for injected keyframes
8
- const injectedKeyframes = new Set();
7
+ import { prefersReducedMotion, markMotionLoop } from "../animations/motion.js";
8
+ import { injectKeyframes, animationName } from "./styleInjector.js";
9
9
 
10
10
  /**
11
- * Inject CSS keyframes if not already injected
12
- * @param {string} name - Animation name
13
- * @param {string} keyframes - CSS keyframes
11
+ * Re-exported for callers that imported it from here before the injection was
12
+ * centralised: the implementation and the dedupe set are the shared ones in
13
+ * `utils/styleInjector.js`, never a second set local to this file.
14
14
  */
15
- export const injectKeyframes = (name, keyframes) => {
16
- if (injectedKeyframes.has(name)) return;
17
- const style = document.createElement("style");
18
- style.textContent = `@keyframes ${name} { ${keyframes} }`;
19
- document.head.appendChild(style);
20
- injectedKeyframes.add(name);
21
- };
15
+ export { injectKeyframes };
22
16
 
23
17
  /**
24
18
  * Apply stripe pattern to an element
@@ -46,15 +40,20 @@ export const applyStripes = (element, options = {}) => {
46
40
  element.style.backgroundSize = `${size * 2}px ${size * 2}px`;
47
41
 
48
42
  if (animated) {
49
- element.style.animation = `progress-stripes ${duration} linear infinite`;
50
-
51
- injectKeyframes(
52
- "progress-stripes",
53
- `
43
+ // The body depends on `size`, so the name is derived from it: two stripe
44
+ // widths are two animations, not one animation whose first width wins.
45
+ const frames = `
54
46
  0% { background-position: 0 0; }
55
47
  100% { background-position: ${size * 2}px 0; }
56
- `,
57
- );
48
+ `;
49
+ const name = animationName("stripes", frames);
50
+ element.style.animation = `${name} ${duration} linear infinite`;
51
+ // A `infinite` CSS loop is stopped by the media rule, not by a JS check: the
52
+ // check reads the preference when the effect is applied, so a loop already
53
+ // running when the reader flips the OS setting would keep running.
54
+ markMotionLoop(element);
55
+
56
+ injectKeyframes(name, frames);
58
57
  }
59
58
  };
60
59
 
@@ -85,15 +84,15 @@ export const applyShimmer = (element, options = {}) => {
85
84
  ${baseColor} 100%
86
85
  )`;
87
86
  element.style.backgroundSize = "200% 100%";
88
- element.style.animation = `shimmer ${duration} ease-in-out infinite`;
89
-
90
- injectKeyframes(
91
- "shimmer",
92
- `
87
+ const frames = `
93
88
  0% { background-position: 200% 0; }
94
89
  100% { background-position: -200% 0; }
95
- `,
96
- );
90
+ `;
91
+ const name = animationName("shimmer", frames);
92
+ element.style.animation = `${name} ${duration} ease-in-out infinite`;
93
+ markMotionLoop(element);
94
+
95
+ injectKeyframes(name, frames);
97
96
  };
98
97
 
99
98
  /**
@@ -107,16 +106,16 @@ export const applyGlow = (element, options = {}) => {
107
106
  } = options;
108
107
 
109
108
  element.style.transition = "box-shadow 0.2s ease";
110
- element.style.animation = `glow-pulse ${duration} ease-in-out infinite`;
111
-
112
- injectKeyframes(
113
- "glow-pulse",
114
- `
109
+ const frames = `
115
110
  0% { box-shadow: 0 0 0 ${color}; }
116
111
  50% { box-shadow: 0 0 ${intensity}px ${color}; }
117
112
  100% { box-shadow: 0 0 0 ${color}; }
118
- `,
119
- );
113
+ `;
114
+ const name = animationName("glow-pulse", frames);
115
+ element.style.animation = `${name} ${duration} ease-in-out infinite`;
116
+ markMotionLoop(element);
117
+
118
+ injectKeyframes(name, frames);
120
119
  };
121
120
 
122
121
  /**
@@ -127,16 +126,16 @@ export const applyIndeterminate = (element, options = {}) => {
127
126
 
128
127
  element.style.position = "relative";
129
128
  element.style.width = width;
130
- element.style.animation = `indeterminate-progress ${duration} ease-in-out infinite`;
131
-
132
- injectKeyframes(
133
- "indeterminate-progress",
134
- `
129
+ const frames = `
135
130
  0% { transform: translateX(-100%); }
136
131
  50% { transform: translateX(0%); }
137
132
  100% { transform: translateX(100%); }
138
- `,
139
- );
133
+ `;
134
+ const name = animationName("indeterminate-progress", frames);
135
+ element.style.animation = `${name} ${duration} ease-in-out infinite`;
136
+ markMotionLoop(element);
137
+
138
+ injectKeyframes(name, frames);
140
139
  };
141
140
 
142
141
  /**
@@ -145,17 +144,20 @@ export const applyIndeterminate = (element, options = {}) => {
145
144
  export const applyPulse = (element, options = {}) => {
146
145
  const { scale = 1.05, duration = "0.3s" } = options;
147
146
 
148
- element.style.transition = `transform ${duration} ease`;
149
- element.style.animation = `pulse ${duration} ease-in-out`;
147
+ // A pulse is decoration saying "look here", so reduced motion removes it rather
148
+ // than shortening it — the element is left exactly as it was.
149
+ if (prefersReducedMotion()) return;
150
150
 
151
- injectKeyframes(
152
- "pulse",
153
- `
151
+ element.style.transition = `transform ${duration} ease`;
152
+ const frames = `
154
153
  0% { transform: scale(1); }
155
154
  50% { transform: scale(${scale}); }
156
155
  100% { transform: scale(1); }
157
- `,
158
- );
156
+ `;
157
+ const name = animationName("pulse", frames);
158
+ element.style.animation = `${name} ${duration} ease-in-out`;
159
+
160
+ injectKeyframes(name, frames);
159
161
  };
160
162
 
161
163
  export default {
@@ -1,5 +1,8 @@
1
1
  // core/createWidget.js
2
2
 
3
+ import { ensureFocusRing } from "../utils/focusRing.js";
4
+ import { ensureFieldFont } from "../utils/fieldFont.js";
5
+
3
6
  /**
4
7
  * Creates a bare DOM element for use as a widget.
5
8
  *
@@ -8,12 +11,22 @@
8
11
  * rely on for listener tracking. No props are applied here — that is the
9
12
  * responsibility of `assignProps`.
10
13
  *
11
- * @param {string} tag - HTML tag name for the element (e.g. `'div'`, `'button'`).
14
+ * It is also where the document gets its focus rule: this is the one step every
15
+ * widget passes through, and a ring has to be in the stylesheet before anyone
16
+ * can press Tab.
17
+ *
18
+ * The field-font floor rides along for the same reason: it is a document-level
19
+ * rule about text fields, and this is the only step that runs exactly once per
20
+ * widget without being owned by any of them.
21
+ *
22
+ * @param {string} tag - HTML tag name for the widget.
12
23
  * Defaults to `'div'` when the value is not a string.
13
- * @param {Object} props - Reserved for future use; currently unused.
14
24
  * @returns {HTMLElement} The newly created DOM element with `_events` initialised.
15
25
  */
16
- export const createWidget = (tag, props) => {
26
+ export const createWidget = (tag) => {
27
+ ensureFocusRing();
28
+ ensureFieldFont();
29
+
17
30
  // Validateste that tag is a string; fall back to 'div' when it is not.
18
31
  let finalTag = tag;
19
32
  if (typeof finalTag !== "string") {