Skip to content

Adopt @microbit/ui - #937

Draft
microbit-matt-hillsdon wants to merge 97 commits into
mainfrom
experiment-rai
Draft

Adopt @microbit/ui#937
microbit-matt-hillsdon wants to merge 97 commits into
mainfrom
experiment-rai

Conversation

@microbit-matt-hillsdon

@microbit-matt-hillsdon microbit-matt-hillsdon commented Jul 10, 2026

Copy link
Copy Markdown

This PR extracted the initial seed version of @microbit/ui (initially as src/shared-ui) and has since adopted the separately packaged version in https://github.com/microbit-foundation/ui.

Stand up Panda CSS + react-aria-components alongside Chakra as the basis for
migrating off Chakra UI, with reusable primitives in src/shared-ui/ (to be
extracted into a shared library later). Visuals match the Chakra theme; the
OSS-vs-private brand split is preserved as a Panda preset swap.

- Panda via CLI codegen/cssgen (no PostCSS; Vite uses LightningCSS). Generated
  styled-system/ and src/styled-system.css are git-ignored.
- OSS preset ports the exact Chakra v2 token set (snapshotted by
  bin/gen-chakra-tokens.mjs) plus this app's overrides; config recipes for
  button, heading, dialog. RAC interaction conditions widened to match RAC
  data-attributes so Chakra-shaped style objects work unchanged.
- Coexistence: Chakra/Emotion inject unlayered CSS that beats Panda's @layer
  output, so bin/unlayer-panda.mjs strips Panda's layers during the transition
  (bin/panda-dev.mjs keeps it unlayered in watch). staticCss generates recipe
  variants used via runtime props. Emotion files excluded from Panda extraction.
- shared-ui: Button, Text, Heading, Link, Icon, CloseIcon, List, Modal, Tooltip,
  Toast (+useToast), useBreakpointValue, layout re-exports.
- LanguageDialog + ModalFooterContent migrated and verified against the live
  branded deployment; ToastProvider mounted in App (ChakraProvider retained).

See RAC-MIGRATION.md for architecture, gotchas, and next steps.
The dev watcher spawned `panda --watch --outfile …`, but the bare
`panda` command runs codegen, which rejects `--outfile` ("Unknown
option"). Use `panda cssgen --watch --outfile …` instead — it
re-extracts src/styled-system.css on source changes and watches the
config too.
Build a shared-ui Menu primitive (MenuTrigger/MenuList/MenuItem) on
react-aria-components, preserving the native Android back-button
integration, and migrate SettingsMenu, LanguageMenuItem and
SettingsMenuItem onto it.

Styling comes from a new `menu` slot recipe and a `plain` button
variant in panda-recipes.ts, mirroring Chakra's menu theme (3xs width,
sm shadow, gray.200 border, gray.100/200 focus/press, 0.75rem icon
spacing) so the dropdown matches visually. The square icon-button
trigger zeroes horizontal padding so the recipe's default md px:4
doesn't clamp the icon.

RAC restores focus to the trigger on close, so the LanguageDialog
finalFocusRef focus-restoration hack is removed.
Extend the shared-ui Menu with a MenuDivider (RAC Separator + a `divider`
slot) and add a shared-ui IconButton (square, px:0, isRound) for the
action-bar icon-button triggers. Migrate HelpMenu and HelpMenuItems onto
shared-ui: external links use RAC MenuItem href/target/rel (rendered as
anchors), actions use onAction. SettingsMenu's trigger now uses the same
IconButton, with the shared white/round/focus styling in
ActionBar/action-bar-menu-button.ts.

The menu icon slot gains fontSize 0.8em to match Chakra's MenuIcon;
items passing an explicitly sized icon still override it.
The shared `actionBarMenuButtonCss()` helper returned the trigger's
style object from a plain function, but Panda only statically extracts
`css` prop literals at the JSX site — not objects returned from helpers.
So h/w/minW utilities were never generated and the button fell back to
the recipe's size:md (40px), shrinking the circular focus ring's radius.

Replace the helper with an ActionBarMenuButton component whose inline
`css` literal Panda can extract, and take the 48px dimensions from the
recipe's size="lg" variant (always generated via staticCss) rather than
utility overrides the size variant would otherwise beat.
Replace the Chakra Box/HStack/VStack in ActionBar with the shared-ui
(Panda) patterns, and the stray Chakra HStack in ActionBarItemsRight.
Chakra->Panda prop translations: spacing->gap, bgColor->bg, sx->css /
direct style props. itemsLeftProps/itemsCenterProps are typed HstackProps
(spread into HStack, whose row-only direction is narrower than StackProps).
Export the Panda pattern prop types from shared-ui/system.ts.

Surfaced (not caused) a branding bug: the Panda brand preset override
wasn't in the emitted tokens, so the now-Panda header rendered OSS grey
instead of CreateAI green. Root cause was a stale incremental codegen —
'panda codegen' didn't pick up the external preset change; a clean regen
fixed it. All Panda brand colours (e.g. brand.500 #3182ce -> #007dbc)
are now correct.
Split src/deployment/default/panda-recipes.ts into per-component
<Component>.recipe.ts files in src/shared-ui/, still registered in the
OSS preset. The private brand preset only overrides tokens/semantic
tokens in practice, so the recipes aren't deployment-specific;
deployment/default now holds just the genuinely brandable surface.

Also correct stale recipe comments claiming the private preset
overrides variants (divergence is token-driven via semantic tokens),
and document the .recipe.ts convention in RAC-MIGRATION.md.

Generated CSS is unchanged.
Migrate DataSamplesMenu, ProjectCardActions, ActionDataSamplesCard
(record options) and TestingModelPage (MakeCode split button) onto
shared-ui MenuTrigger/MenuList/MenuItem, and delete the old
components/Menu.tsx back-button wrapper (shared-ui MenuTrigger already
handles Android back), the now-unused ToolbarMenu and
LoadProjectMenuItem.

Details:

- shared-ui MenuItem now wraps an icon-item's children in a flex:1
  "label" slot span, matching Chakra's MenuItem so block children (the
  two-line record-options items) stack vertically.
- MoreMenuButton is rebuilt on shared-ui IconButton. Chakra's
  borderLeft="1px" resolved via the borders scale to "1px solid"
  currentColor, so the split-button divider is white on filled variants
  and red on recordOutline; ported as-is.
- LoadProjectInput moves outside the menu in DataSamplesMenu: RAC
  popovers unmount on close (Chakra kept the list mounted), which would
  drop the hidden file input's change event mid-pick. The menu item now
  triggers it via ref.
- The Android save toast uses shared-ui useToast; Chakra's id-based
  dedup and per-call position aren't supported, so rapid repeat saves
  can stack duplicate toasts (region is top-centre).
- The split buttons keep Chakra ButtonGroup isAttached during
  coexistence; its child selectors style the RAC trigger too.

Verified against the live branded deployment (data-actions, record
options, project card and settings menus match in placement, sizing,
icons and dividers; Chakra's focus-first-item-on-open is the only
interaction difference). Typecheck, lint, unit tests green; e2e radio
flakes reproduce identically on the base commit under parallel load.

ActionBar.tsx is a Prettier-only diff (the committed file was not
format-clean).
With the brand package installed as a symlink to a sibling checkout, its
font/image assets resolve to real paths outside the project root, which
Vite's server.fs allow list rejects (403) — the GT Walsheim @font-face
loaded but every font file request failed, so marketing headings fell
back to the default font. Dev-only; builds bundle the assets.

Add the theme package's realpath to server.fs.allow alongside the
workspace root when the external package is present.
Add a shared-ui Drawer built on react-aria-components ModalOverlay/
Modal/Dialog with a Drawer.recipe.ts slot recipe matching Chakra's
default xs drawer; placement (left/right) is a runtime prop generated
via staticCss. The drawer has no title slot so it requires an
aria-label. onCloseComplete (used to defer navigation until the exit
animation ends) is an unmount-callback sentinel inside the overlay —
RAC keeps the tree mounted until the exit transition finishes.

NavigationDrawer and DefaultPageLayout are fully ported. Nav items are
now li > button / li > a rather than Chakra's ul > button. The
ProjectToolbarItems native share menu was a raw Chakra Menu with no
Android back-button integration; as shared-ui MenuTrigger it now gets
that behaviour too.

shared-ui additions/changes:
- Divider (horizontal hr matching Chakra's).
- Button wraps leftIcon/rightIcon in Chakra-style spaced spans
  (iconSpacing 0.5rem), removing the need for call-site gap tweaks.
- BackArrow becomes a plain Panda-styled svg (only used by the layout).
- useNativeTabletBreakpoint switches to shared-ui useBreakpointValue.

Verified: unit + full e2e green (66/66); pixel-compared against the
live branded deployment at desktop and tablet widths, both drawer
placements.
ConfirmDialog uses shared-ui Modal with role=alertdialog, isCentered
and autoFocus on the least-destructive (cancel) button, replacing
Chakra's AlertDialog/leastDestructiveRef. The confirm button uses a new
warningSolid variant (Chakra's solid + red colorScheme; same values as
record today, kept separate so recording UI and destructive actions can
diverge).

NameProjectDialog is the first form port, via a new shared-ui TextField
(react-aria TextField/Label/Input/Text/FieldError) with a `field` slot
recipe collapsing Chakra's FormControl/FormLabel/Input/FormHelperText/
FormErrorMessage. Focus keys off data-focused (react-aria treats
text-input focus as keyboard-visible, matching Chakra's _focusVisible
on inputs) and is declared after invalid so a focused invalid field
shows the focus ring, as in Chakra. Helper/error text use
lineHeight normal to match Chakra's dialog height exactly.

Modal grows role, isCentered (a `centered` recipe variant),
onCloseComplete and finalFocusRef (unmount sentinel shared with Drawer;
final focus applied on rAF after RAC's own restore) and
ModalCloseButton (closeTrigger slot, localised via close-action where
Chakra's CloseButton was hardcoded "Close").

react-aria popovers (menus included) render role="dialog" and linger
with data-exiting while animating out, so a bare Playwright
getByRole("dialog") hits strict-mode ambiguity when a dialog opens from
a menu. e2e page objects now use the modalDialog() helper
(src/e2e/app/shared.ts), which scopes to <section> — both Chakra and
shared-ui modals render the dialog on a section; popovers are divs.

Accepted interaction diff: the auto-focused cancel button shows its
focus ring even after mouse interaction (Chakra focused it invisibly).

Verified: unit + full e2e green (66/66); rename dialog (incl. invalid
state) and delete confirm pixel-compared against the live branded
deployment.
Bottom-up port of the home page: carousel stack (CarouselRow,
SwiperCarousel, SwiperCarouselButtons, CarouselButton), ResourceCard,
ClickableTooltip (+InfoToolTip), HomepageBanner, ProjectCard and the
page itself.

New shared-ui primitives: Card/CardBody (Card.recipe.ts, elevated +
outline variants per Chakra md), LinkBox/LinkOverlay, Image, an
AspectRatio pattern re-export, and left/right top/bottom Tooltip
placements. ClickableTooltip is now a controlled shared-ui Tooltip with
a react-aria Focusable trigger span and a document-level Escape
listener (Chakra's closeOnEsc equivalent; the span-level handler missed
the key).

A new _shortHeight preset condition (@media max-height: 800px) replaces
src/responsive.ts's shortScreenHeightBreakpoint for style usage —
Panda's extractor cannot resolve constants imported across files.
HomepageBanner keeps its own tighter 700px same-file query, matching
the Chakra original (it shadowed the shared constant).

Chakra's LinkOverlay-over-Button pattern needs two adjustments in
RAC-land, both applied to the project/action card overlay buttons:
- react-aria's usePress cancels presses landing outside the button's
  bounding rect, so an inset ::before overlay never fires on a RAC
  <Button>; the overlays are plain <button>s styled with the button
  recipe (the widened conditions match native :focus-visible).
- The button recipe base sets position: relative, which anchors the
  overlay to the button itself; the call sites set position: static,
  which is what Chakra's LinkOverlay did over its Button.

RecordingFingerprint's tooltip placement maps from Chakra's "end-end"
to RAC's "right bottom".

Deferred (noted in RAC-MIGRATION.md): rebuilding card collections on
RAC GridList — native whole-item presses, arrow-key navigation and
built-in multi-selection — as a deliberate UX change, best tried on the
projects page grid first.

Verified: unit + full e2e green (66/66); home page pixel-compared
against the live branded deployment at desktop and mobile widths incl.
the open storage tooltip, and whole-card click checked end-to-end on
the built app.
Port ProjectsToolbar, Search, SortInput, the remainder of
ProjectCardActions (checkbox + skip-to-toolbar button, which uses RAC
excludeFromTabOrder rather than tabIndex=-1) and ProjectsPage itself.
SimpleGrid becomes a Panda Grid; Chakra's Slide becomes a fixed
bottom-sheet div with a transform transition.

New shared-ui primitives (extracted after review feedback that the
first pass leaned on call-site css):
- input config recipe: Chakra's outline field, shared by new Input and
  NativeSelect components and TextField's input (removing the
  duplicate styles from the field recipe). State selectors match both
  native pseudo-classes and react-aria data attributes.
- InputGroup/InputLeftElement/InputRightElement, so Search reads like
  the Chakra original.
- ButtonGroup with isAttached; also replaces the two remaining Chakra
  ButtonGroups (record and MakeCode split buttons). Its child selectors
  use :first-child/:last-child, not -of-type: attached groups can mix
  element types (select + button) and -of-type matches per type, which
  silently skipped the corner squaring in SortInput.
- Checkbox (Chakra md/blue; borderColor: inherit on the control so call
  sites tint via the root, as in Chakra).
- LinkOverlayButton, encapsulating the plain-button + position:static +
  ::before overlay pattern from the home page; both card call sites now
  use it.

SortInput details: the arrow button squares the recipe's pill radius to
md on its outer corners, and the select gets flex:1/minW:0 so it
shrinks below its longest option at narrow widths, clipping like
Chakra's Select.

Verified: unit + full e2e green (66/66); projects page compared against
the live branded deployment for grid, single-selection with desktop
attached toolbar, tablet labelled bottom sheet, mobile icon-only bottom
sheet and the search state; sort-control seam radii checked via
computed styles.
Page, DataSamplesTable/Row, HeadingGrid (className API; the
TestingModelTable call site passes a css() literal), ActionNameCard,
ActionDataSamplesCard, DataSamplesTableHints, ShowGraphsCheckbox,
LedIcon/LedIconSvg/LedIconPicker, LiveGraphPanel/LiveGraph/
LiveGraphLabels/PredictedAction, and the Emoji/EmojiArrow/UpCurveArrow/
AlertIcon svgs. The still-Chakra dialog flows (Recording,
TrainModelFlow, Welcome, ConnectFirst, ...) are a later pass.

New shared-ui: CloseButton (a plain button rather than react-aria so
call sites can extend the hit area with pseudo-elements, which the
usePress bounding-rect check would defeat) and Toast id dedup
(Chakra-parity suppression of repeat toasts, used by the
name-too-long toast). New useElementSize hook replaces Chakra's
useSize; it measures synchronously in a layout effect because
LiveGraph paints its only frame while the chart is stopped and an
async first measure resizes (= clears) the canvas after that paint.

Emotion keyframes move to preset keyframes (tada, spin3d,
microbitWobble, ledTurnOn/Off, recordingFlash) and the ported files
come out of panda.config.ts's exclude list — Panda skips extraction
for excluded files entirely, which left applied class names with no
generated rules (visible as a 0x0 emoji svg in the hints).
usePrefersReducedMotion becomes prefers-reduced-motion media queries.
Chakra Portal containerRef usages become createPortal with state refs.
LedIconPicker is a react-aria DialogTrigger/Popover; its decorative
dropdown arrow reuses the button recipe's ghost/sm classes on a span
(Chakra had IconButton as="div") to keep the hover effect.

Border width/style/color are set as longhands where a colour is also
set: border shorthands imply currentColor and the class order between
the shorthand and borderColor utilities is not guaranteed.

Accepted diff: the action-name-too-long toast renders our solid error
style rather than Chakra's subtle variant.

Verified: unit tests green; full e2e 65/66 with the documented flaky
radio reconnection spec passing in isolation; data samples page
compared against the live branded deployment (single/two actions with
hints, record split button, LED icon picker, live graph panel) with
computed-style, element-size and canvas painted-pixel probes. The
comparison workflow is now documented in RAC-MIGRATION.md.
Page remainder, TestingModelTable, ActionCertaintyCard,
PercentageMeter/PercentageDisplay (token() for the runtime prediction
colour updates), CodeViewCard/CodeViewDefaultBlockCard/
CodeViewDefaultBlock. This completes the pages phase; the remaining
Chakra surfaces are the dialog flows, EditableName, ProjectPreview and
the tour/animation components.

New shared-ui:
- Slider (Slider.recipe.ts): react-aria Slider styled like Chakra's
  horizontal md slider, with an optional `mark` positioned at the value
  and revealed on focus-within (how the certainty percentage appears).
- Spinner: Chakra's border-based spinner, sm/md.

ButtonWithLoading is rebuilt on shared-ui Button + Spinner (label kept
in layout and hidden while loading so the width is stable) and keeps
the onClick prop name so its four still-Chakra dialog consumers are
untouched. New usePrevious hook replaces Chakra's. The MakeCode blocks
loader is an opacity-pulse skeleton (skeletonPulse keyframe) standing
in for Chakra's SkeletonText shimmer.

Verified: unit + full e2e green (66/66). This page is unreachable on
the live deployment without a device, so it was compared Chakra-build
vs Panda-build instead: a temporary spec on the e2e mock fixtures
screenshots the trained-model page on the working tree and again with
the port stashed — near-identical output; approach noted in
RAC-MIGRATION.md as a preview of the fidelity harness. Accepted diffs:
slider thumb shows its focus ring where Chakra's was invisible, ~3px
internal shift in the certainty card, pulse-vs-shimmer skeleton.
Modal grows `motionless` (Chakra's motionPreset="none" — the connect
flow steps between dialogs without animation) and ModalHeader a `level`
prop. Chakra's closeOnOverlayClick={false} maps to
isDismissable={false}; react-aria handles Escape separately so it still
closes, matching Chakra.

ConnectContainerDialog (the shared shell) ported first, making its 12
step dialogs largely mechanical: WhatYouWillNeed (dynamic grid template
via inline style), ConnectCable, SelectMicrobitUsb/Bluetooth (list
spacing → flex gap, VisuallyHidden → srOnly), EnterBluetoothPattern,
ConnectBattery, ManualFlashing, UnplugRadioLink,
ConnectRadioDataCollection, ResetToBluetoothMode,
NativeBluetoothConnectBattery, and DownloadChooseMicrobit, whose radio
cards are now react-aria RadioGroup/Radio with render-prop state
(arrow-key navigation between cards comes free).

The ten error/troubleshoot dialogs (TryAgain, ConnectError,
BluetoothPermissionError, UnsupportedMicrobit, BrokenFirmware,
NativeBluetoothPairingLost, NativeBluetoothError,
ResetToBluetoothModeTroubleshoot, WebUsbBluetoothUnsupported,
BluetoothConnecting) were batch-converted — unwrap
ModalOverlay/ModalContent, map dialog props, onClick → onPress — with
the remainder fixed from typecheck errors (footer justifyContent → css,
Icon boxSize → css, isLoading → ButtonWithLoading).

Helpers: ExternalLink (Chakra's ExternalLinkIcon glyph inlined as svg;
built on shared-ui Link so jsx-no-target-blank sees the rel) and
DialogFooterLink. New shared-ui UnorderedList/OrderedList.

Deferred within the flow: BluetoothPatternInput — the pairing pattern
grid was recently reworked for screen reader accessibility (#926) and
its radio machinery gets its own pass.

Verified: unit tests green; full e2e 66/66 (the mocked bluetooth,
radio, reconnection and download specs exercise nearly every dialog
here); Web Bluetooth connect flow compared against the live branded
deployment — the what-you-need and WebUSB select-device steps are
pixel-identical.
The testing model page's edited-project code view spans rows with
rowSpan={actions.length + 1} — a computed value, so Panda generated no
CSS for it and the grid re-flowed, scattering the row cells (visible
with a real trained-and-edited project; the e2e mocks only exercise the
default-code path). Now an inline gridRow span style.

An audit for other computed style props in ported files found two more,
fixed the same way: PercentageMeter's prop-driven width (rendering
correctly only because the certainty slider call site happens to emit
an identical w_240px class) and TestingModelTable's
scrollbar-compensated calc() width.
Recording, the progress dialogs (DownloadProgress/TrainingModelProgress/
Save/Loading/LoadingOverlay), Welcome/About/Settings/Feedback, the three
help dialogs, TrainModelInsufficientData, TrainingError, ImportError,
NotCreateAiHex, MakeCodeLoadError, IncompatibleEditorDevice, ConnectFirst,
EditCodeDialog and SelectFormControl (label + NativeSelect + chevron).

New shared-ui: ProgressBar (RAC, Chakra md Progress; fill via barCss) and
Switch (Switch.recipe.ts, Chakra md/blue). Modal grew
isKeyboardDismissDisabled, contentCss and aria-label (for dialogs without
a ModalHeader). Chakra's useClipboard/useDisclosure replaced with
navigator.clipboard/useState at the call sites.

Spinner and ProgressBar merge base + caller css into a single css() call:
same-property overrides across separate css() calls race on stylesheet
order and were silently losing (LoadingOverlay's 166px spinner rendered
24px; progress fills stayed blue.500).

SettingsDialog drops the Chakra-era initial-focus hack: react-aria
focuses the dialog element itself on open, so the first <select> never
steals focus. Its graph preview uses the native aspect-ratio property
because the AspectRatio pattern's &>* child selector loses to the
still-Chakra RecordingGraph's own position style.

Deferred to their own passes: Tour/TourOverlay (usePopper positioning +
spotlight overlay), EditableName, ProjectPreview, animation components.

Verified against live: Welcome, Settings, About, Feedback,
InsufficientData and SaveHelp match (Feedback pixel-identical); 66 e2e
and 406 unit tests pass.
Drop the per-chunk changelog (git history covers it); promote the still-
operative learnings buried in those entries to first-class gotchas (Emotion
exclude list, AspectRatio vs still-Chakra child, popover unmount/file
inputs, modalDialog() e2e helper, react-aria focus defaults) and collect
the deliberate Chakra behaviour diffs into one section.
…gences

bin/diff-chakra-themes.mjs diffs the resolved OSS and private Chakra
themes (esbuild-bundled so both share this repo's hoisted Chakra, as
vite's theme-package alias does at runtime) and cross-checks that the
Panda preset pair encodes the same delta for every divergent token.

Result: 70 token deltas, all within the seven brand colour ramps the
private preset overrides, all mechanically reproduced by the Panda
presets; 3 structural diffs (language button colour + hover, marketing
heading font), all already driven by the languageText/languageTextHover/
display tokens. Closes the gotcha #6 TODO.
RecordingGraph and RecordingFingerprint (data-driven grid columns and
cell colours via inline style), EditableName (Chakra Editable rebuilt as
a preview button/input swap: Enter/blur commits, Escape reverts,
select-all on edit, focus returns to the button), ProjectPreview and
OpenSharedProjectPage (array responsive props to object syntax;
BlocksLoadingSkeleton extracted from CodeViewCard for the MakeCode
loader), ChooseDeviceOverlay, NativeConsentDialog, CodePage, and the two
remaining Chakra useToast call sites (App.tsx, project-hooks).

New shared-ui: VisuallyHidden (Panda srOnly span; all raw
css({srOnly: true}) call sites swept onto it) and Toast isActive/update
plus duration: null persistence for the storage-error toast (update
re-adds, so it re-animates, unlike Chakra's in-place update).

Verified against live: toolbar and drawer EditableName (editing state
pixel-identical, behaviour probes match), the shared-project preview
page (pixel-identical including RecordingGraph previews; blocks-frame
computed styles match exactly). 66 e2e and 406 unit tests pass.
…ation

Link wrapper is now styled(RouterLinkAdapted) - Panda extracts style
props on styled-factory components cross-file, so call sites are
unchanged. AppLogo instead takes a css prop: forwarding style props
through a plain wrapper component is not extracted (its call-site
transforms were silently missing from the generated CSS); call sites
updated and the vertical divider is a one-off (shared-ui Divider is
horizontal-only). Also PreReleaseNotice, FileDropTarget,
ProjectDropTarget (drops an unused BoxProps spread), LoadProjectInput
(plain hidden input), icons/PauseIcon (plain svg),
PauseResumeAnimationLink, ErrorPage and ErrorHandlerErrorView.

NewPageChoice and StepByStepIllustration had no importers (NewPage was
removed with the multi-project work) and are deleted rather than ported.

Verified: 404 page link computed styles identical to live; welcome
pause link renders; drawer header strip byte-identical before/after the
AppLogo change. 406 unit tests pass; e2e green on rerun (one transient
open-shared-project failure under parallel load - it fetches the real
MakeCode API - passed in isolation and in a full rerun).
HowItWorksAnimation (18 files), PairingModeAnimation (5),
PlugMicrobitAnimation, LoadingAnimation, ArrowOne/ArrowTwo and
AnimationProvider are now shared-ui/Panda. utils/animations.ts is
deleted and panda.config.ts's Emotion exclude list is empty.

All animation keyframes move to the preset (Panda emits preset
keyframes unconditionally, so inline-style-only references are safe).
Runtime-parameterised Emotion keyframes become static keyframes over
CSS custom properties set inline per instance: gauge segment colours
(--gauge-*), signal travel offset and per-dot opacities (--signal-*),
and the wave-scroll window width (--wave-window). withPlayState
animation shorthands are inline styles throughout; CodeBlocks' only
per-breakpoint keyframe uses an extracted animationName {base, sm}
plus uniform inline longhands.

New shared-ui Svg (styled svg with Chakra Icon base sizing) hosts the
custom-path icons; icon wrappers take css/style props rather than
forwarding style props, which the extractor cannot see.

Verified by Chakra-build vs Panda-build stash-compare (live runs an
older release with different animations): the welcome animation at a
paused checkpoint, the connect-cable plug animation and the
native-flow reset-press animation match frame-for-frame. One deliberate
fix, noted in RAC-MIGRATION.md: the A/B-hold pairing label fill-up
interpolated a raw token name into a linear-gradient, which Chakra
never resolved, so it likely never painted; the port uses the resolved
colour. 66 e2e and 406 unit tests pass.
Spotlighted steps are a RAC Popover anchored to the spotlit element via
an external triggerRef (assigned during render so the keyed,
remounted-per-step popover measures its new anchor on mount), with an
OverlayArrow (white, shadowless, rotated via the placement data
attribute) and shouldCloseOnInteractOutside disabled to match Chakra's
closeOnOverlayClick={false}. Default placement is "bottom" - RAC
defaults to "top" but Chakra's popper defaulted bottom. Selector-less
steps render as a shared-ui Modal; Modal grows overlayCss so
TourOverlay can own the dimming in multi-step tours. TourOverlay's
spotlight svg is unchanged apart from createPortal/token swaps.

Chakra's returnFocusOnClose={false} (Tab restarts from the page top,
and on the MakeCode page focus restoration would land in the editor
iframe) is reproduced by blurring the active element before each tour
action: react-aria only restores focus on unmount while focus remains
inside the dialog.

model.ts and tours.tsx no longer import Chakra (TourStep's placement
and modalSize are now local/shared-ui types), removing two kill-switch
type unpicks early.

Verified by Chakra-build vs Panda-build comparison over the mock
connect flow's Connect tour: step content, arrows, spotlight cutouts
and end-of-tour focus (body) all match; accepted diff is RAC's 12px
viewport-edge clamp where popper sat flush. 66 e2e and 406 unit tests
pass.
Each pattern column remains a radio group whose options are the LEDs,
with the checked option the topmost lit LED. The ARIA tree is verified
structurally identical to the Chakra build via Playwright ariaSnapshot
(radiogroup names, per-LED labels, checked semantics), as are keyboard
behaviour (arrow keys change the LED count, one tabstop per column),
the e2e test-id contract and the rendered pixels.

The subtle part is the reactivate affordance: clicking the checked
topmost lit LED turns it off, and radios fire no change event for a
click on the checked option. react-aria's press handling swallows the
click before React's synthetic handlers see it, and re-selects the
pressed value against current state late in the dispatch, which
silently reverts any state written by an earlier handler. A native
capture listener on the option wrapper defers the reactivate write by
a tick so it lands after react-aria's press processing; the listener
is only wired on the currently-checked option so selection clicks are
unaffected, and keyboard activation of a checked radio also fires a
click, matching Chakra.

66 e2e (which drive this input in the bluetooth flows) and 406 unit
tests pass.
@microbit/ui merged its core + micro:bit-foundation presets into a
single base-preset (with OSS default brand values) and dropped the
separate foundation/oss preset. Update the import and stack accordingly:
base-preset, then the app preset, then the optional private brand preset.

Verified output-identical: the generated styled-system.css for the
branded build is byte-equivalent (ordering-only) before and after.
i18n:compile now runs bin/compile-lang.mjs, which compiles each locale
from lang/ plus the source catalogs @microbit/ui ships, so package
strings ride the existing lazy per-locale chunks instead of an eagerly
bundled catalog-of-all-locales in the main chunk (verified: the lol
marker string moved from index-*.js to ui.lol-*.js). Tracks the
package-side change (ui repo 2329f67) that dropped the
@microbit/ui/messages catalogs export, and removes the
withSharedUiMessages runtime merge it fed.
Exact-pinned to 0.1.0-alpha.3, published from
github.com/microbit-foundation/ui (byte-identical to the sibling
checkout the symlink pointed at; verified with a clean Panda regen,
unit tests and the catalog chunk-placement check). Fresh installs and
CI now get the package with no extra wiring; symlinking back to
../ui/packages/ui remains the local package-development workflow.

The lockfile churn is dropped "dev": true flags: @microbit/ui peers on
@pandacss/dev and friends, which were previously dev-only.
Panda wraps all its CSS in @layer, which older browsers (Safari <15.4)
drop wholesale, leaving the app unstyled. Switch Vite off the lightningcss
transformer (which disables PostCSS and does not downlevel @layer) to the
default PostCSS transformer and run @csstools/postcss-cascade-layers to
flatten @layer into :not(#\#) specificity fallbacks. lightningcss is kept
as the minifier. Cost is ~+8% gzipped CSS.

Also degrade the cross-tab-sync BroadcastChannel to a no-op EventTarget
where it is unavailable, so the module no longer throws at load on
Safari <15.4.
Safari 14.x silently drops logical *shorthands* whose value contains
var() (padding-inline: var(--spacing-2) applies nothing, though a literal
value or the -start/-end longhands both work). Panda emits these for its
px/py/mx/my utilities, so most spacing collapsed on Safari 14.1.

- Add an expand-logical-shorthands PostCSS plugin that rewrites the
  inline/block shorthands into their -start/-end longhands (kept logical,
  so RTL still flips; lightningcss won't downlevel logical props itself).
- Pin build.cssTarget to safari14.1. Without it the lightningcss minifier
  inherits build.target (es2017 -> ~Safari 11) and downlevels logical
  longhands into fragile :lang()-based physical rules; safari14.1 ships
  clean logical output. var()-valued longhands can't be recombined into
  the buggy shorthand, so the fix is stable.
Replace the scattered, browser-unaware build inputs with a single pinned
floor:
- BUILD_TARGETS in vite.config.ts drives both build.target (JS syntax via
  esbuild) and build.cssTarget (CSS via the lightningcss minifier).
- Document the same floor in package.json's browserslist for humans and
  browserslist-aware tooling.
- Remove the dead css.lightningcss.targets block: since the transformer is
  PostCSS, css.lightningcss was ignored and the minifier read build.target
  (es2017 -> ~Safari 11), which is what forced logical props into fragile
  :lang() rules. Also drops the now-unused browserslist/lightningcss imports.

Previously build.target ('es2017') was the only real target input and CSS
inherited it; now JS and CSS share one honest floor.
Now that the CSS pipeline is on PostCSS (not the lightningcss transformer),
use Panda's PostCSS plugin to generate the CSS instead of the CLI cssgen +
a static styled-system.css import:
- postcss.config.cjs runs @pandacss/dev/postcss first, then the two legacy
  Safari plugins (logical-shorthand expansion, cascade-layers).
- panda script drops cssgen (codegen only, still needed for the TS helpers);
  panda:watch removed (the plugin gives live CSS in dev).
- main.tsx drops the styled-system.css import; layers.css is now the plugin's
  injection entry.
- Update panda.config.ts and RAC-MIGRATION.md notes accordingly.

Verified: production build emits Panda CSS with @layer flattened and logical
shorthands expanded, unchanged output size.
Replace ml-trainer's inline copy of the logical-shorthand plugin with the
shared @microbit/ui/postcss-legacy-safari export now that alpha.4 ships it.
Behaviour and build output are unchanged (verified: @layer flattened, no
var()-valued logical shorthands, same gzip size).
Swiper ships modules/autoplay.css as a 0-byte file (the autoplay module
has no styles), and postcss-import warns about empty imports on every
dev server run. The autoplay behaviour comes from the JS module, which
is unaffected.
Comment thread RAC-MIGRATION.md Outdated
Comment on lines +21 to +22
and **Panda CSS** for styling, building reusable primitives in `src/shared-ui/`
(intended for later extraction into a library shared across sibling apps). The

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess src/shared-ui doesn't exist because it is already extracted as https://github.com/microbit-foundation/ui ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this doc is a bit of a mess, I want to delete it but first extract some kind of playbook for updating other apps.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I killed the doc. There's now a temporary one in the @microbit/ui as context for new conversions.

The reusable method (sequence, gotcha catalog, fidelity/verification
recipes, family roadmap) moved to ../ui's docs/migration-playbook.md and
the censuses moved out (python-editor-v3's to that repo's own doc). What
remains here: open items and current how-to-run/verify up top, the frozen
migration/extraction record below.

Also drop bin/gen-chakra-tokens.mjs: its output path (src/shared-ui/) is
gone since the extraction; the script now lives in the ui repo's
migration kit.
Picks up the languageText default change (brand.500/600): OSS language
buttons/labels are now brand blue rather than brand2 grey, matching the
family primary-brand vocabulary; branded is unaffected (private preset
overrides to brand.600/600). Also brings LinkButton (unused here).
The reusable method already lives in ../ui/docs/migration-playbook.md
(this doc seeded it); the last transferable bits move there too (gotcha
27: clean Panda regen for sibling-preset changes; the expected
behavioural deltas list) and the run/verify operational notes move to
AGENTS.md. Remaining open items (landing the experiment-rai branches,
the @microbit/ui catalog translation pipeline) are tracked elsewhere.
alpha.7 moves the Chakra-parity * { border-color; word-wrap } defaults
out of the preset's globalCss (which the production cascade-layer
flattening specificity-boosts above CSS it doesn't process) into
@microbit/ui/reset.css, imported here into the reset layer. Verified:
the built CSS carries the reset rule unboosted; 426 tests and build
green.
@microbit-matt-hillsdon microbit-matt-hillsdon changed the title Experiment: RAC + Panda CSS Update to @microbit/ui Jul 30, 2026
@microbit-matt-hillsdon microbit-matt-hillsdon changed the title Update to @microbit/ui Adopt @microbit/ui Jul 30, 2026
Brings the input/checkbox/switch size scales and the new Radio (unused
here yet), plus fixes that apply directly: visible unselected
checkbox/radio borders (--global-color-border), Chakra's disabled
checkbox greys, and useClipboard's execCommand fallback for
unpermissioned frames and insecure origins.
- Slider regains Chakra root geometry: the track recovers the 14px the
  root's px inset consumed, thumbs sit on the track ends at 0%/100%,
  and the certainty card's mark bubble returns to its Chakra-era spot.
- NativeSelect is full-width by default (Chakra Select parity); the
  settings dialog selects keep their 28ch via the new wrapperCss,
  which is where Chakra put layout props. SortInput is chevronless and
  flex-constrained, so unaffected.
- Text gains the config recipe (no default size here — unsized Text
  still inherits, as before).
- ButtonGroup attached children overlap borders by 1px (Chakra
  parity): SortInput's select/sort-order joint tightens by 1px.
- Dividers are decorative by default (aria-hidden) — the
  NavigationDrawer separators stop announcing to screen readers —
  and gain a thickness variant (unused here).
Adapt to the toast API change: drop duration: 5000 now it's the
default and use persistent rather than duration: null.
This makes the outline cancel/stop recording button one step darker as a minor
alignment with Python.
Carries everything classroom's migration added to the library, plus three
renderings that change here: Tooltip takes Chakra's colour, padding and radius
(fba05fb), Icon regains Chakra's vertical-align rather than relying on Panda's
preflight (2f7e18a), and Avatar shows its fallback until a photo loads and
keeps it if the load fails (c80530d). All three are deliberate corrections in
the library.

The one breaking change is in the types: Modal's isOpen/onClose are now a
union — both or neither, so a DialogTrigger can own the state — and `Omit` over
that union collapses to just the keys both halves share, widening isOpen to
`boolean | undefined` and then matching neither half. Every dialog here that
forwards modal props hit it. They now say `Omit<ControlledModalProps,
"children">`, which is what a forwarding shell means anyway; the library
exports that half for exactly this. Eight files, all of them shells.

Verified: typecheck clean, 427 unit tests pass, production build fine. The one
lint error (ml4f-output/autogenerated.ts outside the tsconfig) is pre-existing
and unrelated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants