@microbit/ui 0.1.0-alpha.9 → 0.1.1

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 (113) hide show
  1. package/LICENSE.md +8 -0
  2. package/README.md +171 -20
  3. package/lang/ui.ar.json +62 -0
  4. package/lang/ui.ca.json +43 -3
  5. package/lang/ui.cy.json +62 -0
  6. package/lang/ui.de.json +44 -4
  7. package/lang/ui.en-us.json +40 -0
  8. package/lang/ui.en.json +40 -0
  9. package/lang/ui.es-es.json +43 -3
  10. package/lang/ui.fr.json +44 -4
  11. package/lang/ui.ga-ie.json +43 -3
  12. package/lang/ui.it.json +62 -0
  13. package/lang/ui.ja.json +44 -4
  14. package/lang/ui.ko.json +44 -4
  15. package/lang/ui.lo.json +62 -0
  16. package/lang/ui.lol.json +40 -0
  17. package/lang/ui.nl.json +44 -4
  18. package/lang/ui.pl.json +44 -4
  19. package/lang/ui.pt-br.json +44 -4
  20. package/lang/ui.vi.json +62 -0
  21. package/lang/ui.zh-cn.json +44 -4
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +13 -11
  24. package/src/Avatar.recipe.ts +191 -0
  25. package/src/Avatar.tsx +278 -0
  26. package/src/Breadcrumb.recipe.ts +45 -0
  27. package/src/Breadcrumb.tsx +114 -0
  28. package/src/Button.recipe.ts +88 -50
  29. package/src/Button.tsx +67 -14
  30. package/src/ButtonGroup.tsx +37 -15
  31. package/src/Card.recipe.ts +1 -2
  32. package/src/Card.tsx +2 -1
  33. package/src/Checkbox.recipe.ts +13 -12
  34. package/src/Checkbox.tsx +103 -34
  35. package/src/CheckboxGroup.tsx +70 -0
  36. package/src/CloseButton.tsx +3 -3
  37. package/src/CloseIcon.tsx +6 -4
  38. package/src/Code.tsx +1 -1
  39. package/src/Collapse.tsx +13 -14
  40. package/src/ComboBox.tsx +246 -0
  41. package/src/Divider.tsx +40 -7
  42. package/src/Drawer.recipe.ts +21 -10
  43. package/src/Drawer.tsx +3 -4
  44. package/src/ExternalLink.tsx +43 -0
  45. package/src/Fade.tsx +18 -4
  46. package/src/Field.recipe.ts +114 -0
  47. package/src/Field.tsx +187 -0
  48. package/src/GridList.recipe.ts +57 -0
  49. package/src/GridList.tsx +81 -0
  50. package/src/Heading.recipe.ts +20 -1
  51. package/src/Heading.tsx +3 -3
  52. package/src/Icon.tsx +23 -7
  53. package/src/IconButton.tsx +8 -18
  54. package/src/Image.tsx +1 -1
  55. package/src/Input.recipe.ts +30 -26
  56. package/src/Input.tsx +16 -7
  57. package/src/InputGroup.tsx +9 -9
  58. package/src/Kbd.tsx +1 -1
  59. package/src/Link.tsx +3 -3
  60. package/src/LinkBox.tsx +2 -3
  61. package/src/LinkButton.tsx +14 -13
  62. package/src/List.tsx +5 -7
  63. package/src/ListBox.recipe.ts +43 -0
  64. package/src/ListBox.tsx +88 -0
  65. package/src/Menu.recipe.ts +22 -19
  66. package/src/Menu.tsx +54 -28
  67. package/src/Modal.recipe.ts +17 -9
  68. package/src/Modal.tsx +123 -24
  69. package/src/MoreMenuButton.tsx +63 -0
  70. package/src/NativeSelect.tsx +37 -14
  71. package/src/NativeSelectField.tsx +84 -0
  72. package/src/NumberField.recipe.ts +56 -15
  73. package/src/NumberField.tsx +74 -22
  74. package/src/PopoverArrow.tsx +1 -2
  75. package/src/ProgressBar.tsx +3 -5
  76. package/src/Radio.recipe.ts +13 -12
  77. package/src/Radio.tsx +2 -29
  78. package/src/RadioGroup.tsx +68 -0
  79. package/src/Select.recipe.ts +211 -0
  80. package/src/Select.tsx +194 -0
  81. package/src/SharedUIProvider.tsx +63 -7
  82. package/src/Skeleton.tsx +146 -0
  83. package/src/Slide.tsx +2 -2
  84. package/src/Slider.recipe.ts +27 -16
  85. package/src/Slider.tsx +20 -12
  86. package/src/Spinner.tsx +11 -9
  87. package/src/Svg.tsx +2 -3
  88. package/src/Switch.recipe.ts +40 -21
  89. package/src/Switch.tsx +35 -5
  90. package/src/Text.recipe.ts +26 -0
  91. package/src/Text.tsx +6 -2
  92. package/src/TextField.tsx +35 -38
  93. package/src/Toast.recipe.ts +52 -15
  94. package/src/Toast.tsx +147 -48
  95. package/src/Tooltip.recipe.ts +32 -0
  96. package/src/Tooltip.tsx +68 -28
  97. package/src/TooltipButton.tsx +229 -0
  98. package/src/UnmountCallback.tsx +2 -2
  99. package/src/VisuallyHidden.tsx +1 -1
  100. package/src/base-preset.ts +323 -84
  101. package/src/base-tokens.ts +0 -3
  102. package/src/button-icon.ts +4 -5
  103. package/src/data-attrs.ts +16 -0
  104. package/src/dense-preset.ts +107 -0
  105. package/src/hooks/useBreakpointValue.ts +4 -4
  106. package/src/hooks/useClipboard.ts +5 -6
  107. package/src/hooks/useDisclosure.ts +31 -0
  108. package/src/hooks/useMediaQuery.ts +3 -4
  109. package/src/hooks/usePrevious.ts +1 -4
  110. package/src/index.ts +19 -3
  111. package/src/rac-locale.ts +33 -0
  112. package/src/system.ts +22 -1
  113. package/src/TextField.recipe.ts +0 -54
@@ -23,38 +23,103 @@ import {
23
23
  } from "./base-tokens";
24
24
  // Config recipes are colocated with the shared-ui components they style; this
25
25
  // preset registers them so Panda merges them at codegen time.
26
+ import { avatar } from "./Avatar.recipe";
27
+ import { breadcrumb } from "./Breadcrumb.recipe";
26
28
  import { button } from "./Button.recipe";
27
29
  import { card } from "./Card.recipe";
28
30
  import { checkbox } from "./Checkbox.recipe";
29
31
  import { radio } from "./Radio.recipe";
30
32
  import { drawer } from "./Drawer.recipe";
33
+ import { gridList } from "./GridList.recipe";
31
34
  import { heading } from "./Heading.recipe";
32
35
  import { input } from "./Input.recipe";
36
+ import { listBox } from "./ListBox.recipe";
33
37
  import { numberField } from "./NumberField.recipe";
34
38
  import { menu } from "./Menu.recipe";
39
+ import { select } from "./Select.recipe";
35
40
  import { slider } from "./Slider.recipe";
36
41
  import { switchRecipe } from "./Switch.recipe";
37
42
  import { dialog } from "./Modal.recipe";
38
- import { field } from "./TextField.recipe";
43
+ import { text } from "./Text.recipe";
44
+ import { tooltip } from "./Tooltip.recipe";
45
+ import { field } from "./Field.recipe";
39
46
  import { toast } from "./Toast.recipe";
40
47
 
48
+ // The family gray ramp: pure neutrals as the library default. The two
49
+ // halves have different jobs and different override rules:
50
+ //
51
+ // - 10–300 are surface stops (panels, page backdrops, hairlines, subtle
52
+ // fills). Presets may freely override these values — pinning an app's
53
+ // existing surfaces or applying a brand tint — because a few bits of
54
+ // lightness here reads across a whole viewport.
55
+ // - 350 is the decorative/state fill stop (~2.1:1): avatar discs, skeleton
56
+ // pulse, pressed fills. Never text or boundaries.
57
+ // - 400–900 are ink stops (outlines, placeholders, text) with a contrast
58
+ // contract on white: 400 ≥ 3:1, the floor for boundaries that identify
59
+ // a control (checkbox-family boxes; fields rest lighter — see the input
60
+ // recipe); 500 ≥ 4.5:1, text-safe secondary. Presets may re-tint these
61
+ // only luminance-matched — the contrast figures are the contract, hue
62
+ // is free.
63
+ //
64
+ // Override values, never names: raw var(--colors-gray-*) references and
65
+ // paired private presets depend on the names, so a rename is a breaking
66
+ // change to both and needs every app and paired preset moved in lockstep
67
+ // (as was done when the misnamed darker-than-50 stop `25` became
68
+ // `75`). And never override partially in a way that lets a stop fall
69
+ // through to a different grey system.
70
+ const gray = {
71
+ 10: { value: "#fcfcfc" },
72
+ 50: { value: "#f9f9f9" },
73
+ 75: { value: "#f5f5f5" },
74
+ 100: { value: "#f1f1f1" },
75
+ 200: { value: "#e7e7e7" },
76
+ 300: { value: "#d4d4d4" },
77
+ 350: { value: "#b4b4b4" }, // ~2.1:1 — decorative fills only
78
+ 400: { value: "#949494" }, // 3.05:1 — accessible outline stop
79
+ 500: { value: "#767676" }, // 4.54:1 — text-safe secondary
80
+ 600: { value: "#575757" },
81
+ 700: { value: "#404040" },
82
+ 800: { value: "#262626" },
83
+ 900: { value: "#1a1a1a" },
84
+ };
85
+
86
+ // The family red, on the gray ramp's ladder — it is the conventional colour
87
+ // for errors and recording, not a brand colour, so it can be graded rather
88
+ // than negotiated. 400 was already exactly gray's 3:1 and stays verbatim,
89
+ // anchoring the hue and saturation the darker stops hold while their
90
+ // lightness solves for gray's ratio. The washes have no contract and keep
91
+ // their values.
92
+ const red = {
93
+ 50: { value: "#FFF5F5" },
94
+ 100: { value: "#FED7D7" },
95
+ 200: { value: "#FEB2B2" },
96
+ 300: { value: "#FC8181" },
97
+ 400: { value: "#F56565" }, // 3.03:1
98
+ 500: { value: "#e22b2b" }, // 4.55:1 — the white-text fill stop
99
+ 600: { value: "#ac1818" },
100
+ 700: { value: "#811212" },
101
+ 800: { value: "#4f0b0b" },
102
+ 900: { value: "#380808" },
103
+ };
104
+
41
105
  /**
42
106
  * The base preset: the complete, working micro:bit design system. The base
43
107
  * token scales (base-tokens.ts), the micro:bit house style
44
- * (pill `radii.button`, `outline*` focus shadows, Helvetica fonts, the
45
- * `language`/`toolbar` button variants in Button.recipe.ts, the
108
+ * (pill `radii.button`, the `focusRing` utility/token pair, Helvetica
109
+ * fonts, the
110
+ * `toolbar` button variant in Button.recipe.ts, the
46
111
  * `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui
47
- * component recipes, the react-aria condition widening, the Chakra-reset
48
- * parity `globalCss`, and the `staticCss` that keeps runtime-prop recipe
112
+ * component recipes, the react-aria condition widening, the `globalCss`
113
+ * defaults, and the `staticCss` that keeps runtime-prop recipe
49
114
  * variants generated. Used alone it renders in the OSS default look.
50
115
  *
51
116
  * ── Brand contract ──────────────────────────────────────────────────────
52
117
  * A private brand preset (a sibling repo, e.g. CreateAI) is merged AFTER this
53
118
  * one to restyle everything by overriding just these token *values* (never
54
119
  * their names — see the CSS-var contract in the README):
55
- * - colours: the `brand` and `brand2` ramps (OSS defaults: Chakra
56
- * blue / Chakra's unmodified gray). Other ramps a brand tweaks
57
- * (teal/purple/pink/…) already exist in the Chakra scales below.
120
+ * - colours: the `brand` and `brand2` ramps (OSS defaults: the blue
121
+ * ramp / a legacy slate gray). Other ramps a brand tweaks
122
+ * (teal/purple/pink/…) already exist in the base scales below.
58
123
  * - font: `display` (OSS default: Helvetica; e.g. GT Walsheim privately).
59
124
  * The recipes and semantic tokens here reference those, so a brand swap needs
60
125
  * no recipe changes. With no private preset, these OSS defaults stand.
@@ -64,25 +129,44 @@ export const basePreset = definePreset({
64
129
  theme: {
65
130
  breakpoints,
66
131
  keyframes: {
67
- // Spinner's revolution (the only keyframe a shared-ui component uses).
132
+ // Spinner's revolution.
68
133
  spin: {
69
134
  "0%": { transform: "rotate(0deg)" },
70
135
  "100%": { transform: "rotate(360deg)" },
71
136
  },
137
+ // Skeleton's pulse, over the pair of custom properties the component
138
+ // sets, so a retinted skeleton animates between its own colours.
139
+ skeletonFade: {
140
+ from: {
141
+ borderColor: "var(--skeleton-start-color)",
142
+ background: "var(--skeleton-start-color)",
143
+ },
144
+ to: {
145
+ borderColor: "var(--skeleton-end-color)",
146
+ background: "var(--skeleton-end-color)",
147
+ },
148
+ },
149
+ // Toast enter/exit, played on the view-transition snapshots (see the
150
+ // ::view-transition rules in globalCss): fade + short slide in,
151
+ // quicker fade + shrink out.
152
+ toastSlideIn: {
153
+ from: { opacity: 0, transform: "translateY(-24px)" },
154
+ },
155
+ toastSlideOut: {
156
+ to: { opacity: 0, transform: "scale(0.85)" },
157
+ },
72
158
  },
73
159
  tokens: {
74
160
  colors: {
75
161
  ...colors,
76
- gray: {
77
- ...colors.gray,
78
- // Very light grays the family's designs use below Chakra's 50.
79
- 10: { value: "#fcfcfc" },
80
- 25: { value: "#f5f5f5" },
81
- },
162
+ gray,
163
+ red,
82
164
  // OSS default brand ramps (see the brand contract above). `brand`
83
- // aliases Chakra blue; `brand2` Chakra's *unmodified* gray (not the
84
- // `gray` above, whose 10/25 additions are lighter — getting this
85
- // wrong once made card text near-invisible).
165
+ // aliases the blue ramp; `brand2` is a frozen legacy alias of the
166
+ // slate gray in base-tokens, deliberately decoupled from the neutral
167
+ // `gray` above so ml-trainer's OSS look and `statusBarBg`'s default
168
+ // don't move.
169
+ // Removing the slot is a follow-up needing an ml-trainer lockstep.
86
170
  brand: colors.blue,
87
171
  brand2: colors.gray,
88
172
  },
@@ -104,16 +188,11 @@ export const basePreset = definePreset({
104
188
  },
105
189
  shadows: {
106
190
  ...shadows,
107
- // Chakra's outline shadow widened to 4px, plus dark/light-surface
108
- // companions. Consumed via the `focusShadow` utility.
109
- outline: { value: "0 0 0 4px rgba(66, 153, 225, 0.6)" },
110
- outlineDark: { value: "0 0 0 4px rgba(0, 0, 0, 0.5)" },
111
- outlineLight: { value: "0 0 0 4px rgba(255, 255, 255, 0.8)" },
112
191
  },
113
192
  fonts: {
114
193
  // Helvetica heading/body (4/4 apps); a brand preset leaves these and
115
194
  // overrides only `display` (the marketing font — see the brand
116
- // contract above). `mono` is Chakra's default stack.
195
+ // contract above).
117
196
  heading: { value: "Helvetica, Arial, sans-serif" },
118
197
  body: { value: "Helvetica, Arial, sans-serif" },
119
198
  mono: {
@@ -127,42 +206,86 @@ export const basePreset = definePreset({
127
206
  },
128
207
  semanticTokens: {
129
208
  colors: {
130
- // Checked/focus states of form controls (Chakra's default blue
131
- // colorScheme). Checkbox/Switch checked backgrounds, Input focus
132
- // border, and the Slider/ProgressBar fills (the last two are
133
- // near-dead defaults - current call sites override them).
134
- controlCheckedBg: { value: "{colors.blue.500}" },
135
- controlCheckedHoverBg: { value: "{colors.blue.600}" },
136
- focusBorder: { value: "{colors.blue.500}" },
137
- sliderFilledTrack: { value: "{colors.blue.500}" },
138
- progressFilledTrack: { value: "{colors.blue.500}" },
139
- // Error/destructive ramp (Chakra red). Destructive button variants,
140
- // field error states and the error toast; the record* button
141
- // variants deliberately stay on red.* (recording vocabulary, not
142
- // danger).
209
+ // Checked states of form controls: Checkbox/Switch/Radio checked
210
+ // backgrounds. Semantic so a brand can diverge them from its ramp.
211
+ controlCheckedBg: { value: "{colors.brand.500}" },
212
+ controlCheckedHoverBg: { value: "{colors.brand.600}" },
213
+ // Focused form-control border, any modality: the dark brand stop
214
+ // (all-ink read flat next to the ink ring). Flips white under the
215
+ // dark-surface tag, like `focusRing`.
216
+ // Both flips are condition objects, and a merge replaces a token
217
+ // value wholesale: an override must keep the `{ base, _onDark }`
218
+ // shape or silently lose the flip.
219
+ focusBorder: {
220
+ value: { base: "{colors.brand.600}", _onDark: "{colors.white}" },
221
+ },
222
+ // The focus ring colour: ink, or white inside `data-surface="dark"`
223
+ // (the `onDark` condition). The var inherits — tag the bar, cover
224
+ // its controls; portalled overlays escape with the DOM. Dark
225
+ // surfaces MUST tag (ink is near-invisible there). Opaque
226
+ // deliberately: translucent rings washed out (classroom #780).
227
+ // Both tag states: the Button "Variants" story.
228
+ focusRing: {
229
+ value: { base: "{colors.gray.900}", _onDark: "{colors.white}" },
230
+ },
231
+ // Error/destructive ramp: field error states, the error toast, and
232
+ // the `danger` button tone. Aliased whole, not just at the stops in
233
+ // use, so a tone has nowhere to fall through (Button.recipe.ts).
143
234
  danger: {
144
235
  50: { value: "{colors.red.50}" },
145
236
  100: { value: "{colors.red.100}" },
237
+ 200: { value: "{colors.red.200}" },
238
+ 300: { value: "{colors.red.300}" },
239
+ 400: { value: "{colors.red.400}" },
146
240
  500: { value: "{colors.red.500}" },
147
241
  600: { value: "{colors.red.600}" },
148
242
  700: { value: "{colors.red.700}" },
243
+ 800: { value: "{colors.red.800}" },
244
+ 900: { value: "{colors.red.900}" },
149
245
  },
150
- // The `language` button variant's text colour follows the primary
151
- // interactive brand: every consumer resolves it to its `brand` ramp
152
- // (CreateAI privately to brand.600 with no hover change,
153
- // python-editor to brand.500/600 — the default). Semantic tokens so
154
- // the recipe stays shared and a brand preset overrides only values.
155
- // (Was brand2.* the grey ml-trainer OSS Chakra look — but both
156
- // apps' final values sit on their primary brand, so the default
157
- // follows; OSS language buttons are brand blue.)
246
+ // The language-dialog cards' text colour (@microbit/ui-patterns'
247
+ // LanguageDialog) follows the primary interactive brand: every
248
+ // consumer resolves it to its `brand` ramp (CreateAI privately to
249
+ // brand.600 with no hover change, python-editor to brand.500/600 —
250
+ // the default; data-microbit-org to black). Semantic tokens so the
251
+ // pattern stays shared and a brand preset overrides only values.
158
252
  languageText: { value: "{colors.brand.500}" },
159
253
  languageTextHover: { value: "{colors.brand.600}" },
160
- // Toast status colours: the Chakra-era toast Alert restyle (teal for
161
- // every status except error) shared across the app family.
254
+ // The `label`/`subtitle` heading variants' colour (page-title chrome).
255
+ // classroom and data-microbit-org carried byte-identical variants with
256
+ // a hardcoded #cd0365 — the brand deep pink, which is data's
257
+ // `pink.500`; both override this to it. The OSS default follows the
258
+ // languageText precedent: the primary interactive brand.
259
+ headingAccent: { value: "{colors.brand.500}" },
260
+ // The `primary`/`secondary` button variants' colours. Two brand
261
+ // idioms exist in the family: brand-coloured buttons (ml-trainer,
262
+ // python-editor — the defaults below) and a black-on-white system
263
+ // (classroom, data-microbit-org: black solid, black outline, no
264
+ // border colour change on hover but a blackAlpha wash instead).
265
+ // Tokens rather than per-app recipe overrides so both idioms share
266
+ // one recipe — a `variant` fork would be duplicated by every app on
267
+ // the far side of it. `primary`'s text colour stays a literal
268
+ // `white`: every app in the family puts white on a dark solid.
269
+ // `ghost` needs no tokens (black + blackAlpha in all four apps).
270
+ button: {
271
+ primaryBg: { value: "{colors.brand.500}" },
272
+ primaryHoverBg: { value: "{colors.brand.600}" },
273
+ primaryActiveBg: { value: "{colors.brand.700}" },
274
+ secondaryText: { value: "{colors.brand.700}" },
275
+ secondaryBorder: { value: "{colors.brand.500}" },
276
+ secondaryHoverBorder: { value: "{colors.brand.600}" },
277
+ secondaryHoverBg: { value: "transparent" },
278
+ secondaryActiveBorder: { value: "{colors.brand.700}" },
279
+ secondaryActiveBg: { value: "{colors.brand.50}" },
280
+ },
281
+ // Toast status colours (teal for every status except error), shared
282
+ // across the app family.
162
283
  toastInfoBg: { value: "{colors.teal.800}" },
163
284
  toastSuccessBg: { value: "{colors.teal.800}" },
164
285
  toastWarningBg: { value: "{colors.teal.800}" },
165
- toastErrorBg: { value: "{colors.danger.600}" },
286
+ // 500, the text-safe stop, rather than following the teal toasts'
287
+ // 800: white on it is 4.55:1 and an error toast should read as red.
288
+ toastErrorBg: { value: "{colors.danger.500}" },
166
289
  // The native app's status-bar area colour, shared by the ActionBar
167
290
  // and the full-size dialog's safe-area gradient.
168
291
  statusBarBg: { value: "{colors.brand2.500}" },
@@ -172,35 +295,45 @@ export const basePreset = definePreset({
172
295
  button,
173
296
  heading,
174
297
  input,
298
+ text,
299
+ tooltip,
175
300
  },
176
301
  slotRecipes: {
302
+ avatar,
303
+ breadcrumb,
177
304
  card,
178
305
  checkbox,
179
306
  dialog,
180
307
  drawer,
181
308
  field,
309
+ gridList,
310
+ listBox,
182
311
  menu,
183
312
  numberField,
184
313
  radio,
314
+ select,
185
315
  slider,
186
316
  switchRecipe,
187
317
  toast,
188
318
  },
189
319
  },
190
- // What ChakraProvider used to inject and Panda's preflight doesn't cover:
191
- // the theme's styles.global (body text/background defaults, global
192
- // border/placeholder colours) plus the parts of Chakra's CSS reset that
193
- // Panda's has no equivalent for kerning/text-rendering (their absence
194
- // shifts glyphs page-wide), word-wrap and touch-action. Token references
195
- // resolve against the merged preset stack, so the values track any brand
196
- // overrides exactly as they did under Chakra's runtime theme.
320
+ // Global defaults Panda's preflight doesn't cover: body text/background,
321
+ // placeholder colour, kerning/text-rendering (their absence shifts
322
+ // glyphs page-wide) and touch-action. Token references resolve against
323
+ // the merged preset stack, so the values track any brand overrides.
197
324
  globalCss: {
198
325
  html: {
199
326
  textRendering: "optimizeLegibility",
200
327
  touchAction: "manipulation",
328
+ // Alias preset-base's ring plumbing to our colour, so a stray use
329
+ // of its focusVisibleRing/focusRing* utilities renders in our
330
+ // ink rather than #005FCC. Still don't use them: un-gated focus
331
+ // selector, and this alias resolves on <html> (no tag awareness).
332
+ "--global-color-focus-ring": "var(--colors-focus-ring)",
201
333
  },
202
334
  body: {
203
- position: "relative",
335
+ // No `position: relative` (Chakra had it): it breaks react-aria's
336
+ // overlay positioning — see Tooltip's "In a scrolling page" story.
204
337
  minHeight: "100%",
205
338
  fontFeatureSettings: '"kern"',
206
339
  fontFamily: "body",
@@ -213,23 +346,70 @@ export const basePreset = definePreset({
213
346
  "*::placeholder": {
214
347
  color: "gray.500",
215
348
  },
216
- // The `* { border-color; word-wrap }` Chakra-reset parity lives in
217
- // ../reset.css, imported into the `reset` layer by consumers'
218
- // layers.css — NOT here: globalCss emits into the `base` layer, which
219
- // the legacy-Safari cascade-layer flattening specificity-boosts above
220
- // runtime-injected CSS (CodeMirror themes) and other app CSS files.
221
- // Resets must stay at the bottom (playbook gotcha #28).
222
- // Panda's preflight, unlike Chakra's reset, doesn't set the pointer
223
- // cursor on buttons. Recipes' disabled states (cursor: not-allowed)
224
- // override this from the higher recipes layer.
349
+ // The `* { border-color; word-wrap }` defaults live in ../reset.css,
350
+ // imported into the `reset` layer by consumers' layers.css — NOT here:
351
+ // globalCss emits into the `base` layer, which the legacy-Safari
352
+ // cascade-layer flattening specificity-boosts above runtime-injected
353
+ // CSS (CodeMirror themes) and other app CSS files. Resets must stay in
354
+ // the bottom layer.
355
+ // Panda's preflight doesn't set the pointer cursor on buttons.
356
+ // Recipes' disabled states (cursor: not-allowed) override this from
357
+ // the higher recipes layer.
225
358
  "button, [role='button']": {
226
359
  cursor: "pointer",
227
360
  },
228
- // Panda's preflight balance-wraps headings; Chakra didn't, and balanced
229
- // multi-line headings break at different points (mobile/translations).
361
+ // Panda's preflight balance-wraps headings; balanced multi-line
362
+ // headings break at different points (mobile/translations), so undo it.
230
363
  "h1, h2, h3, h4, h5, h6": {
231
364
  textWrap: "wrap",
232
365
  },
366
+ // The colour of the seams an attached ButtonGroup draws (ButtonGroup.tsx).
367
+ // Transparent, so a variant with no border of its own divides by letting
368
+ // the surface show through rather than by a line in the text colour.
369
+ //
370
+ // From `base`, so a variant that does bring a border colours its seams
371
+ // from `recipes` (as the cursor rule above is overridden). Longhands
372
+ // because Panda resolves `transparent` to a token, and the logical
373
+ // *shorthand* with a var() value is the one Safari 14.x drops — the
374
+ // postcss-legacy-safari shim does not cover the border ones.
375
+ "[data-attached] > *": {
376
+ borderInlineStartColor: "transparent",
377
+ borderInlineEndColor: "transparent",
378
+ },
379
+ // While a full-size dialog is open (the Modal stamps data-fullsize on
380
+ // its overlay), release the scrollbar gutter that react-aria's scroll
381
+ // lock reserves on the root. The reserved strip is scrollbar chrome to
382
+ // hit-testing — elementFromPoint returns null there, so clicks fall
383
+ // through to the root and dismiss the dialog, and controls near the
384
+ // right edge lose part of their target. With the page fully covered,
385
+ // the reflow this causes is invisible. !important: react-aria sets the
386
+ // reservation as a non-important inline style.
387
+ "html:has([data-fullsize])": {
388
+ scrollbarGutter: "auto !important",
389
+ },
390
+ // Toast enter/exit (the ToastQueue wraps updates in
391
+ // document.startViewTransition — see Toast.tsx, which also stamps the
392
+ // scoping class on <html> while its transitions run). Timings: 0.4s
393
+ // fade+slide in, 0.2s fade+shrink out; the
394
+ // stack reflow comes from the default group animation. `(*)` +
395
+ // `:only-child` matches exactly the entering/exiting toast groups: the
396
+ // root snapshot always has both old and new children, and toasts are
397
+ // the only named groups during a toast transition. The snapshot
398
+ // overlay must not eat clicks while a toast animates, hence
399
+ // pointer-events, scoped likewise.
400
+ "html.microbit-ui-toast-transition::view-transition": {
401
+ pointerEvents: "none",
402
+ },
403
+ // `both` fill: the snapshots must hold the keyframes' end states for
404
+ // however long the rest of the transition (e.g. the 0.25s default group
405
+ // animation) outlives them, or they snap back to full size/opacity for
406
+ // the remainder.
407
+ "html.microbit-ui-toast-transition::view-transition-new(*):only-child": {
408
+ animation: "toastSlideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) both",
409
+ },
410
+ "html.microbit-ui-toast-transition::view-transition-old(*):only-child": {
411
+ animation: "toastSlideOut 0.2s cubic-bezier(0.4, 0, 1, 1) both",
412
+ },
233
413
  },
234
414
  // shared-ui components forward `variant`/`size`/etc. as runtime props to
235
415
  // the recipe functions, so Panda's static analysis can't see which variants
@@ -238,7 +418,18 @@ export const basePreset = definePreset({
238
418
  // can silently lose runtime-prop variants.
239
419
  staticCss: {
240
420
  recipes: {
241
- button: ["*"],
421
+ // Size is passed responsively at call sites
422
+ // (`size={["md", "lg"]}`), so generate the breakpoint-prefixed variants
423
+ // too — otherwise the class lands on the element with no rule behind it
424
+ // and the button silently falls back to the base size.
425
+ avatar: ["*"],
426
+ // `tone` generates as its own rule, not crossed with `variant`: it
427
+ // only assigns the palette custom properties a shape reads.
428
+ button: [
429
+ { size: ["*"], responsive: true },
430
+ { variant: ["*"] },
431
+ { tone: ["*"] },
432
+ ],
242
433
  checkbox: ["*"],
243
434
  heading: ["*"],
244
435
  card: ["*"],
@@ -246,41 +437,89 @@ export const basePreset = definePreset({
246
437
  // as a runtime prop, so generate the breakpoint-prefixed variants too.
247
438
  dialog: [{ size: ["*"], responsive: true }, { centered: ["*"] }],
248
439
  drawer: ["*"],
440
+ field: ["*"],
441
+ gridList: ["*"],
442
+ listBox: ["*"],
249
443
  input: ["*"],
444
+ numberField: ["*"],
250
445
  radio: ["*"],
446
+ select: ["*"],
251
447
  switchRecipe: ["*"],
448
+ text: ["*"],
449
+ tooltip: ["*"],
252
450
  // Toast status is chosen at runtime from the toast content.
253
451
  toast: ["*"],
254
452
  },
255
453
  },
256
454
  utilities: {
257
455
  extend: {
258
- // The app-wide focus indicator, usually inside `_focusVisible`. Values
259
- // are the outline* shadow token names. The transparent outline is for
260
- // forced-colors modes, which strip box-shadows but recolour outlines to
261
- // a visible system colour. (Named to avoid preset-base's outline-based
262
- // `focusRing` utility, whose values would break this transform.)
263
- focusShadow: {
264
- className: "focus-shadow",
265
- values: ["outline", "outlineDark", "outlineLight"],
266
- transform: (value: string, { token }) => ({
267
- outline: "2px solid transparent",
268
- outlineOffset: "2px",
269
- boxShadow: token(`shadows.${value}`),
270
- }),
456
+ // The app-wide focus indicator, usually inside `_focusVisible`: a
457
+ // 2px `focusRing`-coloured outline at 2px offset the surface shows
458
+ // through the gap, and call sites never pick a ring per background.
459
+ // A real outline, so forced-colors modes keep a ring; longhands
460
+ // because Panda resolves tokens per-property. Thickness/contrast
461
+ // rationale: ui-private docs/a11y-positions.md. Shadows
462
+ // preset-base's `focusRing`: our transform replaces theirs, but the
463
+ // values arrays union, so its outside/inside/mixed/none typecheck
464
+ // here. `none` is honoured the alternative is a permanent un-gated
465
+ // ring; the other three fall through to the standard one. Don't use
466
+ // them.
467
+ focusRing: {
468
+ className: "focus-ring",
469
+ // `outlineInset` draws the ring just inside — for full-bleed rows
470
+ // whose outward ring would overhang their popover.
471
+ values: ["outline", "outlineInset"],
472
+ transform: (value: string, { token }) =>
473
+ value === "none"
474
+ ? { outlineStyle: "none" }
475
+ : {
476
+ outlineStyle: "solid",
477
+ outlineWidth: "2px",
478
+ outlineColor: token("colors.focusRing"),
479
+ outlineOffset: value === "outlineInset" ? "-2px" : "2px",
480
+ },
481
+ },
482
+ // preset-base's remaining ring plumbing sets --focus-ring-* custom
483
+ // properties only its own utilities read. Repointed at the outline
484
+ // longhands ours draws with, rather than left as no-ops that read
485
+ // like working knobs.
486
+ focusRingWidth: {
487
+ className: "focus-ring-w",
488
+ values: "borderWidths",
489
+ transform: (value: string) => ({ outlineWidth: value }),
490
+ },
491
+ focusRingOffset: {
492
+ className: "focus-ring-o",
493
+ values: "spacing",
494
+ transform: (value: string) => ({ outlineOffset: value }),
495
+ },
496
+ focusRingStyle: {
497
+ className: "focus-ring-s",
498
+ values: "borderStyles",
499
+ // `outlineStyle` is a keyword union, hence the cast.
500
+ transform: (value: string) => ({ outlineStyle: value as "solid" }),
271
501
  },
272
502
  },
273
503
  },
274
- // Widen the interaction conditions so the Chakra-shaped recipe/style objects
275
- // (`_hover`/`_active`/`_focusVisible`/`_disabled`) also respond to
504
+ // Widen the interaction conditions so recipe/style objects written with
505
+ // `_hover`/`_active`/`_focusVisible`/`_disabled` also respond to
276
506
  // react-aria-components' data attributes, not just native pseudo-classes.
277
507
  conditions: {
278
508
  extend: {
279
509
  hover: "&:is(:hover, [data-hovered])",
280
510
  active: "&:is(:active, [data-pressed])",
281
- focusVisible: "&:is(:focus-visible, [data-focus-visible])",
511
+ // Native :focus-visible counts only on elements RAC doesn't manage:
512
+ // react-aria's modality tracking is stricter than the browser's
513
+ // (e.g. focus restored from a menu after mouse-only use).
514
+ focusVisible:
515
+ "&:is(:focus-visible:not([data-rac]), [data-focus-visible])",
282
516
  disabled:
283
517
  "&:is(:disabled, [disabled], [data-disabled], [aria-disabled=true])",
518
+ // A dark-by-design surface (spread the exported `darkSurface` onto
519
+ // the bar element); scopes the focusRing/focusBorder flips. Never
520
+ // theme-relative: a future dark mode flips untagged defaults via
521
+ // token conditions, not markup.
522
+ onDark: '[data-surface="dark"] &',
284
523
  // High-contrast/forced-palette modes (e.g. Windows High Contrast), which
285
524
  // strip author backgrounds and box-shadows.
286
525
  forcedColors: "@media (forced-colors: active)",
@@ -960,9 +960,6 @@ export const shadows = {
960
960
  "2xl": {
961
961
  value: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
962
962
  },
963
- outline: {
964
- value: "0 0 0 3px rgba(66, 153, 225, 0.6)",
965
- },
966
963
  inner: {
967
964
  value: "inset 0 2px 4px 0 rgba(0,0,0,0.06)",
968
965
  },
@@ -5,9 +5,8 @@
5
5
  */
6
6
  import { cva } from "styled-system/css";
7
7
 
8
- // Chakra's ButtonIcon: keeps the glyph centred and spaced from the label
9
- // (iconSpacing 0.5rem). Shared by Button and LinkButton; deliberately not
10
- // exported from the package index.
8
+ // Keeps the glyph centred and spaced 0.5rem from the label. Shared by Button
9
+ // and LinkButton; deliberately not exported from the package index.
11
10
  export const buttonIcon = cva({
12
11
  base: {
13
12
  display: "inline-flex",
@@ -16,8 +15,8 @@ export const buttonIcon = cva({
16
15
  },
17
16
  variants: {
18
17
  side: {
19
- left: { marginEnd: "2" },
20
- right: { marginStart: "2" },
18
+ start: { marginEnd: "2" },
19
+ end: { marginStart: "2" },
21
20
  },
22
21
  },
23
22
  });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ /**
8
+ * The `data-*` entries of a props object, for components that let a caller
9
+ * put test hooks on an inner element rather than the one their props land on.
10
+ *
11
+ * Internal: not exported from the package.
12
+ */
13
+ export const dataAttrs = (props: object): Record<string, unknown> =>
14
+ Object.fromEntries(
15
+ Object.entries(props).filter(([key]) => key.startsWith("data-")),
16
+ );