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
@@ -1,12 +1,39 @@
1
1
  // src/components/flet-box/utils/themes.js
2
+ import { readableInk } from "./readableInk.js";
2
3
 
3
4
  // ========== DEFAULT COLORS (static, safe) ==========
4
5
  const DEFAULT_COLORS = {
5
- primary: "#6366f1",
6
+ // #6366f1 put white on 4.47:1 — a hair under AA for a button label. Two units
7
+ // darker measures 4.55:1, the same indigo, and every brand button passes.
8
+ primary: "#6165ef",
6
9
  secondary: "#8b5cf6",
7
10
  success: "#10b981",
8
11
  warning: "#f59e0b",
12
+ // The readable greens and ambers, same rule as the red below: #10b981 and
13
+ // #f59e0b carry white at 2.54:1 and 2.15:1, so they are dots and borders. These
14
+ // are the ones a person can read or press — 5.48:1 and 5.02:1, both directions.
15
+ successStrong: "#047857",
16
+ warningStrong: "#b45309",
17
+ // #ef4444 is the error *signal*: a badge, an icon, a border. As words on a
18
+ // white page it measures 3.76:1, which is not AA, and white on it is 3.76 too.
9
19
  danger: "#ef4444",
20
+ // The red a person can read and press: 6.47:1 against white in both directions,
21
+ // so it is legal as the label of a delete and as the plate under white words.
22
+ dangerStrong: "#b91c1c",
23
+ // `*Strong` answers "what may I write words in?" and its answer moves with the
24
+ // theme; `*Plate` answers "what may I fill, with white words on it?" and stays
25
+ // deep everywhere. On a light page the two are the same red — #b91c1c reads at
26
+ // 6.47:1 and carries white at 6.47:1 — so the light values below coincide with
27
+ // the Strong ones on purpose. On a dark page they part: the readable red is
28
+ // light, the plate behind white has to be dark.
29
+ successPlate: "#047857",
30
+ warningPlate: "#b45309",
31
+ dangerPlate: "#b91c1c",
32
+ // `info` had no readable sibling and it showed: a filled chip, a selected page
33
+ // or a tooltip that asks for `colors.info` paints white on #3b82f6, which is
34
+ // 3.68:1. This is the blue that carries the label — 6.70:1 in both directions.
35
+ infoStrong: "#1d4ed8",
36
+ infoPlate: "#1d4ed8",
10
37
  info: "#3b82f6",
11
38
  background: "#ffffff",
12
39
  surface: "#f8fafc",
@@ -14,6 +41,19 @@ const DEFAULT_COLORS = {
14
41
  text: "#0f172a",
15
42
  textSecondary: "#64748b",
16
43
  textDisabled: "#94a3b8",
44
+ // What to write on a `primary` fill. White measures 4.55:1 on #6165ef.
45
+ onPrimary: "#ffffff",
46
+ // What to write on a `secondary` fill (badges, tooltips). Neutral secondary is
47
+ // a dark plate in both themes, so its ink is white as well.
48
+ onSecondary: "#ffffff",
49
+ // The soft brand tint a card or icon chip uses instead of `primary + '15'`.
50
+ primaryBg: "#eef2ff",
51
+ // Glass is a translucent surface meant to sit over content with
52
+ // `backdropFilter`. It is a token and not an `rgba()` in the app for the same
53
+ // reason as every other color: the theme has to be able to move it, and the
54
+ // app's clean-UI gate rejects a hand-written literal (`RAW_COLOR`).
55
+ glass: "rgba(255, 255, 255, 0.66)",
56
+ glassBorder: "rgba(255, 255, 255, 0.75)",
17
57
  border: "#e2e8f0",
18
58
  gray50: "#f8fafc",
19
59
  gray100: "#f1f5f9",
@@ -41,58 +81,133 @@ export let colors = { ...DEFAULT_COLORS };
41
81
  // ========== PALETTES ==========
42
82
  export const palettes = {
43
83
  light: {
44
- primary: "#00A5FF",
45
- secondary: "#8b5cf6",
84
+ // Chakra's neutral scale: a white page, #09090B ink, #E4E4E7 hairlines,
85
+ // #FAFAFA panels and a near-black solid for everything selected or primary.
86
+ // `primary` is the plate under a checked box, an active page, a filled chip
87
+ // and the slider fill, so it has to be dark enough to carry its label:
88
+ // #18181B measures 17.3:1 with white, and reads as ink on the page too.
89
+ primary: "#18181b",
90
+ secondary: "#52525b",
46
91
  success: "#10b981",
47
92
  warning: "#f59e0b",
48
93
  danger: "#ef4444",
94
+ dangerStrong: "#b91c1c",
95
+ successStrong: "#047857",
96
+ warningStrong: "#b45309",
97
+ // On a white page the readable red and the plate under white words are the
98
+ // same red, so the plates repeat the Strong values rather than inventing a
99
+ // fourth tone that no one would notice the difference from.
100
+ dangerPlate: "#b91c1c",
101
+ successPlate: "#047857",
102
+ warningPlate: "#b45309",
103
+ infoStrong: "#1d4ed8",
104
+ infoPlate: "#1d4ed8",
49
105
  info: "#3b82f6",
50
106
  background: "#ffffff",
51
- surface: "#f8fafc",
107
+ surface: "#fafafa",
52
108
  card: "#ffffff",
53
- text: "#0f172a",
54
- textSecondary: "#64748b",
55
- textDisabled: "#94a3b8",
56
- border: "#e2e8f0",
57
- gray50: "#f8fafc",
58
- gray100: "#f1f5f9",
59
- gray200: "#e2e8f0",
60
- gray300: "#cbd5e1",
61
- gray400: "#94a3b8",
62
- gray500: "#64748b",
63
- gray600: "#475569",
64
- gray700: "#334155",
65
- gray800: "#1e293b",
66
- gray900: "#0f172a",
67
- overlay: "rgba(0, 0, 0, 0.4)",
68
- shadow: "rgba(0, 0, 0, 0.1)",
109
+ text: "#09090b",
110
+ // What to write on a `primary` fill. This is the theme's answer, not the
111
+ // widget's: `#18181B` is dark, so its ink is white.
112
+ onPrimary: "#ffffff",
113
+ // Badges and tooltips sit on `secondary` (#52525B), dark enough for white.
114
+ onSecondary: "#ffffff",
115
+ // The soft neutral tint a card or icon chip uses instead of `primary + '15'`.
116
+ primaryBg: "#f4f4f5",
117
+ glass: "rgba(255, 255, 255, 0.78)",
118
+ glassBorder: "rgba(9, 9, 11, 0.10)",
119
+ textSecondary: "#52525b",
120
+ textDisabled: "#a1a1aa",
121
+ border: "#e4e4e7",
122
+ gray50: "#fafafa",
123
+ gray100: "#f4f4f5",
124
+ gray200: "#e4e4e7",
125
+ gray300: "#d4d4d8",
126
+ gray400: "#a1a1aa",
127
+ gray500: "#71717a",
128
+ gray600: "#52525b",
129
+ gray700: "#3f3f46",
130
+ gray800: "#27272a",
131
+ gray900: "#18181b",
132
+ divider: "#e4e4e7",
133
+ overlay: "rgba(0, 0, 0, 0.5)",
134
+ shadow: "rgba(0, 0, 0, 0.08)",
69
135
  },
70
136
  dark: {
71
- primary: "#00AFBC",
72
- secondary: "#a78bfa",
137
+ // The same zinc ladder read from the other end: #09090B page, #18181B cards
138
+ // and bars, #27272A hairlines, #A1A1AA secondary words. Every surface token
139
+ // is a true grey, so any widget that paints `surface`, `card`, `gray*` or
140
+ // `border` lands on the same black/grey/white hierarchy; only the semantic
141
+ // signals below keep a hue.
142
+ //
143
+ // `primary` is the inverse of the light theme: a near-white solid whose
144
+ // labels are the page ink (#09090B on #FAFAFA = 17.4:1). That is the same
145
+ // solid Chakra paints in dark mode, and it only works because a widget that
146
+ // writes on a primary fill asks the theme — `colors.onPrimary` — instead of
147
+ // assuming white.
148
+ primary: "#fafafa",
149
+ secondary: "#71717a",
73
150
  success: "#10b981",
74
151
  warning: "#fbbf24",
75
152
  danger: "#f87171",
153
+ // On this page "the tone a person can read" is the light one, because the
154
+ // page is dark: #fca5a5 measures 9.33:1 on a card, 10.48:1 on the page, and
155
+ // the base signal #f87171 is already readable at 6.40:1. `*Strong` keeps its
156
+ // promise here — the red you may write words in — it just points the other
157
+ // way down the ladder than it does on a white page.
158
+ dangerStrong: "#fca5a5",
159
+ successStrong: "#34d399",
160
+ warningStrong: "#fcd34d",
161
+ // The deep plates are the tokens that used to hide inside `*Strong`. A badge
162
+ // or a completed step is a fill carrying white words, which on this page means
163
+ // going dark: white on #7f1d1d is 10.02:1, on #14532d 9.11:1, on #78350f 9.07:1.
164
+ dangerPlate: "#7f1d1d",
165
+ successPlate: "#14532d",
166
+ warningPlate: "#78350f",
167
+ // The same pair for the blue: #93c5fd is the word a person reads on this page
168
+ // (11.03:1), #1e40af is the plate a white label sits on (8.72:1).
169
+ infoStrong: "#93c5fd",
170
+ infoPlate: "#1e40af",
171
+ // The alert tints are a *fill*, and a pale pink fill is the wrong answer on a
172
+ // black page: `#fafafa` on `#ffe5e5` measures 1.14:1, so an error card that
173
+ // used to be painted from the boot value went unreadable in dark mode. These
174
+ // are the same deep tints a generated app's own palette declares, so an app
175
+ // that themes its alert the framework way and one that themes it by hand land
176
+ // on one number: the Strong ink of that hue sits on them at 6.04–10.51:1.
177
+ error: "#f87171",
178
+ errorBg: "#3b1113",
179
+ successBg: "#0c2f22",
180
+ warningBg: "#33230a",
181
+ infoBg: "#12263c",
76
182
  info: "#60a5fa",
77
- background: "#0f172a",
78
- surface: "#1e293b",
79
- card: "#1e293b",
80
- text: "#f1f5f9",
81
- textSecondary: "#94a3b8",
82
- textDisabled: "#64748b",
83
- border: "#334155",
84
- gray50: "#1e293b",
85
- gray100: "#334155",
86
- gray200: "#475569",
87
- gray300: "#64748b",
88
- gray400: "#94a3b8",
89
- gray500: "#cbd5e1",
90
- gray600: "#e2e8f0",
91
- gray700: "#f1f5f9",
92
- gray800: "#f8fafc",
93
- gray900: "#ffffff",
94
- overlay: "rgba(0, 0, 0, 0.6)",
95
- shadow: "rgba(0, 0, 0, 0.3)",
183
+ background: "#09090b",
184
+ surface: "#18181b",
185
+ card: "#18181b",
186
+ text: "#fafafa",
187
+ onPrimary: "#09090b",
188
+ // `secondary` stays a mid grey so badges and tooltips can carry white
189
+ // (4.6:1 on #71717A) while it still reads as ink on the surface.
190
+ onSecondary: "#ffffff",
191
+ primaryBg: "#27272a",
192
+ // A dark pane with a light rim, at the same alpha as the light token.
193
+ glass: "rgba(24, 24, 27, 0.72)",
194
+ glassBorder: "rgba(250, 250, 250, 0.12)",
195
+ textSecondary: "#a1a1aa",
196
+ textDisabled: "#52525b",
197
+ border: "#27272a",
198
+ gray50: "#111111",
199
+ gray100: "#1f1f1f",
200
+ gray200: "#27272a",
201
+ gray300: "#3f3f46",
202
+ gray400: "#52525b",
203
+ gray500: "#71717a",
204
+ gray600: "#a1a1aa",
205
+ gray700: "#d4d4d8",
206
+ gray800: "#e4e4e7",
207
+ gray900: "#fafafa",
208
+ divider: "#27272a",
209
+ overlay: "rgba(0, 0, 0, 0.72)",
210
+ shadow: "rgba(0, 0, 0, 0.6)",
96
211
  },
97
212
  };
98
213
 
@@ -134,13 +249,49 @@ const notifyThemeChange = () => {
134
249
 
135
250
  // ========== THEME FUNCTIONS ==========
136
251
  /**
137
- * Applies a named theme palette to the global `colors` object, updates all
138
- * CSS custom properties on `:root`, persists the choice to `localStorage`,
139
- * and notifies all subscribers.
140
- * @param {string} themeName - Name of the palette to apply (e.g. `"light"` or `"dark"`).
141
- * @returns {Object} The updated `colors` object after the theme is applied.
252
+ * Moves the browser's own chrome — the PWA status bar and navigation bar, the
253
+ * scrollbar and form-control canvases — onto the palette that was just painted.
254
+ *
255
+ * An installed web app paints those bars from `<meta name="theme-color">`, which
256
+ * is a document-level value the page must rewrite itself: nothing in CSS reaches
257
+ * it. Left alone, the app keeps whatever literal the scaffold wrote — the indigo
258
+ * every FletBox PWA shipped before the zinc rebuild — and the reader sees a
259
+ * system bar from the old palette hanging over the new one. `color-scheme` is the
260
+ * same problem in the other direction: without it the OS draws light-mode
261
+ * scrollbars and form controls on a dark page.
262
+ *
263
+ * @private
142
264
  */
143
- export const setTheme = (themeName) => {
265
+ const syncBrowserChrome = () => {
266
+ try {
267
+ let meta = document.querySelector('meta[name="theme-color"]');
268
+ if (!meta) {
269
+ meta = document.createElement("meta");
270
+ meta.name = "theme-color";
271
+ document.head.appendChild(meta);
272
+ }
273
+ // The bars sit directly against the AppBar and the BottomNavigation, and
274
+ // both paint `surface` — matching `background` would put a band of the page
275
+ // color between two panels and look like a gap in the chrome.
276
+ meta.setAttribute("content", colors.surface);
277
+ // Whether the page is dark is read from its own background, not from the
278
+ // palette's name: `registerPalette` lets an app call a dark scale anything.
279
+ document.documentElement.style.colorScheme =
280
+ readableInk(colors.background, "#ffffff") === "#ffffff" ? "dark" : "light";
281
+ } catch (e) {
282
+ // Non-browser environments have no chrome to sync.
283
+ }
284
+ };
285
+
286
+ /**
287
+ * Copies a registered palette onto the live `colors` object and refreshes the
288
+ * CSS custom properties. Shared by `setTheme` and `registerPalette`; it does not
289
+ * persist anything, because "this is the palette now" and "the user picked this
290
+ * theme" are two different sentences.
291
+ * @param {string} themeName
292
+ * @returns {Object} the updated `colors` object
293
+ */
294
+ const applyPalette = (themeName) => {
144
295
  const palette = palettes[themeName];
145
296
  if (palette) {
146
297
  Object.keys(palette).forEach((key) => {
@@ -160,17 +311,66 @@ export const setTheme = (themeName) => {
160
311
  // Silently fail in non-browser environments
161
312
  }
162
313
 
163
- // Save preference
164
- try {
165
- localStorage.setItem("fletbox-theme", themeName);
166
- } catch (e) {
167
- // Silently fail
168
- }
314
+ syncBrowserChrome();
169
315
 
170
316
  notifyThemeChange();
171
317
  return colors;
172
318
  };
173
319
 
320
+ /**
321
+ * Applies a named theme palette to the global `colors` object, updates all
322
+ * CSS custom properties on `:root`, persists the choice to `localStorage`,
323
+ * and notifies all subscribers.
324
+ * @param {string} themeName - Name of the palette to apply (e.g. `"light"` or `"dark"`).
325
+ * @returns {Object} The updated `colors` object after the theme is applied.
326
+ */
327
+ export const setTheme = (themeName) => {
328
+ // Persist before repainting: applyPalette notifies subscribers synchronously,
329
+ // and a subscriber that reads `getTheme()` while it rebuilds (a toggle button
330
+ // labelling itself, a theme-aware screen) has to see the theme it is being
331
+ // rebuilt for, not the one being replaced.
332
+ //
333
+ // `window` is the test, not a try/catch around the call: Node 22+ defines a
334
+ // global `localStorage` that only answers with a warning and then throws, so
335
+ // an import-time theme would print an ExperimentalWarning on every server run.
336
+ if (typeof window !== "undefined") {
337
+ try {
338
+ localStorage.setItem("fletbox-theme", themeName);
339
+ } catch (e) {
340
+ // Silently fail
341
+ }
342
+ }
343
+
344
+ applyPalette(themeName);
345
+ return colors;
346
+ };
347
+
348
+ /**
349
+ * Merges an app's own tokens into a named palette and, when that palette is the
350
+ * one currently on screen, repaints with it. An app that only declares a
351
+ * `colors` object changes nothing: every widget reads the framework's live
352
+ * `colors`, so the tokens have to be registered to reach the page.
353
+ *
354
+ * Unlike `setTheme` this never writes the theme preference — an app supplying
355
+ * its brand colors is not the user choosing light or dark.
356
+ *
357
+ * @param {string} themeName - Palette to extend, usually `"light"` or `"dark"`.
358
+ * @param {Object} palette - Token names to override; unknown keys are ignored.
359
+ * @returns {Object} The registered palette.
360
+ */
361
+ export const registerPalette = (themeName, palette) => {
362
+ if (!themeName || !palette || typeof palette !== "object") return palettes;
363
+ // A token the framework never heard of is seeded onto `colors` first:
364
+ // applyPalette only copies keys that already exist — that filter is what stops
365
+ // a palette which leaves one out (both built-ins do) from blanking it.
366
+ Object.keys(palette).forEach((key) => {
367
+ if (!colors.hasOwnProperty(key)) colors[key] = palette[key];
368
+ });
369
+ palettes[themeName] = { ...(palettes[themeName] || {}), ...palette };
370
+ if (getTheme() === themeName) applyPalette(themeName);
371
+ return palettes[themeName];
372
+ };
373
+
174
374
  /**
175
375
  * Returns the currently active theme name.
176
376
  * Reads from `localStorage` first; falls back to the OS
@@ -215,7 +415,10 @@ export const applySystemTheme = () => {
215
415
  .matches
216
416
  ? "dark"
217
417
  : "light";
218
- setTheme(systemTheme);
418
+ // applyPalette, not setTheme: painting what the OS asked for is not the
419
+ // reader choosing a theme. Persisting here would pin the first answer and
420
+ // make watchSystemTheme's own savedTheme check bail forever.
421
+ applyPalette(systemTheme);
219
422
  return systemTheme;
220
423
  }
221
424
  return savedTheme;
@@ -236,8 +439,9 @@ export const watchSystemTheme = () => {
236
439
  const handler = (e) => {
237
440
  const savedTheme = localStorage.getItem("fletbox-theme");
238
441
  if (!savedTheme) {
239
- const theme = e.matches ? "dark" : "light";
240
- setTheme(theme);
442
+ // Same rule as applySystemTheme: the OS changing its mind is not the
443
+ // reader choosing a theme, so it must not be written down as one.
444
+ applyPalette(e.matches ? "dark" : "light");
241
445
  }
242
446
  };
243
447
  mediaQuery.addEventListener("change", handler);
@@ -312,13 +516,16 @@ const initTheme = () => {
312
516
  try {
313
517
  const savedTheme = localStorage.getItem("fletbox-theme");
314
518
  if (savedTheme && (savedTheme === "light" || savedTheme === "dark")) {
315
- setTheme(savedTheme);
519
+ applyPalette(savedTheme);
316
520
  } else {
317
521
  const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
318
522
  .matches
319
523
  ? "dark"
320
524
  : "light";
321
- setTheme(systemTheme);
525
+ // applyPalette, not setTheme: this runs the first time the module is
526
+ // imported, so persisting here wrote the OS answer down as a reader
527
+ // choice — and every later watcher then refused to follow the OS.
528
+ applyPalette(systemTheme);
322
529
  }
323
530
  watchSystemTheme();
324
531
  } catch (e) {
@@ -9,10 +9,12 @@
9
9
  // toREM(24) // "1.5rem"
10
10
  // toREM(8) // "0.5rem"
11
11
  //
12
- // // Strings pass through as-is
13
- // toREM('16px') // "16px"
14
- // toREM('1rem') // "1rem"
15
- // toREM('50%') // "50%"
12
+ // // A length written in pixels is re-written in rem; anything else is left alone
13
+ // toREM('16px') // "1rem"
14
+ // toREM('8px 12px') // "0.5rem 0.75rem"
15
+ // toREM('1rem') // "1rem"
16
+ // toREM('50%') // "50%"
17
+ // toREM('2px solid red')// "2px solid red"
16
18
  //
17
19
  // // For exact pixels
18
20
  // toPX(16) // "16px"
@@ -35,15 +37,30 @@ export const setBaseFontSize = (size) => {
35
37
 
36
38
  /**
37
39
  * Converts a numeric value to rem, or keeps strings as they are
40
+ *
41
+ * A number always becomes rem: `40` is `2.5rem`, so it grows with the reader's
42
+ * base font the same way the rest of the page does.
43
+ *
44
+ * A string is usually a CSS value the framework should not touch (`"50%"`,
45
+ * `"calc(100% - 8px)"`, `"2px solid red"`) and passes through unchanged. The one
46
+ * exception is a length shorthand made only of pixels — `"8px 12px"`, `"0 16px"`,
47
+ * `"-4px -8px"`. That shape is a widget writing a framework number by hand
48
+ * (`padding: \`${v}px ${h}px\``), and if it passes through, the whole rule set
49
+ * still scales except for the one line that spelled its units out. So every
50
+ * token of it is converted: `"8px 12px"` → `"0.5rem 0.75rem"`.
51
+ *
38
52
  * @param {number|string} value - Value to convert
39
- * @returns {string} Converted value (e.g. "1rem", "16px", "50%")
53
+ * @returns {string} Converted value (e.g. "1rem", "0.5rem 0.75rem", "50%")
40
54
  * @example
41
- * toREM(16) // "1rem"
42
- * toREM(8) // "0.5rem"
43
- * toREM('16px') // "16px"
44
- * toREM('1rem') // "1rem"
45
- * toREM('50%') // "50%"
55
+ * toREM(16) // "1rem"
56
+ * toREM(8) // "0.5rem"
57
+ * toREM('8px 12px')// "0.5rem 0.75rem"
58
+ * toREM('16px') // "1rem"
59
+ * toREM('50%') // "50%"
60
+ * toREM('2px solid red') // unchanged: "solid" is not a length
46
61
  */
62
+ const PX_TOKEN = /^-?\d+(?:\.\d+)?px$/;
63
+
47
64
  export const toREM = (value) => {
48
65
  if (value === undefined || value === null) return undefined;
49
66
 
@@ -54,7 +71,12 @@ export const toREM = (value) => {
54
71
 
55
72
  // If it is a string, return it as-is
56
73
  if (typeof value === "string") {
57
- return value;
74
+ const parts = value.trim().split(/\s+/);
75
+ const allPixels = parts.every((part) => PX_TOKEN.test(part));
76
+ if (!allPixels) return value;
77
+ return parts
78
+ .map((part) => `${parseFloat(part) / BASE_FONT_SIZE}rem`)
79
+ .join(" ");
58
80
  }
59
81
 
60
82
  // For other types (objects, booleans, etc.)
@@ -1,5 +1,42 @@
1
1
  // core/effects.js
2
2
 
3
+ import { SHADOW_LEVELS } from "../tools/shadow.js";
4
+
5
+ /**
6
+ * The three geometry numbers of a CSS shadow: x, y and blur.
7
+ * The colour is stripped first, because the scale writes `0 3px 6px` while the
8
+ * browser serialises the live style as `0px 3px 6px 0px` — matching numbers
9
+ * instead of tokens is what lets both forms compare equal.
10
+ */
11
+ const shadowGeometry = (value) => {
12
+ const numbers = String(value ?? "")
13
+ .replace(/rgba?\([^)]*\)/g, "")
14
+ .trim()
15
+ .split(/\s+/)
16
+ .map(parseFloat);
17
+ return numbers.length >= 3 && numbers.every(Number.isFinite)
18
+ ? numbers.slice(0, 3)
19
+ : null;
20
+ };
21
+
22
+ /**
23
+ * The next `SHADOW_LEVELS` entry above `value`, or `null` when the shadow is not
24
+ * one of the scale's levels. Matching is by geometry, not by string: the browser
25
+ * serialises "0 3px 6px rgba(0,0,0,0.16)" as "rgba(0, 0, 0, 0.16) 0px 3px 6px",
26
+ * so comparing the literals would never find a level.
27
+ */
28
+ const nextElevation = (value) => {
29
+ const geometry = shadowGeometry(value);
30
+ if (!geometry) return null;
31
+ const level = Object.keys(SHADOW_LEVELS).find((key) => {
32
+ const candidate = shadowGeometry(SHADOW_LEVELS[key]);
33
+ return candidate && candidate.every((n, i) => Math.abs(n - geometry[i]) < 0.01);
34
+ });
35
+ if (level === undefined) return null;
36
+ return SHADOW_LEVELS[Number(level) + 1] ?? null;
37
+ };
38
+
39
+
3
40
  /**
4
41
  * Applies interactive visual effects to a widget that has a click handler.
5
42
  *
@@ -19,7 +56,8 @@
19
56
  * - On press end (`mouseup` / `touchend` / `mouseleave` / `touchcancel`):
20
57
  * restores the original scale and shadow.
21
58
  * - On hover enter (`mouseenter`): translates the widget up by `0.5px` and
22
- * enhances the box shadow when one was present.
59
+ * raises the box shadow by one `SHADOW_LEVELS` step when the widget already
60
+ * carries a shadow from that scale.
23
61
  * - On hover leave (`mouseleave`): restores the original translate and shadow.
24
62
  * - Sets `cursor: pointer`.
25
63
  *
@@ -47,7 +85,12 @@ export const applyEffects = (widget) => {
47
85
  if (!hasClick) return widget;
48
86
 
49
87
  // Store the current shadow so it can be restored after press / hover effects.
50
- widget._originalShadow = widget.style.boxShadow || "";
88
+ //
89
+ // Skipped while the pointer is over the widget: `update()` re-runs
90
+ // `applyEffects`, and capturing the lifted hover shadow as the base would make
91
+ // every update climb one more elevation step.
92
+ if (!widget._hovering) widget._originalShadow = widget.style.boxShadow || "";
93
+ const hoverShadow = nextElevation(widget._originalShadow);
51
94
 
52
95
  const disableTransform = widget._originalProps?.disableTransform === true;
53
96
  const listeners = [];
@@ -98,11 +141,10 @@ export const applyEffects = (widget) => {
98
141
  */
99
142
  const onHoverEnter = () => {
100
143
  if (isPressed) return;
144
+ widget._hovering = true;
101
145
  // Use the independent `translate` property.
102
146
  widget.style.translate = "0 -0.5px";
103
- if (widget._originalShadow && widget._originalShadow !== "none") {
104
- widget.style.boxShadow = "0 8px 16px rgba(0,0,0,0.2)";
105
- }
147
+ if (hoverShadow) widget.style.boxShadow = hoverShadow;
106
148
  };
107
149
 
108
150
  /**
@@ -115,6 +157,7 @@ export const applyEffects = (widget) => {
115
157
  // Restore translate without touching transform.
116
158
  widget.style.translate = "";
117
159
  widget.style.boxShadow = widget._originalShadow || "";
160
+ widget._hovering = false;
118
161
  };
119
162
 
120
163
  widget.addEventListener("mouseenter", onHoverEnter, { passive: true });
@@ -122,6 +165,14 @@ export const applyEffects = (widget) => {
122
165
  listeners.push({ event: "mouseenter", handler: onHoverEnter });
123
166
  listeners.push({ event: "mouseleave", handler: onHoverLeave });
124
167
 
168
+ // An update re-applies the props' own shadow and translate, which cancels a
169
+ // hover that is still in progress. Re-assert it, from the same base, so a
170
+ // reactive update neither drops the lift nor climbs another step.
171
+ if (widget._hovering) {
172
+ widget.style.translate = "0 -0.5px";
173
+ if (hoverShadow) widget.style.boxShadow = hoverShadow;
174
+ }
175
+
125
176
  widget.style.cursor = "pointer";
126
177
  }
127
178
 
@@ -123,8 +123,17 @@ function applyUnit(def, value) {
123
123
  }
124
124
  if (value === true) return 1;
125
125
  if (value === false) return 0;
126
- if (typeof value !== "number") return value;
127
126
  const unit = def.unit || "px";
127
+ // A rem prop written as a pixel *string* ("8px 12px") is the same framework
128
+ // number written a different way, so it converts like the number does. `toREM`
129
+ // only rewrites strings made entirely of pixel lengths, which leaves "100%",
130
+ // "auto", "calc(100% - 16px)" and "2px solid red" exactly as they were.
131
+ if (unit === "rem" && typeof value === "string") return toREM(value);
132
+ // A rem prop written as a *list* (`padding: [8, 16]`) is the same pair of
133
+ // framework units again; the generic formatter would suffix each item with px.
134
+ if (unit === "rem" && Array.isArray(value))
135
+ return value.map((item) => toREM(item)).join(" ");
136
+ if (typeof value !== "number") return value;
128
137
  if (unit === "none") return value;
129
138
 
130
139
  // Use the dynamic REM converter instead of a hard-coded division.
@@ -1,34 +1,20 @@
1
1
  /**
2
- * REM TOOL - Only converts numbers to REM
2
+ * @file remTool.js
3
+ * @description The engine's entry point to the framework's one unit converter.
4
+ *
5
+ * This file used to keep its own copy of the rule — `value / 16` for numbers,
6
+ * strings passed through untouched. Two converters in one framework means two
7
+ * meanings for one number: a widget that called `src/utils/units.js` honoured
8
+ * `setBaseFontSize()` and an engine-routed prop did not, so the same `40` painted
9
+ * 40px in one place and 50px in another. Both halves now call the same function,
10
+ * and a length written as pixels ("8px", "4px 12px") converts like the number
11
+ * that produced it instead of freezing at that size.
3
12
  */
13
+ import { toREM as convert } from "../utils/units.js";
4
14
 
5
15
  /**
6
- * Converts a number to REM
7
- * @param {number} value - Number to convert
8
- * @param {number} baseFontSize - Base root font size (default: 16)
9
- * @returns {string} - Value in REM
16
+ * @param {number|string} value - A framework number, or a CSS length/value.
17
+ * @returns {string|*} `40` → `"2.5rem"`; `"8px 12px"` → `"0.5rem 0.75rem"`;
18
+ * anything that is not a pixel length (`"50%"`, `"auto"`) is returned as given.
10
19
  */
11
- export const toREM = (value, baseFontSize = 16) => {
12
- if (typeof value !== "number") return value;
13
- return `${value / baseFontSize}rem`;
14
- };
15
-
16
- /**
17
- * Applies REM to a styles object
18
- * @param {Object} styles - Styles with numbers
19
- * @param {number} baseFontSize - Base root font size (default: 16)
20
- * @returns {Object} - Styles with REM
21
- */
22
- export const applyREM = (styles, baseFontSize = 16) => {
23
- const result = {};
24
-
25
- for (const [key, value] of Object.entries(styles)) {
26
- if (typeof value === "number") {
27
- result[key] = toREM(value, baseFontSize);
28
- } else {
29
- result[key] = value;
30
- }
31
- }
32
-
33
- return result;
34
- };
20
+ export const toREM = (value) => convert(value);