@microbit/ui 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (115) hide show
  1. package/LICENSE.md +40 -0
  2. package/README.md +279 -26
  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 +62 -0
  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 +62 -0
  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 +62 -0
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +19 -10
  24. package/postcss-legacy-safari.cjs +96 -0
  25. package/reset.css +35 -0
  26. package/src/Avatar.recipe.ts +191 -0
  27. package/src/Avatar.tsx +278 -0
  28. package/src/Breadcrumb.recipe.ts +45 -0
  29. package/src/Breadcrumb.tsx +114 -0
  30. package/src/Button.recipe.ts +88 -50
  31. package/src/Button.tsx +68 -30
  32. package/src/ButtonGroup.tsx +37 -15
  33. package/src/Card.recipe.ts +1 -2
  34. package/src/Card.tsx +2 -1
  35. package/src/Checkbox.recipe.ts +49 -14
  36. package/src/Checkbox.tsx +111 -32
  37. package/src/CheckboxGroup.tsx +70 -0
  38. package/src/CloseButton.tsx +3 -3
  39. package/src/CloseIcon.tsx +6 -4
  40. package/src/Code.tsx +20 -0
  41. package/src/Collapse.tsx +179 -0
  42. package/src/ComboBox.tsx +246 -0
  43. package/src/Divider.tsx +72 -8
  44. package/src/Drawer.recipe.ts +21 -10
  45. package/src/Drawer.tsx +3 -4
  46. package/src/ExternalLink.tsx +43 -0
  47. package/src/Fade.tsx +62 -0
  48. package/src/Field.recipe.ts +114 -0
  49. package/src/Field.tsx +187 -0
  50. package/src/GridList.recipe.ts +57 -0
  51. package/src/GridList.tsx +81 -0
  52. package/src/Heading.recipe.ts +20 -1
  53. package/src/Heading.tsx +3 -3
  54. package/src/Icon.tsx +23 -7
  55. package/src/IconButton.tsx +8 -13
  56. package/src/Image.tsx +1 -1
  57. package/src/Input.recipe.ts +41 -28
  58. package/src/Input.tsx +23 -7
  59. package/src/InputGroup.tsx +26 -12
  60. package/src/Kbd.tsx +26 -0
  61. package/src/Link.tsx +3 -3
  62. package/src/LinkBox.tsx +2 -3
  63. package/src/LinkButton.tsx +81 -0
  64. package/src/List.tsx +8 -6
  65. package/src/ListBox.recipe.ts +43 -0
  66. package/src/ListBox.tsx +88 -0
  67. package/src/Menu.recipe.ts +51 -17
  68. package/src/Menu.tsx +117 -2
  69. package/src/Modal.recipe.ts +17 -9
  70. package/src/Modal.tsx +137 -24
  71. package/src/MoreMenuButton.tsx +63 -0
  72. package/src/NativeSelect.tsx +41 -14
  73. package/src/NativeSelectField.tsx +84 -0
  74. package/src/NumberField.recipe.ts +108 -0
  75. package/src/NumberField.tsx +138 -0
  76. package/src/PopoverArrow.tsx +19 -5
  77. package/src/ProgressBar.tsx +3 -5
  78. package/src/Radio.recipe.ts +108 -0
  79. package/src/Radio.tsx +62 -0
  80. package/src/RadioGroup.tsx +68 -0
  81. package/src/Select.recipe.ts +211 -0
  82. package/src/Select.tsx +194 -0
  83. package/src/SharedUIProvider.tsx +63 -7
  84. package/src/Skeleton.tsx +146 -0
  85. package/src/Slide.tsx +2 -2
  86. package/src/Slider.recipe.ts +27 -16
  87. package/src/Slider.tsx +78 -6
  88. package/src/Spinner.tsx +11 -9
  89. package/src/Svg.tsx +2 -3
  90. package/src/Switch.recipe.ts +57 -11
  91. package/src/Switch.tsx +39 -7
  92. package/src/Text.recipe.ts +26 -0
  93. package/src/Text.tsx +6 -2
  94. package/src/TextField.tsx +54 -40
  95. package/src/Toast.recipe.ts +52 -15
  96. package/src/Toast.tsx +151 -46
  97. package/src/Tooltip.recipe.ts +32 -0
  98. package/src/Tooltip.tsx +68 -28
  99. package/src/TooltipButton.tsx +229 -0
  100. package/src/UnmountCallback.tsx +2 -2
  101. package/src/VisuallyHidden.tsx +1 -1
  102. package/src/base-preset.ts +336 -84
  103. package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
  104. package/src/button-icon.ts +22 -0
  105. package/src/data-attrs.ts +16 -0
  106. package/src/dense-preset.ts +107 -0
  107. package/src/hooks/useBreakpointValue.ts +4 -4
  108. package/src/hooks/useClipboard.ts +63 -0
  109. package/src/hooks/useDisclosure.ts +31 -0
  110. package/src/hooks/useMediaQuery.ts +27 -0
  111. package/src/hooks/usePrevious.ts +15 -0
  112. package/src/index.ts +29 -3
  113. package/src/rac-locale.ts +33 -0
  114. package/src/system.ts +22 -1
  115. package/src/TextField.recipe.ts +0 -54
@@ -20,39 +20,106 @@ import {
20
20
  sizes,
21
21
  spacing,
22
22
  zIndex,
23
- } from "./chakra-tokens";
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";
31
+ import { radio } from "./Radio.recipe";
29
32
  import { drawer } from "./Drawer.recipe";
33
+ import { gridList } from "./GridList.recipe";
30
34
  import { heading } from "./Heading.recipe";
31
35
  import { input } from "./Input.recipe";
36
+ import { listBox } from "./ListBox.recipe";
37
+ import { numberField } from "./NumberField.recipe";
32
38
  import { menu } from "./Menu.recipe";
39
+ import { select } from "./Select.recipe";
33
40
  import { slider } from "./Slider.recipe";
34
41
  import { switchRecipe } from "./Switch.recipe";
35
42
  import { dialog } from "./Modal.recipe";
36
- import { field } from "./TextField.recipe";
43
+ import { text } from "./Text.recipe";
44
+ import { tooltip } from "./Tooltip.recipe";
45
+ import { field } from "./Field.recipe";
37
46
  import { toast } from "./Toast.recipe";
38
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
+
39
105
  /**
40
- * The base preset: the complete, working micro:bit design system. Chakra v2's
41
- * default token scales (chakra-tokens.ts snapshot), the micro:bit house style
42
- * (pill `radii.button`, `outline*` focus shadows, Helvetica fonts, the
43
- * `language`/`toolbar` button variants in Button.recipe.ts, the
106
+ * The base preset: the complete, working micro:bit design system. The base
107
+ * token scales (base-tokens.ts), the micro:bit house style
108
+ * (pill `radii.button`, the `focusRing` utility/token pair, Helvetica
109
+ * fonts, the
110
+ * `toolbar` button variant in Button.recipe.ts, the
44
111
  * `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui
45
- * component recipes, the react-aria condition widening, the Chakra-reset
46
- * 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
47
114
  * variants generated. Used alone it renders in the OSS default look.
48
115
  *
49
116
  * ── Brand contract ──────────────────────────────────────────────────────
50
117
  * A private brand preset (a sibling repo, e.g. CreateAI) is merged AFTER this
51
118
  * one to restyle everything by overriding just these token *values* (never
52
119
  * their names — see the CSS-var contract in the README):
53
- * - colours: the `brand` and `brand2` ramps (OSS defaults: Chakra
54
- * blue / Chakra's unmodified gray). Other ramps a brand tweaks
55
- * (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.
56
123
  * - font: `display` (OSS default: Helvetica; e.g. GT Walsheim privately).
57
124
  * The recipes and semantic tokens here reference those, so a brand swap needs
58
125
  * no recipe changes. With no private preset, these OSS defaults stand.
@@ -62,25 +129,44 @@ export const basePreset = definePreset({
62
129
  theme: {
63
130
  breakpoints,
64
131
  keyframes: {
65
- // Spinner's revolution (the only keyframe a shared-ui component uses).
132
+ // Spinner's revolution.
66
133
  spin: {
67
134
  "0%": { transform: "rotate(0deg)" },
68
135
  "100%": { transform: "rotate(360deg)" },
69
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
+ },
70
158
  },
71
159
  tokens: {
72
160
  colors: {
73
161
  ...colors,
74
- gray: {
75
- ...colors.gray,
76
- // Very light grays the family's designs use below Chakra's 50.
77
- 10: { value: "#fcfcfc" },
78
- 25: { value: "#f5f5f5" },
79
- },
162
+ gray,
163
+ red,
80
164
  // OSS default brand ramps (see the brand contract above). `brand`
81
- // aliases Chakra blue; `brand2` Chakra's *unmodified* gray (not the
82
- // `gray` above, whose 10/25 additions are lighter — getting this
83
- // 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.
84
170
  brand: colors.blue,
85
171
  brand2: colors.gray,
86
172
  },
@@ -102,16 +188,11 @@ export const basePreset = definePreset({
102
188
  },
103
189
  shadows: {
104
190
  ...shadows,
105
- // Chakra's outline shadow widened to 4px, plus dark/light-surface
106
- // companions. Consumed via the `focusShadow` utility.
107
- outline: { value: "0 0 0 4px rgba(66, 153, 225, 0.6)" },
108
- outlineDark: { value: "0 0 0 4px rgba(0, 0, 0, 0.5)" },
109
- outlineLight: { value: "0 0 0 4px rgba(255, 255, 255, 0.8)" },
110
191
  },
111
192
  fonts: {
112
193
  // Helvetica heading/body (4/4 apps); a brand preset leaves these and
113
194
  // overrides only `display` (the marketing font — see the brand
114
- // contract above). `mono` is Chakra's default stack.
195
+ // contract above).
115
196
  heading: { value: "Helvetica, Arial, sans-serif" },
116
197
  body: { value: "Helvetica, Arial, sans-serif" },
117
198
  mono: {
@@ -125,39 +206,86 @@ export const basePreset = definePreset({
125
206
  },
126
207
  semanticTokens: {
127
208
  colors: {
128
- // Checked/focus states of form controls (Chakra's default blue
129
- // colorScheme). Checkbox/Switch checked backgrounds, Input focus
130
- // border, and the Slider/ProgressBar fills (the last two are
131
- // near-dead defaults - current call sites override them).
132
- controlCheckedBg: { value: "{colors.blue.500}" },
133
- controlCheckedHoverBg: { value: "{colors.blue.600}" },
134
- focusBorder: { value: "{colors.blue.500}" },
135
- sliderFilledTrack: { value: "{colors.blue.500}" },
136
- progressFilledTrack: { value: "{colors.blue.500}" },
137
- // Error/destructive ramp (Chakra red). Destructive button variants,
138
- // field error states and the error toast; the record* button
139
- // variants deliberately stay on red.* (recording vocabulary, not
140
- // 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).
141
234
  danger: {
142
235
  50: { value: "{colors.red.50}" },
143
236
  100: { value: "{colors.red.100}" },
237
+ 200: { value: "{colors.red.200}" },
238
+ 300: { value: "{colors.red.300}" },
239
+ 400: { value: "{colors.red.400}" },
144
240
  500: { value: "{colors.red.500}" },
145
241
  600: { value: "{colors.red.600}" },
146
242
  700: { value: "{colors.red.700}" },
243
+ 800: { value: "{colors.red.800}" },
244
+ 900: { value: "{colors.red.900}" },
147
245
  },
148
- // The `language` button variant's text colour is the one place the
149
- // brands diverge structurally (OSS uses the grey brand2 ramp, the
150
- // CreateAI brand its blue brand ramp with no hover change). Driven by
151
- // these semantic tokens so the recipe stays shared and a brand preset
152
- // overrides only the values.
153
- languageText: { value: "{colors.brand2.500}" },
154
- languageTextHover: { value: "{colors.brand2.600}" },
155
- // Toast status colours: the Chakra-era toast Alert restyle (teal for
156
- // every status except error) shared across the app family.
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.
252
+ languageText: { value: "{colors.brand.500}" },
253
+ languageTextHover: { value: "{colors.brand.600}" },
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.
157
283
  toastInfoBg: { value: "{colors.teal.800}" },
158
284
  toastSuccessBg: { value: "{colors.teal.800}" },
159
285
  toastWarningBg: { value: "{colors.teal.800}" },
160
- 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}" },
161
289
  // The native app's status-bar area colour, shared by the ActionBar
162
290
  // and the full-size dialog's safe-area gradient.
163
291
  statusBarBg: { value: "{colors.brand2.500}" },
@@ -167,33 +295,45 @@ export const basePreset = definePreset({
167
295
  button,
168
296
  heading,
169
297
  input,
298
+ text,
299
+ tooltip,
170
300
  },
171
301
  slotRecipes: {
302
+ avatar,
303
+ breadcrumb,
172
304
  card,
173
305
  checkbox,
174
306
  dialog,
175
307
  drawer,
176
308
  field,
309
+ gridList,
310
+ listBox,
177
311
  menu,
312
+ numberField,
313
+ radio,
314
+ select,
178
315
  slider,
179
316
  switchRecipe,
180
317
  toast,
181
318
  },
182
319
  },
183
- // What ChakraProvider used to inject and Panda's preflight doesn't cover:
184
- // the theme's styles.global (body text/background defaults, global
185
- // border/placeholder colours) plus the parts of Chakra's CSS reset that
186
- // Panda's has no equivalent for kerning/text-rendering (their absence
187
- // shifts glyphs page-wide), word-wrap and touch-action. Token references
188
- // resolve against the merged preset stack, so the values track any brand
189
- // 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.
190
324
  globalCss: {
191
325
  html: {
192
326
  textRendering: "optimizeLegibility",
193
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)",
194
333
  },
195
334
  body: {
196
- 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.
197
337
  minHeight: "100%",
198
338
  fontFeatureSettings: '"kern"',
199
339
  fontFamily: "body",
@@ -206,21 +346,70 @@ export const basePreset = definePreset({
206
346
  "*::placeholder": {
207
347
  color: "gray.500",
208
348
  },
209
- "*, *::before, *::after": {
210
- borderColor: "gray.200",
211
- wordWrap: "break-word",
212
- },
213
- // Panda's preflight, unlike Chakra's reset, doesn't set the pointer
214
- // cursor on buttons. Recipes' disabled states (cursor: not-allowed)
215
- // 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.
216
358
  "button, [role='button']": {
217
359
  cursor: "pointer",
218
360
  },
219
- // Panda's preflight balance-wraps headings; Chakra didn't, and balanced
220
- // 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.
221
363
  "h1, h2, h3, h4, h5, h6": {
222
364
  textWrap: "wrap",
223
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
+ },
224
413
  },
225
414
  // shared-ui components forward `variant`/`size`/etc. as runtime props to
226
415
  // the recipe functions, so Panda's static analysis can't see which variants
@@ -229,45 +418,108 @@ export const basePreset = definePreset({
229
418
  // can silently lose runtime-prop variants.
230
419
  staticCss: {
231
420
  recipes: {
232
- 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
+ ],
433
+ checkbox: ["*"],
233
434
  heading: ["*"],
234
435
  card: ["*"],
235
436
  // Dialog size is chosen with responsive objects ({ base, md }) passed
236
437
  // as a runtime prop, so generate the breakpoint-prefixed variants too.
237
438
  dialog: [{ size: ["*"], responsive: true }, { centered: ["*"] }],
238
439
  drawer: ["*"],
440
+ field: ["*"],
441
+ gridList: ["*"],
442
+ listBox: ["*"],
443
+ input: ["*"],
444
+ numberField: ["*"],
445
+ radio: ["*"],
446
+ select: ["*"],
447
+ switchRecipe: ["*"],
448
+ text: ["*"],
449
+ tooltip: ["*"],
239
450
  // Toast status is chosen at runtime from the toast content.
240
451
  toast: ["*"],
241
452
  },
242
453
  },
243
454
  utilities: {
244
455
  extend: {
245
- // The app-wide focus indicator, usually inside `_focusVisible`. Values
246
- // are the outline* shadow token names. The transparent outline is for
247
- // forced-colors modes, which strip box-shadows but recolour outlines to
248
- // a visible system colour. (Named to avoid preset-base's outline-based
249
- // `focusRing` utility, whose values would break this transform.)
250
- focusShadow: {
251
- className: "focus-shadow",
252
- values: ["outline", "outlineDark", "outlineLight"],
253
- transform: (value: string, { token }) => ({
254
- outline: "2px solid transparent",
255
- outlineOffset: "2px",
256
- boxShadow: token(`shadows.${value}`),
257
- }),
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" }),
258
501
  },
259
502
  },
260
503
  },
261
- // Widen the interaction conditions so the Chakra-shaped recipe/style objects
262
- // (`_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
263
506
  // react-aria-components' data attributes, not just native pseudo-classes.
264
507
  conditions: {
265
508
  extend: {
266
509
  hover: "&:is(:hover, [data-hovered])",
267
510
  active: "&:is(:active, [data-pressed])",
268
- 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])",
269
516
  disabled:
270
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"] &',
271
523
  // High-contrast/forced-palette modes (e.g. Windows High Contrast), which
272
524
  // strip author backgrounds and box-shadows.
273
525
  forcedColors: "@media (forced-colors: active)",
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * (c) 2026, Micro:bit Educational Foundation and contributors
3
3
  *
4
+ * Token values (c) 2019, Segun Adebayo — Chakra UI's @chakra-ui/theme
5
+ * defaults, used under the MIT License (see LICENSE.md third-party notices).
6
+ *
4
7
  * SPDX-License-Identifier: MIT
5
8
  */
6
- /* eslint-disable */
7
- // GENERATED by bin/gen-chakra-tokens.mjs (snapshot of @chakra-ui/theme defaults).
8
- // Do not edit by hand. Canonical Chakra v2 foundation values in Panda token
9
- // format so the OSS preset matches Chakra exactly.
9
+ // The base token scales, in Panda token format. Began as a mechanical
10
+ // snapshot of Chakra UI v2's @chakra-ui/theme defaults; hand-maintained.
11
+ // Deliberately absent: `fonts` (foundation-owned, defined in base-preset.ts)
12
+ // and `transition.property` (no Panda token category; inlined at use sites).
10
13
 
11
14
  export const colors = {
12
15
  transparent: {
@@ -957,9 +960,6 @@ export const shadows = {
957
960
  "2xl": {
958
961
  value: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
959
962
  },
960
- outline: {
961
- value: "0 0 0 3px rgba(66, 153, 225, 0.6)",
962
- },
963
963
  inner: {
964
964
  value: "inset 0 2px 4px 0 rgba(0,0,0,0.06)",
965
965
  },
@@ -0,0 +1,22 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { cva } from "styled-system/css";
7
+
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.
10
+ export const buttonIcon = cva({
11
+ base: {
12
+ display: "inline-flex",
13
+ alignSelf: "center",
14
+ flexShrink: 0,
15
+ },
16
+ variants: {
17
+ side: {
18
+ start: { marginEnd: "2" },
19
+ end: { marginStart: "2" },
20
+ },
21
+ },
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
+ );