diff --git a/bin/i18n-packages.cjs b/bin/i18n-packages.cjs
index 21be923..64fc8a2 100644
--- a/bin/i18n-packages.cjs
+++ b/bin/i18n-packages.cjs
@@ -16,8 +16,10 @@ module.exports = [
crowdinDir: "new/packages/microbit-ui",
languages: [
"ca",
+ "cy",
"es-ES",
"fr",
+ "it",
"ja",
"ko",
"nl",
diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md
index 4545f08..fcc7bac 100644
--- a/docs/migration-playbook.md
+++ b/docs/migration-playbook.md
@@ -261,78 +261,104 @@ Numbering is stable — ml-trainer's doc, commit messages and reviews
reference these by number. #1–#16 are from ml-trainer's migration, #17–#18
from the library extraction.
-1. **CSS layer conflict (the big one).** Unlayered CSS always beats layered
- CSS regardless of specificity. During coexistence Chakra/Emotion are
- unlayered — hence `bin/unlayer-panda.mjs`. After the kill-switch the rule
- applies to **third-party stylesheets**: any unlayered vendor CSS beats
- every Panda rule (Swiper's `.swiper-slide { width: 100% }` collapsed
- ml-trainer's carousels). Import vendor stylesheets into the `vendor`
- cascade layer (`@import "..." layer(vendor)`), which `layers.css` orders
- between `reset` and `base` so vendor CSS beats the preflight but loses to
- app styling. Runtime CSS-in-JS (react-select's Emotion) cannot be
- layered — replace the component instead.
-2. **RAC interaction states.** The base preset widens Panda's `hover`/
- `active`/`focusVisible`/`disabled` conditions to also match RAC's
- `data-*` attributes, so Chakra-shaped `_hover`/`_active` style objects
- work unchanged on RAC.
-3. **`staticCss` for recipe variants.** Components forward `variant`/`size`
- as runtime props, invisible to Panda's static analysis. The base preset
- carries `staticCss` for library recipes; if an **app preset** adds
- recipes or variants selected at runtime, it must extend `staticCss` in
- the preset too (never only in `panda.config.ts` — a consumer that drops
- it silently loses variants; this migration's signature failure class).
-4. **Responsive recipe variants must be symmetric.** Panda applies the
- base-breakpoint variant's CSS unconditionally; if `full` sets more props
- than `4xl`, they leak into desktop. Every non-full dialog size restates
- the box props so the larger breakpoint fully overrides `full`.
-5. **Know exactly which ramps the app's theme overrode.** ml-trainer's OSS
- `brand2` is Chakra's _unmodified_ gray, not the locally overridden
- `gray` — conflating them made card text near-invisible. Check ramp
- provenance token-by-token (the differ helps).
-6. **OSS vs private divergence → semantic tokens.** Structural forks
- (variant colours, fonts, gradients) are driven by semantic tokens the
- private preset overrides (`languageText*`, the `display` font,
- `statusBarBg`), keeping recipes shared. Recipe extension is the escape
- hatch, not the plan.
-7. **Icons inherit `currentColor`.** Don't pass `fill` to react-icons (it
- overrides their default `fill="currentColor"` → black). `Icon`/
- `CloseIcon` set `fill: currentColor` in CSS.
-8. **Atomic overrides: same-property conflicts across separate `css()`
- calls race on stylesheet order** — cx'ing a base class with an override
- class does NOT mean the override wins; the winner is whichever atomic
- rule is emitted later. Merge base + overrides into a _single_
- `css(base, cssProp)` call so conflicts resolve at merge time. Related:
- longhand beats shorthand across calls; and a border shorthand plus
- separate `borderColor` in one object is order-dependent — use
- width/style longhands with `borderColor`.
-9. **Styles must be literals at the JSX/`css()` site.** Panda's extractor
- only reads `css` prop object literals and `css()` call literals where
- they appear — not objects returned from helper functions, not computed
- values (`rowSpan={n + 1}`, ``w={`${x}px`}``, ``w={`calc(...)`}``),
- not style props forwarded through a _plain_ wrapper component. It fails
- silently: classes are applied but no CSS exists, and a coincidental
- identical class from another call site can mask the miss — verify
- against the generated CSS, not the rendered page. What works: same-file
- consts, ternaries of literals, literal arithmetic, custom-named
- object-literal JSX props, and style props on `styled()`-factory
- components (cross-file). Fixes: wrap shared styling in a component with
- an inline `css` literal; give wrappers a `css` prop instead of
- forwarding style props; prefer recipe variants for dimensions (generated
- via `staticCss`, extraction-independent); use inline `style` (with
- runtime `token()` lookups) for data-driven values. After porting a
- file, grep it for non-literal style props. The `BoxProps`-forwarding
- count in each census is this gotcha's per-app budget.
+1. **CSS layer conflict (the big one).** Unlayered CSS always beats layered
+ CSS regardless of specificity. During coexistence Chakra/Emotion are
+ unlayered — hence `bin/unlayer-panda.mjs`. After the kill-switch the rule
+ applies to **third-party stylesheets**: any unlayered vendor CSS beats
+ every Panda rule (Swiper's `.swiper-slide { width: 100% }` collapsed
+ ml-trainer's carousels). Import vendor stylesheets into the `vendor`
+ cascade layer (`@import "..." layer(vendor)`), which `layers.css` orders
+ between `reset` and `base` so vendor CSS beats the preflight but loses to
+ app styling. Runtime CSS-in-JS (react-select's Emotion) cannot be
+ layered — replace the component instead.
+2. **RAC interaction states.** The base preset widens Panda's `hover`/
+ `active`/`focusVisible`/`disabled` conditions to also match RAC's
+ `data-*` attributes, so Chakra-shaped `_hover`/`_active` style objects
+ work unchanged on RAC.
+3. **`staticCss` for recipe variants.** Components forward `variant`/`size`
+ as runtime props, invisible to Panda's static analysis. The base preset
+ carries `staticCss` for library recipes; if an **app preset** adds
+ recipes or variants selected at runtime, it must extend `staticCss` in
+ the preset too (never only in `panda.config.ts` — a consumer that drops
+ it silently loses variants; this migration's signature failure class).
+4. **Responsive recipe variants must be symmetric.** Panda applies the
+ base-breakpoint variant's CSS unconditionally; if `full` sets more props
+ than `4xl`, they leak into desktop. Every non-full dialog size restates
+ the box props so the larger breakpoint fully overrides `full`.
+5. **Know exactly which ramps the app's theme overrode.** ml-trainer's OSS
+ `brand2` is Chakra's _unmodified_ gray, not the locally overridden
+ `gray` — conflating them made card text near-invisible. Check ramp
+ provenance token-by-token (the differ helps).
+6. **OSS vs private divergence → semantic tokens.** Structural forks
+ (variant colours, fonts, gradients) are driven by semantic tokens the
+ private preset overrides (`languageText*`, the `display` font,
+ `statusBarBg`), keeping recipes shared. Recipe extension is the escape
+ hatch, not the plan.
+7. **Icons inherit `currentColor`.** Don't pass `fill` to react-icons (it
+ overrides their default `fill="currentColor"` → black). `Icon`/
+ `CloseIcon` set `fill: currentColor` in CSS.
+8. **Atomic overrides: same-property conflicts across separate `css()`
+ calls race on stylesheet order** — cx'ing a base class with an override
+ class does NOT mean the override wins; the winner is whichever atomic
+ rule is emitted later. Merge base + overrides into a _single_
+ `css(base, cssProp)` call so conflicts resolve at merge time. Related:
+ longhand beats shorthand across calls; and a border shorthand plus
+ separate `borderColor` in one object is order-dependent — use
+ width/style longhands with `borderColor`.
+9. **Styles must be literals at the JSX/`css()` site.** Panda's extractor
+ only reads `css` prop object literals and `css()` call literals where
+ they appear — not objects returned from helper functions, not computed
+ values (`rowSpan={n + 1}`, ``w={`${x}px`}``, ``w={`calc(...)`}``),
+ not style props forwarded through a _plain_ wrapper component. It fails
+ silently: classes are applied but no CSS exists, and a coincidental
+ identical class from another call site can mask the miss — verify
+ against the generated CSS, not the rendered page. What works: same-file
+ consts, ternaries of literals, literal arithmetic, custom-named
+ object-literal JSX props, and style props on `styled()`-factory
+ components (cross-file). Fixes: wrap shared styling in a component with
+ an inline `css` literal; give wrappers a `css` prop instead of
+ forwarding style props; prefer recipe variants for dimensions (generated
+ via `staticCss`, extraction-independent); use inline `style` (with
+ runtime `token()` lookups) for data-driven values. After porting a
+ file, grep it for non-literal style props. The `BoxProps`-forwarding
+ count in each census is this gotcha's per-app budget.
10. **Removing Chakra/Emotion from a file isn't enough — also remove it
from `panda.config.ts`'s `exclude` list**, or Panda silently skips
extraction for the whole file (classes applied, no rules generated).
11. **Panda's `AspectRatio` pattern positions its child via a `&>*`
selector that a still-Chakra child's own `position` style beats**
(Emotion injects later at equal specificity). Symptom: the `::before`
- padding spacer stacks above the child. Use the native `aspectRatio`
- CSS property instead — arguably the better permanent form anyway.
+ padding spacer stacks above the child.
+
+ **Check the support floor before reaching for the native `aspectRatio`
+ property, which this gotcha used to recommend outright.** Native
+ `aspect-ratio` needs Safari 15, iOS 15 and Firefox 89; the family's floor
+ is `safari >= 14.1`, `ios_saf >= 14.5`, `firefox >= 88`, so three of five
+ targets don't have it and the declaration is simply dropped — the box
+ collapses to content height, with no fallback and nothing for lightningcss
+ to downlevel. Panda's pattern is the padding-bottom hack, exactly like
+ Chakra's, so it works everywhere: classroom measured the two identical
+ (wrapper 185x151, `::before` padding-bottom 150.922px, child absolute with
+ `object-fit: cover`).
+
+ The conflict this gotcha is really about only arises when the child is a
+ **Chakra** component carrying its own `position`. A plain element or an
+ already-ported child is fine, so during coexistence order the child's port
+ before the wrapper's and the pattern is safe.
+
+ **Worth auditing in the completed migrations**: ml-trainer has the same
+ floor and uses native `aspectRatio` in `tours.tsx` (x2) and
+ `NativeBluetoothConnectBatteryDialog.tsx`; python-editor's floor is
+ `Safari >= 14`/`iOS >= 14` and it uses it in the docs content, the ideas
+ page and `YoutubeVideoEmbed`. Those may want the pattern instead.
+
12. **RAC popovers unmount on close** (Chakra kept menu lists mounted), so
a hidden file input must live _outside_ a menu or its change event is
- dropped mid-pick — render it as a sibling and call it via ref.
+ dropped mid-pick — render it as a sibling and call it via ref. Putting one
+ inside is doubly wrong: see #33, where a non-collection child silently
+ deletes the rest of the menu. Chakra's keep-mounted behaviour also trips up
+ verification scripts — `document.querySelector('[role=menu]')` finds a
+ closed menu from an earlier step, so scope to the visible one.
13. **RAC popovers have `role="dialog"`** (menus included, lingering
briefly with `data-exiting` while animating out), so a bare Playwright
`getByRole("dialog")` can hit strict-mode ambiguity when a dialog opens
@@ -360,6 +386,15 @@ from the library extraction.
wrapper-forwarding observation — extraction is per-prop-name, not
per-component). A custom prop named like a utility (`content`) emits a
broken CSS rule; avoid utility names for non-style props.
+ Confirmed from the other direction in classroom: `` on a _plain_ wrapper that spreads onto a `styled()` svg
+ extracted fine and emitted `height: 23px` — Panda appends `px` to
+ unitless numbers for dimension properties, and resolves a number to a
+ token when one exists for that key, both exactly as Chakra did. So
+ #9's real scope is _non-literal_ values and non-utility prop names;
+ a literal utility prop survives a plain wrapper as long as the wrapper
+ forwards it to something styled. Verify in the generated CSS either way
+ — the class name may not be the one you guess (`h_23`, not `h_23px`).
18. **An `include` glob that matches nothing fails silently**, and recipe
styling still works via preset `staticCss`, so a wrong package-source
path shows up only as broken non-recipe styling. In npm workspaces the
@@ -463,6 +498,11 @@ from the library extraction.
compare `space`/`sizes`/`fontSizes` (any global scale) against Chakra
defaults and replicate overrides in the OSS app preset (the private
preset stacks on top — no mirror needed).
+ **classroom then turned out to carry the identical scale** (the same
+ 2022 theme change, byte-identical values), so it now lives in
+ `@microbit/ui/dense-preset`, stacked between the base and app presets
+ by both apps — one explicit, shared place for the override this gotcha
+ is about, and one place to answer the keep-vs-align question.
26. **Slider announced values: react-aria has no `aria-valuetext`
passthrough.** Chakra sliders often passed `aria-valuetext="20 °C"`;
@@ -528,6 +568,236 @@ from the library extraction.
supported hook), making the preflight itself carry the Chakra-parity
default.
+30. **`
` changes box model at the kill-switch.** Chakra's reset carries
+ normalize's `hr { box-sizing: content-box }`; Panda's preflight sets
+ `box-sizing: border-box` on everything and has **no `hr` exception**. So
+ an `` with an explicit height _plus_ top/bottom borders is one height
+ before the flip and 2px shorter after it, with nothing in the diff to
+ show why (same family as #22 — a preflight difference that only bites at
+ the flip). Panda's preflight also gives `hr` a `border-top-width: 1px`
+ that Chakra's didn't; the library Divider's `border: 0` base covers it.
+ Watch for the **zero-size-`` double-edge trick** — `borderWidth: 1px`
+ on all four sides of a 0-width ``, the two side borders reading as a
+ single 2px rule. It was in three apps, and besides being obscure it makes
+ the rule's length depend on the box model. `Divider`'s
+ `thickness="thick"` draws 2px on the orientation's own edge with no
+ top/bottom borders, so its height is whatever it is told — identical
+ either side of the kill-switch. classroom's logo divider was 35px
+ (33 + 2 borders) under Chakra and is now the 33px its code asks for: a
+ deliberate 2px change, taken in exchange for being box-model-stable.
+ It was also the _only_ pixel difference across five screens when the
+ leaf primitives were ported.
+
+31. **A recipe variant's flat value cannot override another variant group's
+ responsive one.** Chakra merged `size` and `variant` in JS before emitting,
+ so `` got the variant's flat
+ `fontSize: 4xl` at every width. Panda emits each variant as its own class
+ and hoists **every** media query into a block after all the base rules, so
+ above `md` the _size_ variant's media rule wins on source order no matter
+ how the recipe declares them — classroom measured 26.99px where Chakra gave
+ 32.4px. Nothing in the types or the generated class names hints at it; it
+ only shows above the breakpoint. Rules:
+
+ - An app-preset variant that sets a property the shared `size` variant sets
+ responsively must be paired with a **flat** size (`md`/`sm`/`xs`) or with
+ no size at all — check what `defaultVariants` then supplies, since it is
+ still in play (the `heading` recipe defaults to `size="xl"`, whose `md`
+ fontSize happens to be `4xl`, which is why dropping `size` reproduced
+ Chakra exactly).
+ - Do not reach for declaration order, `compoundVariants` ordering or a
+ matching responsive value in the variant: within the media block the
+ order is Panda's, not the recipe's.
+ - Distinct from #8: **a `styled()` factory's own props _do_ beat its recipe
+ base and variants**, because Panda merges base + variants + props before
+ emitting, so the element carries one class per property. #8's atomic race
+ is between separate `css()` calls cx'd together. Verified on Divider:
+ `borderLeftWidth={0}` over the recipe's `1px`, and a `borderColor` tint
+ over its `gray.200`, both take effect even though the recipe's classes
+ sit later in the stylesheet.
+
+32. **App code that reads Chakra's CSS variables breaks at the kill-switch, not
+ when you port the component.** `var(--chakra-colors-brand-500)` inside a
+ hand-written value — a gradient, a shadow, a border — keeps resolving for as
+ long as `ChakraProvider` is mounted, so it survives the port of its own
+ component and every screenshot comparison, then silently becomes an invalid
+ value when the provider goes. Gradients are the common case and they fail
+ to _nothing_, so the element just loses its background.
+
+ Audit it up front, not at the flip: `grep -rn -- "--chakra-" src/`. Panda
+ resolves `{colors.brand.500}` inside an arbitrary string value at build
+ time, which is the direct replacement (`background="linear-gradient(90deg,
+
+ {colors.brand.500} 0%, …)"`emits`var(--colors-brand-500)`). classroom had
+ one live instance, its homepage banner, plus one in a comment.
+
+33. **A non-collection child silently truncates a RAC collection.** A `Menu`,
+ `ListBox` or `GridList` builds its children into a collection, and anything
+ that is not a collection node — a `
`, a dialog, a plain element — ends
+ the collection at that point. Everything after it disappears. There is no
+ throw and no console warning, in dev or prod, so a typecheck and a unit test
+ that only asserts "renders" both pass.
+
+ Measured in classroom's port: `` renders one
+ item, and a component returning a fragment that _leads_ with a `
`
+ renders none at all — the whole menu comes back empty. The second shape is
+ the dangerous one, because it is what a "menu item that owns its dialog"
+ component looks like:
+
+ ```tsx
+ // Deletes every item in whatever menu renders it.
+ const LanguageMenuItem = () => (
+ <>
+
+
+ >
+ );
+ ```
+
+ Fragments, `null`, `false`, arrays and custom components are all fine, so
+ long as everything they resolve to is a collection node. Hoist dialogs (and
+ file inputs, per #12) out of the menu: give the opener to the item through
+ context or a prop, and render the dialog beside the `MenuTrigger` or at the
+ app root. classroom added a `LanguageDialogProvider` for exactly this, since
+ the item is rendered by six different menus.
+
+ `@microbit/ui` has a regression test asserting the truncation, so if
+ react-aria ever starts reporting it we can drop the workarounds.
+
+34. **A RAC popover leaves the stacking context it was opened from.** It always
+ portals to the body, so a menu, tooltip or select opened from inside a modal
+ is no longer painted by the modal — it needs a `z-index` above it or it
+ disappears behind. Chakra never showed this because its MenuList rendered
+ inline unless explicitly portalled, so the bug appears exactly at the port
+ and, in a full-screen modal, the menu is invisible rather than merely
+ clipped. The `menu` recipe now sits at `popover` (1500) rather than
+ `dropdown` (1000) for this reason; check any new overlay recipe against
+ `modal` (1400) before assuming the default scale is right.
+
+35. **The library Modal inserts an element between the dialog box and its
+ children.** Chakra's ModalContent was their direct parent, so a call site
+ that laid out its content by styling the box — `display: flex` plus
+ centring, most often — silently stops working: `contentCss` styles the box,
+ but the children are inside a flex-column `inner` element within it. The
+ tell is nasty, because every box measurement stays identical and only the
+ content moves (classroom's loading spinner drifted 141px off centre). Put
+ the layout on a wrapper inside the dialog instead.
+
+36. **Run the Panda codegen before any verification pass.** `npx vite` and
+ friends skip the `prestart`/`prebuild` hook, so `styled-system.css` is
+ whatever the last codegen produced and every atomic class introduced since
+ is missing from it. The failure looks exactly like a botched port — a
+ heading rendering at the slot's default 18px instead of the 43.2px the
+ `css` prop asks for — and the code looks right, because it is. Check the
+ generated CSS for the class before believing a measurement:
+ `grep -o "md\\:fs_5xl" src/styled-system.css`.
+
+37. **A component that hand-picks recipe variants breaks the preset extension
+ point.** `Input` and `TextField` destructured `size` and passed `{ size }`
+ to the recipe, leaving anything else in the rest-spread — so an app preset
+ that _adds_ a variant group got no styling at all and the prop landed on
+ the DOM as an unknown attribute. Nothing caught it because the base recipes
+ only had `size`; classroom's `variant="classroom"` inputs had been
+ rendering as plain outline boxes. Library components should use the
+ recipe's generated `splitVariantProps` so later presets keep working:
+
+ ```tsx
+ const [variantProps, rest] = input.splitVariantProps(props);
+ ;
+ ```
+
+ Worth grepping for when adding any component: a literal variant name inside
+ a recipe call is the smell.
+
+38. **react-select's behaviours are props on a ComboBox, not styling.** Four
+ of them, all of which classroom's sites relied on and none of which comes
+ free:
+
+ - It **opened its menu on click**; react-aria waits for typing
+ (`menuTrigger="focus"` restores it), which otherwise leaves the chevron
+ as the only way in.
+ - It **filtered on `label`, with `matchFrom: "start"` available**;
+ react-aria filters static children on `textValue`, always substring, so
+ prefix matching means filtering the children yourself.
+ - Its **`noOptionsMessage`** needs `allowsEmptyCollection`, or the popover
+ closes the moment nothing matches and the message never shows.
+ - Sites that **hid the menu with `display: none`** to gate on a query
+ length need a real "don't render the popover" prop; an empty list still
+ opens an empty card.
+
+ Also: react-aria renders a listbox's empty state as a `role="option"` row,
+ so a test that counts options counts "no matches" as a match.
+
+39. **`--trigger-width` is the input's width in a ComboBox, not the control's.**
+ RAC measures the element it anchors to, which for a ComboBox is the text
+ input inside the control — narrower than the field by its padding and
+ border, so a card sized from the var comes out visibly narrow. `Select` is
+ fine (its trigger is the button). The library's ComboBox measures its own
+ control instead, so consumers need do nothing; the trap is worth knowing if
+ you build another popover on RAC.
+
+ The obvious fix — reading the trigger ref while rendering the popover —
+ quietly does nothing: RAC mounts the popover from the first render, before
+ the ref is set, and nothing re-renders it afterwards. It needs state set
+ from a layout effect.
+
+40. **During coexistence a call site's `css` beats a recipe only on
+ specificity — there are no layers to settle it.** Gotcha #21 says a flat
+ utility override wins every state; that is true _after_ the kill-switch,
+ where `utilities` outranks `recipes` as a layer. While Chakra is still
+ mounted the layers are stripped (#1), so the two are ordinary rules and the
+ winner is the more specific one, or the later one at equal specificity
+ (utilities are emitted after recipes, so equal-specificity ties go to the
+ call site). Two consequences, both measured in classroom's roster port:
+
+ - **A recipe declaration a call site is expected to override must be a
+ single-class selector.** The Avatar's contrast rule was
+ `&[data-light-bg] { color: gray.800 }`, at (0,2,0), and it beat a call
+ site's `css={{ color: "gray.600" }}` at (0,1,0) — a greyed-out offline
+ student came out gray.800. State-derived values belong in a custom
+ property the base declaration reads (`color: var(--avatar-color, …)`),
+ which is also what Chakra did and what keeps an inline value from
+ beating the call site outright.
+ - **Restating a state is not enough if the recipe combines two of them.**
+ A ListBox option's `&[data-selected] { _hover: … }` is (0,3,0); an
+ override's `_hover` (0,2,0) and `&[data-selected]` (0,2,0) both lose to
+ it, so a selected _and_ hovered row keeps the recipe's background. Match
+ the combination: `"&[data-selected]": { bg: …, _hover: { bg: … } }`.
+
+ Both disappear at the kill-switch, which makes them easy to write off — but
+ they are wrong for the whole coexistence period, i.e. for every screenshot
+ anyone compares.
+
+41. **`styled` must be imported from `styled-system/jsx` to use the
+ `styled.tag` form.** `@microbit/ui` re-exports it, and the re-export is
+ fine for `styled(Component)` — but not for ``: Panda decides
+ whether a member expression is its factory by looking at where the
+ identifier was imported from, and a re-export is not that module. The JSX
+ renders, the classes land on the element, and no CSS exists for them —
+ gotcha #9's silent failure with a new cause. classroom's About-dialog
+ table lost every style this way (`border-collapse`, the caption, the row
+ rules), and only a grep of the generated CSS showed it.
+
+42. **An app preset's `globalCss` entry REPLACES the base preset's for the
+ same selector — it does not merge into it.** Everywhere else in a Panda
+ preset stack, later presets deep-merge; `globalCss` keys do not. So an app
+ that adds, say, `body: { WebkitFontSmoothing: "antialiased" }` silently
+ drops the base preset's whole `body` block — its colour, font family,
+ kerning, line height and background — because the base preset also keys on
+ `body`.
+
+ It is invisible in the source (both files look like additions), invisible
+ to a typecheck, and page-wide when it lands: classroom's text went from
+ gray.800 to black at its kill-switch, and lost the kerning
+ (`fontFeatureSettings`) with it, which shifts every glyph on every screen.
+
+ Rules: an app preset's `globalCss` must not use a selector the base preset
+ uses (currently `html`, `body`, `*::placeholder`, `button, [role='button']`
+ and `h1, h2, h3, h4, h5, h6`), or must restate what it is replacing. Group
+ selectors count as distinct keys, so `"html, body, #root"` is safe where
+ `body` is not — and an inherited property (font smoothing, colour) can
+ simply ride on a group selector that includes `html`. Check the generated
+ CSS for the base preset's body block after adding anything.
+
Also remember (from the RAC component work, not numbered): RAC re-selects a
pressed radio value against current state after any earlier handler runs —
"click the selected option again to deselect" interactions need a native
@@ -546,10 +816,33 @@ accepted — expect them, don't chase them as bugs:
machine (unbuilt).
- **Focus rings show after mouse interaction** in places Chakra hid them
(auto-focused dialog buttons, slider thumbs).
+- **A menu opened with the mouse focuses no item.** Chakra highlighted the
+ first one however the menu was opened; RAC highlights it only for a keyboard
+ open, and Escape still returns focus to the trigger (both verified in
+ classroom). It is the whole of the remaining screenshot diff on a faithful
+ menu port, so expect it and don't chase it.
+- **Choosing an option in a `MenuOptionGroup` leaves the menu open.** That
+ matches Chakra's checkbox groups; Chakra's radio groups closed.
- **Dialogs open with focus on the dialog element itself** (announces the
title — an a11y improvement) unless something has `autoFocus`; Chakra
focused the first control (see gotcha #15 for when to add `autoFocus`
back).
+- **`scrollBehavior` is gone.** Chakra defaulted to `inside` (the box capped
+ at the viewport, its body scrolling); the library always scrolls the
+ backdrop, which is Chakra's `outside`. Nothing in the family needed the
+ distinction — classroom's eight `outside` sites simply dropped the prop —
+ but a dialog taller than the viewport now grows the page rather than
+ scrolling internally.
+- **`preserveScrollBarGap` and `blockScrollOnMount` have no equivalent**; RAC
+ does its own scroll locking. Drop them.
+- **Toast padding is roomier than Chakra's** (measured in classroom: 14.08px
+ vertical against 10.56px, and 35.2px against 28.16px on the close-button
+ side, at the same width). Long descriptions that fitted on one line may wrap.
+- **Chakra's `Progress` `hasStripe`/`isAnimated` have no equivalent.** The
+ ProgressBar takes a percentage, not value+max, and needs an explicit
+ `aria-label` where Chakra's had none. If the stripes matter, restate them at
+ the call site with a `barCss` gradient over a keyframe in the app preset
+ (classroom's ProgressDialog does).
- **Toast semantics**: one top-centre region (no per-call `position`/
`variant`); `duration` defaults to 5000ms and there is no
`duration: null` — use `persistent: true` (which forces the close
@@ -578,6 +871,20 @@ Priority: **python-editor-v3 and classroom are what matter**;
data-microbit-org can trail by months. ml-trainer is done (the pilot).
Censuses were taken July 2026 against Chakra v2.10 in all apps.
+### Open across the completed migrations
+
+- **Native `aspect-ratio` below the support floor** (see gotcha #11, corrected
+ 2026-08-02 — it previously recommended exactly this). It needs Safari 15 /
+ iOS 15 / Firefox 89; where an app's floor is lower the declaration is dropped
+ and the box collapses to content height, silently and with no fallback.
+ Panda's `AspectRatio` pattern is the padding-bottom hack and works at any
+ floor. **To check**: ml-trainer (floor 14.1 / 14.5 / 88) at `tours.tsx` x2
+ and `NativeBluetoothConnectBatteryDialog.tsx`; python-editor (floor
+ `Safari >= 14`, `iOS >= 14`) in the docs content, the ideas page and
+ `YoutubeVideoEmbed`. Not verified as visibly broken on those browsers —
+ someone with a device or a Safari 14 VM should confirm before deciding
+ whether to swap them back to the pattern.
+
### v1 surface (build in the library, on demand)
Policy: anything _clearly core_ design-system goes in the library even with
@@ -585,27 +892,41 @@ a single current consumer — family-wide consistency is a goal; app-local
builds recreate the divergence being retired. Only genuinely app-flavoured
pieces stay app-side.
-- **Select/ComboBox** — retires react-select family-wide (classroom's
- `SelectDropdown`/`SelectWithIcon` wrappers sketch the API; RAC ComboBox +
- `useAsyncList` covers the async school-lookup case).
-- **Collapse + Fade** transition primitives (python-editor ~14 files;
- classroom/data one-offs).
-- **Tabs** (recipe in the library; python-editor's branded sidebar variant
- is preset-side styling).
-- Menu: checkable items (RAC has selection natively), sections, separator.
-- Modal: `role="alertdialog"` mode + least-destructive initial focus (every
- app has a ConfirmDialog).
-- Radio/RadioGroup (promote from ml-trainer's raw RAC usage); **GridList**
- (promote from classroom's hand-rolled react-aria hooks; also ml-trainer's
- parked projects-page idea).
-- Table, TextField error slot, input adornments, Portal-as-primitive,
- Skeleton/SkeletonText, Breadcrumb, Avatar, NumberInput; cheap typography
- wrappers (Kbd/Code/Tag/Mark) as first needed.
-- Hooks: `useMediaQuery`, `usePrevious`, `useClipboard`,
- `usePrefersReducedMotion`.
+Built since (check `packages/ui/src/index.ts` before assuming a gap — this
+list is what was outstanding when the censuses were taken): Collapse + Fade,
+Menu checkable items/sections/separator, Modal `role="alertdialog"`,
+Radio/RadioGroup, NumberField, Kbd/Code, `useMediaQuery`/`usePrevious`/
+`useClipboard`/`useBreakpointValue`.
+
+Still outstanding, in classroom's likely order of need:
+
+- ~~**Select/ComboBox**~~ — **built** (classroom, area 6), retiring
+ react-select there; one `select` slot recipe behind both. Sections,
+ multi-select and async loading via `useAsyncList` are still unbuilt — the
+ data-microbit-org school lookup will want the last of those.
+- ~~**GridList**~~, ~~**Avatar** (+ badge)~~ and ~~**ListBox**~~ — **built**
+ (classroom, area 7), retiring the last of its hand-rolled react-aria v3
+ hooks. Avatar reproduces Chakra's name-hash colour and its contrast rule
+ exactly, so a migrating roster keeps its colours. ListBox arrived with the
+ GridList because the two are the halves of the same question: rows with
+ their own controls need the grid, leaf options the listbox. `Checkbox`
+ gained `control={false}` at the same time, for a checkbox whose children
+ draw the selected state (a selectable tile).
+- Portal-as-primitive, TextField error slot, input adornments,
+ Skeleton/SkeletonText, Breadcrumb, NumberInput; cheap typography
+ wrappers (Tag/Mark) as first needed.
+- `usePrefersReducedMotion`.
+- **Tabs** — stayed app-side in python-editor (special-purpose sidebar
+ chrome); waits for a second consumer, at which point the RAC markup and a
+ generalised recipe extract cleanly.
- Stays app-side: classroom's `active` button variant, Stepper, app-chrome
compositions (ActionBar stays an app component over shared primitives +
`statusBarBg`-family tokens).
+- **Table: decided against** a shared component. python-editor's one table
+ (the About dialog's version/dependency list) is a `styled.table` with
+ Panda styles at the site, and that reads better than a slot recipe
+ wrapping native table semantics. Both remaining apps have ~1 table site
+ each; do the same unless one grows a real data table.
### App order and notes
@@ -637,9 +958,10 @@ pieces stay app-side.
sidebar chrome; a generic library Tabs waits for a second consumer —
the RAC markup and a generalised recipe extract cleanly), likewise
SplitView; the app's teal is a _code/content_ semantic, not `brand2`
- (see Cross-app vocabulary); its bespoke density scale (spacing × 0.88,
- fontSizes md+ × 0.9, see gotcha #25) is replicated in its app preset
- pending a keep-vs-align-with-family-scale discussion.
+ (see Cross-app vocabulary); its density scale (spacing × 0.88, fontSizes
+ md+ × 0.9, see gotcha #25) turned out to be shared verbatim with
+ classroom and now comes from `@microbit/ui/dense-preset`, still pending a
+ keep-vs-align-with-family-scale discussion.
3. **data-microbit-org** — whenever convenient; by then the surface is
covered. Census highlights: fully private repo, no theme-package split;
brand assets committed in-repo. **Multi-root**: three apps in one repo,
@@ -681,6 +1003,18 @@ component in three apps. Default button variant differs (`secondary` in
ml-trainer/classroom/data, `outline` in python-editor) — recipes'
`defaultVariants` must stay preset-overridable per app.
+**Two button colour idioms, one recipe.** The `primary`/`secondary` variants
+split 2–2: brand-coloured (ml-trainer, python-editor) vs black-on-white
+(classroom, data-microbit-org — black solid, black outline, and a
+blackAlpha wash on hover/press instead of a border-colour change). Both
+resolve through `button.*` semantic tokens in the base preset
+(`primaryBg`/`primaryHoverBg`/`primaryActiveBg`,
+`secondaryText`/`secondaryBorder`/`secondaryHover*`/`secondaryActive*`), so
+the second idiom is nine token values in an app preset rather than a forked
+variant — which is what the two apps on that side would otherwise both
+write. `primary`'s text stays a literal `white` (4/4 apps) and `ghost`
+needs no tokens (black + blackAlpha in 4/4).
+
**Same slot number ≠ same role — check usage semantics before mapping an
app's second hue onto `brand2`.** python-editor's investigation:
ml-trainer's `brand2` is a general secondary accent (LED/progress/toggle/
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 1710caf..3f4d113 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -19,8 +19,10 @@ an app must do:
1. **Panda preset stack** (`panda.config.ts`): `@pandacss/preset-base`, then
the **base preset** (`@microbit/ui/base-preset` — the complete micro:bit
- design system), then optionally the app's own preset, then optionally a
- **private brand preset** (Foundation colours, licensed fonts).
+ design system), then optionally `@microbit/ui/dense-preset` (the × 0.88
+ spacing / × 0.9 font-size density the information-dense apps use), then
+ optionally the app's own preset, then optionally a **private brand
+ preset** (Foundation colours, licensed fonts).
Later presets override earlier ones token-by-token — the base recipes and
semantic tokens reference the brand tokens, which is how a brand swap
@@ -162,9 +164,9 @@ keep them stable:
- Brand/app presets may change token _values_, never token _names_.
Semantic tokens (`languageText`, `statusBarBg`, `danger.*`, `toast*Bg`,
-`controlCheckedBg`, `focusBorder`, …) are the extension points brand presets
-override; they resolve through var indirection, so overrides apply wherever
-the token is consumed.
+`button.*`, `controlCheckedBg`, `focusBorder`, …) are the extension points
+brand presets override; they resolve through var indirection, so overrides
+apply wherever the token is consumed.
## Runtime token lookups
diff --git a/packages/ui/lang/ui.cy.json b/packages/ui/lang/ui.cy.json
new file mode 100644
index 0000000..61c8d00
--- /dev/null
+++ b/packages/ui/lang/ui.cy.json
@@ -0,0 +1,22 @@
+{
+ "ui.close-action": {
+ "defaultMessage": "Cau",
+ "description": "Close button text or label"
+ },
+ "ui.toast-status-error": {
+ "defaultMessage": "Gwall",
+ "description": "Announced by screen readers before an error notification"
+ },
+ "ui.toast-status-info": {
+ "defaultMessage": "Information",
+ "description": "Announced by screen readers before an informational notification"
+ },
+ "ui.toast-status-success": {
+ "defaultMessage": "Success",
+ "description": "Announced by screen readers before a success notification"
+ },
+ "ui.toast-status-warning": {
+ "defaultMessage": "Rhybudd",
+ "description": "Announced by screen readers before a warning notification"
+ }
+}
diff --git a/packages/ui/lang/ui.it.json b/packages/ui/lang/ui.it.json
new file mode 100644
index 0000000..850f38e
--- /dev/null
+++ b/packages/ui/lang/ui.it.json
@@ -0,0 +1,22 @@
+{
+ "ui.close-action": {
+ "defaultMessage": "Chiudi",
+ "description": "Close button text or label"
+ },
+ "ui.toast-status-error": {
+ "defaultMessage": "Errore",
+ "description": "Announced by screen readers before an error notification"
+ },
+ "ui.toast-status-info": {
+ "defaultMessage": "Information",
+ "description": "Announced by screen readers before an informational notification"
+ },
+ "ui.toast-status-success": {
+ "defaultMessage": "Success",
+ "description": "Announced by screen readers before a success notification"
+ },
+ "ui.toast-status-warning": {
+ "defaultMessage": "Attenzione",
+ "description": "Announced by screen readers before a warning notification"
+ }
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 35f6ab5..ed58b76 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -8,6 +8,7 @@
".": "./src/index.ts",
"./base-preset": "./src/base-preset.ts",
"./base-tokens": "./src/base-tokens.ts",
+ "./dense-preset": "./src/dense-preset.ts",
"./messages": "./src/messages.ts",
"./postcss-legacy-safari": "./postcss-legacy-safari.cjs",
"./reset.css": "./reset.css",
diff --git a/packages/ui/src/Avatar.recipe.ts b/packages/ui/src/Avatar.recipe.ts
new file mode 100644
index 0000000..95a3ad6
--- /dev/null
+++ b/packages/ui/src/Avatar.recipe.ts
@@ -0,0 +1,168 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { defineSlotRecipe } from "@pandacss/dev";
+
+/**
+ * Avatar slot recipe — Chakra's avatar: a circle showing an image, the
+ * initials of a name, or a generic person glyph, optionally with a badge
+ * pinned to one corner.
+ *
+ * The background and text colour come from `var(--avatar-bg)` and
+ * `var(--avatar-color)` rather than being flat values, because the component
+ * derives them from the name (see Avatar.tsx) and writes them as inline custom
+ * properties — exactly as Chakra did. Two reasons, both about letting a call
+ * site win with a plain `css={{ bg: …, color: … }}`: an inline *property*
+ * would beat any class, where an inline *variable* only feeds this
+ * declaration; and both must stay single-class selectors, since a state
+ * selector like `&[data-light-bg]` outranks the call site's utility class on
+ * specificity wherever cascade layers aren't in play — which is every app
+ * still coexisting with Chakra (playbook gotcha #40).
+ *
+ * Sizes are Chakra's, with its `calc(size / 2.5)` font size resolved per size
+ * so an app preset can restate either independently (classroom's avatars are
+ * a grade larger than Chakra's).
+ *
+ * Registered in the base preset (base-preset.ts), which also has the
+ * `staticCss` entry that keeps the runtime-prop variants generated.
+ */
+export const avatar = defineSlotRecipe({
+ className: "avatar",
+ slots: ["root", "label", "image", "badge"],
+ base: {
+ root: {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ position: "relative",
+ verticalAlign: "top",
+ textAlign: "center",
+ textTransform: "uppercase",
+ fontWeight: "medium",
+ borderRadius: "full",
+ // Chakra's no-name defaults; the name-derived pair arrives inline.
+ background: "var(--avatar-bg, token(colors.gray.400))",
+ color: "var(--avatar-color, token(colors.white))",
+ borderColor: "white",
+ },
+ label: {
+ lineHeight: "1",
+ },
+ image: {
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ borderRadius: "inherit",
+ },
+ badge: {
+ position: "absolute",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: "full",
+ // em-relative, so a badge keeps its proportions at every avatar size.
+ borderWidth: "0.2em",
+ borderStyle: "solid",
+ borderColor: "white",
+ },
+ },
+ variants: {
+ // Chakra's scale: the container size, and Chakra's `calc(size / 2.5)`
+ // font size kept as a calc over the same token so both track a preset
+ // that rescales `sizes` (the dense preset does, by 0.88).
+ //
+ // The font size lands on the root *and* the label, as Chakra's did
+ // (through one variable). They are separate declarations so an app can
+ // move one without the other: the root's is the em basis for a badge,
+ // the label's is how big the initials are, and the two are not always
+ // the same wish.
+ size: {
+ "2xs": {
+ root: {
+ width: "4",
+ height: "4",
+ fontSize: "calc(token(sizes.4) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.4) / 2.5)" },
+ },
+ xs: {
+ root: {
+ width: "6",
+ height: "6",
+ fontSize: "calc(token(sizes.6) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.6) / 2.5)" },
+ },
+ sm: {
+ root: {
+ width: "8",
+ height: "8",
+ fontSize: "calc(token(sizes.8) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.8) / 2.5)" },
+ },
+ md: {
+ root: {
+ width: "12",
+ height: "12",
+ fontSize: "calc(token(sizes.12) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.12) / 2.5)" },
+ },
+ lg: {
+ root: {
+ width: "16",
+ height: "16",
+ fontSize: "calc(token(sizes.16) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.16) / 2.5)" },
+ },
+ xl: {
+ root: {
+ width: "24",
+ height: "24",
+ fontSize: "calc(token(sizes.24) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.24) / 2.5)" },
+ },
+ "2xl": {
+ root: {
+ width: "32",
+ height: "32",
+ fontSize: "calc(token(sizes.32) / 2.5)",
+ },
+ label: { fontSize: "calc(token(sizes.32) / 2.5)" },
+ },
+ },
+ /** Which corner the badge sits in. Chakra's placements, same offsets. */
+ placement: {
+ "top-start": {
+ badge: {
+ top: "0",
+ insetStart: "0",
+ transform: "translate(-25%, -25%)",
+ },
+ },
+ "top-end": {
+ badge: { top: "0", insetEnd: "0", transform: "translate(25%, -25%)" },
+ },
+ "bottom-start": {
+ badge: {
+ bottom: "0",
+ insetStart: "0",
+ transform: "translate(-25%, 25%)",
+ },
+ },
+ "bottom-end": {
+ badge: { bottom: "0", insetEnd: "0", transform: "translate(25%, 25%)" },
+ },
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ placement: "bottom-end",
+ },
+});
diff --git a/packages/ui/src/Avatar.tsx b/packages/ui/src/Avatar.tsx
new file mode 100644
index 0000000..59339c4
--- /dev/null
+++ b/packages/ui/src/Avatar.tsx
@@ -0,0 +1,276 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ cloneElement,
+ CSSProperties,
+ HTMLAttributes,
+ isValidElement,
+ ReactElement,
+ ReactNode,
+ SVGProps,
+ useEffect,
+ useState,
+} from "react";
+import { css, cx } from "styled-system/css";
+import { avatar, AvatarVariantProps } from "styled-system/recipes";
+import { token } from "styled-system/tokens";
+import { SystemStyleObject } from "styled-system/types";
+
+/**
+ * Chakra's `randomColor({ string })`, reproduced exactly: a djb2-style hash of
+ * the name, its low three bytes read as a colour. Not random despite the name
+ * — the same name is always the same colour, which is the point, and
+ * reproducing the hash means avatars keep the colours they had under Chakra.
+ */
+const colorFromName = (name: string): string => {
+ let hash = 0;
+ for (let i = 0; i < name.length; i += 1) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ hash = hash & hash;
+ }
+ let color = "#";
+ for (let j = 0; j < 3; j += 1) {
+ const value = (hash >> (j * 8)) & 255;
+ color += `00${value.toString(16)}`.slice(-2);
+ }
+ return color;
+};
+
+/**
+ * Chakra's contrast rule for the generated background: perceived brightness
+ * (the classic 299/587/114 weighting) below 128 counts as dark, and dark
+ * backgrounds take white text.
+ */
+const isLight = (hex: string): boolean => {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return (r * 299 + g * 587 + b * 114) / 1000 >= 128;
+};
+
+/**
+ * Chakra's `initials`: first letter of the first and last words. Prefixed
+ * because it is exported from the package root, where a bare `initials`
+ * would be a broad name to claim.
+ */
+export const avatarInitials = (name: string): string => {
+ const names = name.trim().split(" ");
+ const firstName = names[0] ?? "";
+ const lastName = names.length > 1 ? names[names.length - 1] : "";
+ return firstName && lastName
+ ? `${firstName.charAt(0)}${lastName.charAt(0)}`
+ : firstName.charAt(0);
+};
+
+/**
+ * Chakra's generic person glyph, the fallback when there is no name. Chakra
+ * hardcoded it white; here it inherits `currentColor`, which is the same white
+ * on the no-name grey background and stays visible if a call site recolours.
+ */
+export const GenericAvatarIcon = (props: SVGProps) => (
+
+);
+
+type ImageStatus = "pending" | "loading" | "loaded" | "failed";
+
+/**
+ * Chakra's `useImage`: load the photo out of band and report how it went, so
+ * the avatar can show the initials or the icon meanwhile and keep showing
+ * them if it never arrives.
+ *
+ * The element is only mounted once this says "loaded", which is what
+ * keeps a broken URL from leaving the browser's broken-image glyph inside the
+ * circle — the failure mode a fallback exists to prevent.
+ */
+const useImageStatus = (src?: string, srcSet?: string): ImageStatus => {
+ const [status, setStatus] = useState(
+ src ? "loading" : "pending",
+ );
+ useEffect(() => {
+ if (!src) {
+ setStatus("pending");
+ return;
+ }
+ // A new src starts again: without this the avatar would keep showing the
+ // previous person's photo, or stay stuck on a fallback it has outgrown.
+ setStatus("loading");
+ const img = new Image();
+ let current = true;
+ img.onload = () => {
+ if (current) {
+ setStatus("loaded");
+ }
+ };
+ img.onerror = () => {
+ if (current) {
+ setStatus("failed");
+ }
+ };
+ // srcSet before src, so the browser has the candidates to choose from
+ // when the load starts.
+ if (srcSet) {
+ img.srcset = srcSet;
+ }
+ img.src = src;
+ return () => {
+ current = false;
+ img.onload = null;
+ img.onerror = null;
+ };
+ }, [src, srcSet]);
+ return status;
+};
+
+export interface AvatarProps
+ extends Omit, "color" | "children">,
+ Pick {
+ /**
+ * The person. Shown as initials, and hashed into the background colour, so
+ * two people are unlikely to share one.
+ */
+ name?: string;
+ /**
+ * Photo. The initials (or the icon) show until it has loaded, and go on
+ * showing if it fails — the avatar never renders a broken image.
+ */
+ src?: string;
+ srcSet?: string;
+ /** Shown when there is no name. Defaults to Chakra's person glyph. */
+ icon?: ReactNode;
+ /** Accessible name for the icon fallback. Chakra's default was " avatar". */
+ iconLabel?: string;
+ /** Override how a name becomes initials. */
+ getInitials?: (name: string) => string;
+ /** Chakra's `showBorder`: a 2px ring in the avatar's border colour. */
+ showBorder?: boolean;
+ /** An `AvatarBadge`. */
+ children?: ReactNode;
+ /** Per-instance style overrides, merged after the recipe. */
+ css?: SystemStyleObject;
+ className?: string;
+}
+
+/**
+ * Avatar — Chakra's : a circular identity marker showing a photo, the
+ * initials of a name, or a generic glyph, in a colour derived from the name.
+ *
+ * Decorative in most designs — pass `aria-hidden` where the name is already
+ * beside it, as Chakra's call sites did.
+ */
+export const Avatar = ({
+ name,
+ src,
+ srcSet,
+ icon,
+ iconLabel = " avatar",
+ getInitials = avatarInitials,
+ showBorder,
+ size,
+ children,
+ css: cssProp,
+ className,
+ style,
+ ...rest
+}: AvatarProps) => {
+ const status = useImageStatus(src, srcSet);
+ const isLoaded = status === "loaded";
+ const slots = avatar({ size });
+ // Only while the image isn't showing, matching Chakra's `:not([data-loaded])`.
+ const bg = name && !isLoaded ? colorFromName(name) : undefined;
+ return (
+
+ {isLoaded ? (
+
+ ) : name ? (
+
+ {getInitials(name)}
+
+ ) : // The icon is labelled in place rather than wrapped, as Chakra did:
+ // a wrapper would make it an inline child with a line box of its own,
+ // where directly in the flex container it is a flex item and centres
+ // exactly.
+ isValidElement(icon) ? (
+ cloneElement(icon as ReactElement>, {
+ role: "img",
+ "aria-label": iconLabel,
+ })
+ ) : (
+ icon ??
+ )}
+ {children}
+
+ );
+};
+
+export interface AvatarBadgeProps
+ extends Omit, "color">,
+ Pick {
+ children?: ReactNode;
+ /**
+ * Per-instance style overrides. The badge has no size of its own — Chakra's
+ * didn't either, so call sites set one (`boxSize: "1.5em"` scales with the
+ * avatar).
+ */
+ css?: SystemStyleObject;
+ className?: string;
+}
+
+/** AvatarBadge — a status dot pinned to a corner of its `Avatar`. */
+export const AvatarBadge = ({
+ placement,
+ children,
+ css: cssProp,
+ className,
+ ...rest
+}: AvatarBadgeProps) => (
+
+ {children}
+
+);
diff --git a/packages/ui/src/Button.recipe.ts b/packages/ui/src/Button.recipe.ts
index 890a9c3..2e857f4 100644
--- a/packages/ui/src/Button.recipe.ts
+++ b/packages/ui/src/Button.recipe.ts
@@ -24,7 +24,7 @@ const transitionCommon =
* Chakra variants plus the family-wide `language`/`toolbar` variants; a
* consuming app's preset extends it with app vocabulary (e.g. ml-trainer's
* `led`/`record*`/`secondary-disabled`). Brand divergence within a variant is
- * token-driven (see the `languageText` semantic tokens).
+ * token-driven (see the `button.*` and `languageText` semantic tokens).
*
* Registered in the base preset (base-preset.ts).
*/
@@ -92,13 +92,22 @@ export const button = defineRecipe({
textDecoration: "underline",
},
},
+ // Colours come from the `button.*` semantic tokens so the family's two
+ // button idioms (brand-coloured vs black-on-white) share this recipe —
+ // see the token block in base-preset.ts.
secondary: {
borderWidth: "2px",
- borderColor: "brand.500",
- color: "brand.700",
+ borderColor: "button.secondaryBorder",
+ color: "button.secondaryText",
bg: "transparent",
- _hover: { borderColor: "brand.600" },
- _active: { bg: "brand.50", borderColor: "brand.700" },
+ _hover: {
+ borderColor: "button.secondaryHoverBorder",
+ bg: "button.secondaryHoverBg",
+ },
+ _active: {
+ bg: "button.secondaryActiveBg",
+ borderColor: "button.secondaryActiveBorder",
+ },
},
ghost: {
color: "black",
@@ -116,14 +125,28 @@ export const button = defineRecipe({
},
primary: {
color: "white",
- bg: "brand.500",
- _hover: { bg: "brand.600", _disabled: { bg: "brand.500" } },
- _active: { bg: "brand.700" },
+ bg: "button.primaryBg",
+ _hover: {
+ bg: "button.primaryHoverBg",
+ _disabled: { bg: "button.primaryBg" },
+ },
+ _active: { bg: "button.primaryActiveBg" },
},
// 600/700, matching what python-editor's Chakra outline + red
// colorScheme resolved to. (Extracted from ml-trainer at 500/600, but
// its one warning button tolerates the darkening; python-editor's
// "Reset project" was visibly lighter than its Chakra self.)
+ // NOTE (2026-08-02, from classroom): two Chakra `outline` shapes have
+ // no home here and are currently restated per call site in that app —
+ // worth considering as variants once a second consumer wants them.
+ // - a neutral outline (transparent, 1px gray.200, inherited text,
+ // gray.50/gray.100 hover/press): Chakra's default-colorScheme
+ // `outline`, and python-editor's *default* variant per the
+ // playbook's cross-app vocabulary, so likely already a 2-app shape.
+ // - an on-colour outline (white 2px + white text over a coloured bar,
+ // whiteAlpha hover/press): Chakra's `outline` + `whiteAlpha`.
+ // `warning` below is the *destructive* outline and is not a substitute
+ // for either; `warningSolid` did map exactly onto Chakra solid+red.
warning: {
borderWidth: "2px",
borderColor: "danger.600",
diff --git a/packages/ui/src/Checkbox.tsx b/packages/ui/src/Checkbox.tsx
index 426bc23..4027407 100644
--- a/packages/ui/src/Checkbox.tsx
+++ b/packages/ui/src/Checkbox.tsx
@@ -12,13 +12,33 @@ import { css, cx } from "styled-system/css";
import { checkbox, CheckboxVariantProps } from "styled-system/recipes";
import { SystemStyleObject } from "styled-system/types";
+/** What a render-prop child is told about the checkbox. */
+export interface CheckboxState {
+ isSelected: boolean;
+ isFocusVisible: boolean;
+ isDisabled: boolean;
+}
+
export interface CheckboxProps
extends Omit,
CheckboxVariantProps {
/** Per-instance style overrides for the root, merged after the recipe. */
css?: SystemStyleObject;
className?: string;
- children?: ReactNode;
+ /**
+ * The label. A function receives the checkbox's state, for a label that
+ * changes with it.
+ */
+ children?: ReactNode | ((state: CheckboxState) => ReactNode);
+ /**
+ * Whether to draw the box. `false` is for a checkbox whose children draw
+ * the selected state themselves — a selectable tile, or an avatar that
+ * grows a tick. The label wrapper goes with it, so the children own the
+ * whole row, including the focus ring the box would otherwise carry.
+ *
+ * @default true
+ */
+ control?: boolean;
}
/**
@@ -31,6 +51,7 @@ export const Checkbox = ({
css: cssProp,
className,
children,
+ control,
...rest
}: CheckboxProps) => {
const slots = checkbox({ size });
@@ -39,38 +60,47 @@ export const Checkbox = ({
className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
{...rest}
>
- {({ isSelected, isFocusVisible, isDisabled }) => (
- <>
-
- {isSelected && (
-
- )}
-
- {children != null && (
+ {({ isSelected, isFocusVisible, isDisabled }) => {
+ const content =
+ typeof children === "function"
+ ? children({ isSelected, isFocusVisible, isDisabled })
+ : children;
+ if (control === false) {
+ return content;
+ }
+ return (
+ <>
- {children}
+ {isSelected && (
+
+ )}
- )}
- >
- )}
+ {content != null && (
+
+ {content}
+
+ )}
+ >
+ );
+ }}
);
};
diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx
new file mode 100644
index 0000000..3349540
--- /dev/null
+++ b/packages/ui/src/ComboBox.tsx
@@ -0,0 +1,192 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ ForwardedRef,
+ forwardRef,
+ ReactNode,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import {
+ Button as RACButton,
+ ComboBox as RACComboBox,
+ ComboBoxProps as RACComboBoxProps,
+ Input as RACInput,
+ Label as RACLabel,
+ ListBox as RACListBox,
+ Popover,
+ PopoverProps,
+} from "react-aria-components";
+import { RiArrowDownSLine } from "react-icons/ri";
+import { css, cx } from "styled-system/css";
+import { select, SelectVariantProps } from "styled-system/recipes";
+import { SystemStyleObject } from "styled-system/types";
+import { Icon } from "./Icon";
+import { SelectSlotProvider } from "./Select";
+
+export interface ComboBoxProps
+ extends Omit, "className" | "children" | "style">,
+ SelectVariantProps {
+ /** Visible label. Use `aria-label` instead where the design has none. */
+ label?: ReactNode;
+ placeholder?: string;
+ /**
+ * Rendered inside the control, before the input — an icon for the current
+ * value, say. A ComboBox's control is a text input, so unlike a Select it
+ * cannot show anything but text for what is chosen; this is the way round
+ * that (react-select did it with a custom `SingleValue`).
+ */
+ startContent?: ReactNode;
+ /** `SelectOption`s. */
+ children: ReactNode;
+ /**
+ * Replaces the chevron; pass `null` for none, which is what a plain
+ * autocomplete wants (react-select's `dropdownIndicator: display none`).
+ */
+ indicator?: ReactNode | null;
+ /**
+ * Shown in place of the list when nothing matches (react-select's
+ * `noOptionsMessage`). Implies `allowsEmptyCollection`, since RAC otherwise
+ * closes the popover the moment the collection empties.
+ */
+ emptyState?: ReactNode;
+ /**
+ * Keep the dropdown shut until this prop is true. For gating on a minimum
+ * query length — react-aria has no `minLength`, and rendering an empty list
+ * still opens an empty card.
+ */
+ isPopoverHidden?: boolean;
+ placement?: PopoverProps["placement"];
+ /**
+ * Cap the dropdown's height (react-select's `maxMenuHeight`). A prop rather
+ * than a `contentCss` rule because RAC writes its own max-height inline
+ * while positioning, which beats any class.
+ */
+ maxHeight?: number;
+ /**
+ * Per-instance overrides for the control — the box around the input, its
+ * `startContent` and its indicator, which is what `Select`'s `css` styles
+ * too. Reach the input itself through the `select` recipe's `value` slot.
+ */
+ css?: SystemStyleObject;
+ /** Per-instance overrides for the dropdown card. */
+ contentCss?: SystemStyleObject;
+ className?: string;
+}
+
+/**
+ * ComboBox — a text input that filters a listbox, for choosing one of a known
+ * set where typing to narrow it down is the point. Use Select where the list
+ * is short enough to just pick from.
+ *
+ * Note the react-select difference this replaces: react-select filtered on
+ * `label` and kept the menu open on selection unless told otherwise, whereas
+ * react-aria filters on each item's `textValue` and closes on selection.
+ */
+const ComboBoxInner = (
+ {
+ label,
+ placeholder,
+ startContent,
+ children,
+ indicator,
+ emptyState,
+ isPopoverHidden,
+ placement = "bottom start",
+ maxHeight,
+ css: cssProp,
+ contentCss,
+ className,
+ ...props
+ }: ComboBoxProps,
+ ref: ForwardedRef,
+) => {
+ // As Select: forward whatever variant groups the merged recipe has.
+ const [variantProps, rest] = select.splitVariantProps(props);
+ const slots = select(variantProps);
+ // Anchor the card to the whole control, not to the bare input inside it —
+ // otherwise it hangs off the text baseline and is as narrow as the input.
+ const triggerRef = useRef(null);
+ // RAC's --trigger-width measures the input it anchors a ComboBox to, which
+ // is the control's content box — so a card sized from it is narrower than
+ // the field by the padding and border. Measure the control instead. State
+ // rather than reading the ref at render time: the popover is mounted from
+ // the first render, before the ref is set, and nothing would re-render it.
+ const [triggerWidth, setTriggerWidth] = useState();
+ useLayoutEffect(() => {
+ const el = triggerRef.current;
+ if (!el) {
+ return;
+ }
+ const update = () => setTriggerWidth(el.offsetWidth);
+ update();
+ if (typeof ResizeObserver === "undefined") {
+ return;
+ }
+ const observer = new ResizeObserver(update);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+ return (
+
+ )}
+ className={cx(slots.root, className)}
+ >
+ {label != null && {label}}
+