Skip to content

Classroom motivated changes - #30

Merged
microbit-matt-hillsdon merged 43 commits into
mainfrom
classroom-migration
Aug 3, 2026
Merged

Classroom motivated changes#30
microbit-matt-hillsdon merged 43 commits into
mainfrom
classroom-migration

Conversation

@microbit-matt-hillsdon

Copy link
Copy Markdown
Contributor

No description provided.

python-editor's Chakra theme shrank the numeric spacing/sizes grid to
Chakra's × 0.88 and fontSizes md+ to × 0.9 (the 2022 "make everything
smaller" change), and its migration replicated that in its app preset.
classroom's theme turns out to carry the identical scale — byte-identical
values, same change — so it belongs in one shared place rather than in two
app presets.

New optional `@microbit/ui/dense-preset`, stacked between the base preset
and the app preset by the two dense apps. ml-trainer and data-microbit-org
don't use it. Whether the family keeps this density or aligns on one scale
is still open; this is now the single place that answer lands.

See migration-playbook gotcha #25, which is what makes a global scale
override worth being explicit and shared about.
The family splits 2–2 on button colour idiom: brand-coloured buttons
(ml-trainer, python-editor) and a black-on-white system (classroom,
data-microbit-org — black solid, black outline, and a blackAlpha wash on
hover/press instead of a border-colour change). Without tokens, both apps
on the second side fork the `primary`/`secondary` variants in their own
presets: the same divergence, written twice.

So the two variants now resolve their colours through `button.*` semantic
tokens, defaulting to today's brand values — resolved output is unchanged
for the two migrated apps (verified: the recipe rules differ only by var
indirection). classroom overrides nine values.

`primary`'s text stays a literal `white` and `ghost` needs no tokens: both
are the same in all four apps.

Also refreshes the playbook's v1-surface list, which had drifted from
what's actually built, and records the decision against a shared Table
component (python-editor's one table site is a styled.table, which reads
better than a slot recipe over native table semantics).
classroom ships Welsh and Italian, which this package's Crowdin locale set
didn't include, so those two locales fell back to English for every shared-ui
string. Both are now in bin/i18n-packages.cjs, and the catalogs are backfilled
from classroom's existing translations of the same strings — the only source in
the family for these locales.

Direct matches, same string and (for close) the same description:
close-action → Cau / Chiudi, and Warning → Rhybudd / Attenzione.

"Error" had no standalone entry, so it is taken from the noun as it appears in
classroom's own translations of longer error strings ("Bu gwall wrth…" →
Gwall; "Errore scaricando…" → Errore). Both are the ordinary dictionary form,
but they are derived rather than translated, so worth a reviewer's eye when
these locales next round-trip through Crowdin.

"Information" and "Success" have no source string in any sibling app; the tidy
script leaves them as English, which is the documented fallback.
Two findings from classroom's leaf-primitive port.

#30: Chakra's reset carries normalize's `hr { box-sizing: content-box }` and
Panda's preflight sets border-box on everything with no `hr` exception, so an
`<hr>` with an explicit height plus top/bottom borders silently changes height
at the flip. The zero-size-hr double-edge trick (1px on all four sides of a
0-width hr, the side borders reading as one 2px rule) is exactly that shape and
was in three apps; Divider's `thickness` variant is height-stable instead.
classroom's logo divider was 35px rather than the 33px its code asked for, and
was the only pixel difference across five screens.

#9/#17: confirmed from the other direction that a literal utility-named prop
does survive a plain wrapper — `h={23}` extracted and emitted `height: 23px`,
matching Chakra's unitless-number handling exactly. So #9's real scope is
non-literal values and non-utility prop names, which is worth saying plainly
next to the wrapper advice it seemed to contradict.
`Icon`'s `as` was typed as react-icons' `IconType`, which an svgr import
(`import X from "./x.svg?react"`) doesn't satisfy — only by its return type
(`ReactNode` vs `ReactElement`), not its props. Chakra's `<Icon as={…}>` took
either, and classroom has five such icons, so the narrow type was a porting
blocker rather than a real constraint.

Widened to a new exported `IconComponent`: any component accepting the props
Icon actually passes. Deliberately no wider than that, so both react-icons and
svgr components stay assignable without variance games over element-typed
event handlers. Exported because call sites that hold an icon in their own
props need to name the type (classroom's ConnectionErrorIndicator does).

Also gotcha #31, from the same sweep: a recipe variant's flat value can't
override another variant group's responsive one, because Panda hoists every
media query into a block after all the base rules — so classroom's
`<Heading size="lg" variant="label">` rendered 26.99px above `md` where Chakra
gave 32.4px. Includes the distinction from #8 that the investigation turned up:
a styled() factory's own props *do* beat its recipe, since Panda merges base +
variants + props before emitting; #8's atomic race is between separate css()
calls.
A value like var(--chakra-colors-brand-500) written by hand into a gradient or
shadow keeps resolving while ChakraProvider is mounted, so it survives the port
of its own component and every screenshot comparison, then silently becomes
invalid at the kill-switch — gradients fail to nothing, so the element just
loses its background. One grep audits it, and Panda's {colors.*} string
interpolation is the direct replacement. Found in classroom's homepage banner.
system.ts exists so consumers import the patterns from one place rather than
reaching into the generated styled-system, and Container was the one standard
pattern missing from the list. classroom's homepage banner needs it; the census
has data-microbit-org using one too.
The gotcha told apps to swap Panda's AspectRatio pattern for the native
property. Native aspect-ratio needs Safari 15 / iOS 15 / Firefox 89, and the
family floor is 14.1 / 14.5 / 88, so on three of five targets the declaration
is dropped and the box collapses to content height — no fallback, and nothing
for lightningcss to downlevel.

Panda's pattern is the same padding-bottom hack as Chakra's and works
everywhere; classroom measured the two identical. The child-override conflict
the gotcha is actually about only arises when the child is a Chakra component
with its own position, so porting the child first makes the pattern safe.

Flags the sites in ml-trainer and python-editor that took the old advice.
…a note

Gotcha #11 mentions the affected sites, but a corrected gotcha isn't where
anyone looks for outstanding work on a migration that's already signed off.
Adds an 'open across the completed migrations' section to the roadmap with the
specific files in ml-trainer and python-editor, and is explicit that nobody has
yet confirmed them visibly broken on a real Safari 14 — that check comes before
any change.
A call site ported from Chakra's `size={["md", "lg"]}` puts
`sm:btn--size_lg` on the element, but staticCss only generated the plain
variant classes — so the class had no rule behind it and the button silently
fell back to the base size. classroom's homepage measured 143px against
Chakra's 171px before the fix, and matches exactly after.

Same treatment `dialog` already had for the same reason; button hadn't needed
it because no app had passed a responsive size until now. Variants stay
non-responsive — nothing passes those conditionally.
classroom hit Chakra's default-colorScheme `outline` and its `outline` +
`whiteAlpha` and found nothing in the recipe for either — `warning` is the
destructive outline, not a neutral one. Both are restated per call site there
for now; recording the shapes next to the variants so the next app to want one
finds the question rather than rediscovering it. The neutral one is likely
already a two-app shape, since the playbook has `outline` as python-editor's
default variant.

`warningSolid` needed no such note: it mapped exactly onto Chakra solid+red,
measured identical.
Two things classroom's menu port needs.

MenuOptionGroup gains Chakra's `type`. The default stays radio
(`menuitemradio`, single-select); `type="checkbox"` is multi-select and
renders `menuitemcheckbox`, each option toggling independently. classroom's
MakeCode settings menu has a lone screen-reader-mode toggle that was a
Chakra checkbox group and had no counterpart here.

An option's own `onAction` fires on every press, including the one that
deselects it, so a lone toggle can be driven from the item rather than the
group — tested, since it is the shape classroom uses. Also tested and now
documented: choosing an option leaves the menu open, which matches Chakra's
checkbox groups but is a behaviour change for radio groups, which closed.

The `content` slot moves from zIndex `dropdown` (1000) to `popover` (1500).
A RAC Popover always portals to the body, so a menu opened from inside a
Modal (zIndex `modal`, 1400) escapes the modal's stacking context and paints
behind it — invisible, in the full-screen case classroom has. Chakra never
hit this because its MenuList rendered inline unless explicitly portalled.
Nothing else sits between 1400 and the toast/tooltip layer.

The tests need a setup file: jsdom 29 ships no global CSS object, and
react-aria calls CSS.escape whenever a collection mounts, so every
Menu/ListBox/GridList test throws without the polyfill.

One test asserts a trap rather than a feature: a non-collection child inside
a MenuList silently drops the rest of the collection — no throw, no warning.
It guards the reason dialogs have to be hoisted out of menus, so we notice if
react-aria ever starts reporting it.
…contexts

Two gotchas from classroom's menu port, plus two expected deltas.

#33 is the one that cost a preparatory commit: a RAC collection ends at the
first non-collection child, silently — no throw, no warning, in dev or prod.
A component that returns a fragment leading with a dialog empties the whole
menu, not just the items after it, and that is exactly the shape of a "menu
item that owns its dialog". Hoist the dialog and hand the item an opener.

#34: a RAC popover always portals to the body, so it leaves the stacking
context it was opened from. A menu inside a modal needs a z-index above the
modal or it is painted behind it — invisible, not merely clipped, when the
modal is full-screen. Chakra never showed this because its MenuList rendered
inline unless portalled, so it appears precisely at the port.

#12 gains the corollary that Chakra's keep-mounted menu lists mislead
verification scripts, which is how a measurement pass first reported five
menus sharing one geometry.
classroom's dialogs carry a data-testid on ModalContent and its end-to-end
suite addresses them that way, and two of its shells (ConfirmDialog,
CommonAlertDialog) forward whatever data attributes their caller passed. The
shell had nowhere to put them, so `data-*` now lands on the dialog box —
where Chakra's went.

Also corrects ModalHeader's `level` doc: the default is 2, not 3. RAC's
Dialog supplies that through HeadingContext for the title slot, so it already
matches the <h2> Chakra call sites put inside their header.
Same reason as the dialog box: classroom's end-to-end suite addresses a
dialog's header and its close button by data-testid, not just the box.
classroom's end-to-end suite waits on the loading spinner by data-testid.
#35: the library Modal puts an element between the dialog box and its
children, where Chakra's ModalContent was their direct parent — so call sites
that centred their content by styling the box silently stop centring it,
while every box measurement stays identical. classroom's loading spinner sat
141px off centre.

#36: run the Panda codegen before a verification pass. `npx vite` skips the
prestart hook, so the stylesheet is stale and any newly-introduced atomic
class is missing from it. That reads as a botched port — a heading at the
slot's default size rather than the one the css prop asks for — with nothing
wrong in the code.

Also two dialog-shaped expected deltas: `scrollBehavior` has no equivalent
(the library always scrolls the backdrop, Chakra's `outside`), and
`preserveScrollBarGap`/`blockScrollOnMount` are simply dropped.
Both components hand-picked `size` and passed it to the recipe, leaving any
other variant group in the rest-spread — so it landed on the DOM as an unknown
attribute and the styling silently did nothing. The base recipe only has
`size`, which is why nothing caught it; classroom's app preset adds a
`variant` group, and `variant="classroom"` was rendering as a plain outline
input with `variant="classroom"` sitting on the element.

They now use the recipe's own `splitVariantProps`, so a preset that adds a
variant group keeps working without the component knowing about it — which is
the point of the extension point.
#37 is the one worth grepping for elsewhere: a library component that
hand-picks recipe variants silently breaks the preset extension point. Input
and TextField passed only `size` to the recipe, so classroom's
`variant="classroom"` inputs rendered as plain outline boxes with the prop
sitting on the DOM. Nothing caught it because the base recipes only have
`size`.

Plus the measured toast padding delta and the missing Progress stripes.
The two dropdown pickers the family has been missing, sharing one `select`
slot recipe so a searchable and a non-searchable picker can't drift apart
visually. `trigger` is styled from the Chakra outline Input field so a select
sits level with a TextField beside it; `content` matches the `menu` recipe's
card so every dropdown surface agrees.

Select is a listbox behind a button. ComboBox is a text input that filters the
same listbox, and carries the three things classroom's react-select call sites
actually rely on:

- `emptyState`, react-select's `noOptionsMessage`. It implies RAC's
  `allowsEmptyCollection`, without which the popover simply closes as soon as
  nothing matches and the message never appears.
- `indicator={null}`, for a plain autocomplete with no chevron.
- `isPopoverHidden`, to withhold the list until a query is long enough.
  react-aria has no minimum-length prop, and rendering an empty list still
  opens an empty card — classroom makes students type two characters before it
  offers names, deliberately, so they pick their own.

Options are children, so they cannot see the variant their parent was given;
the parent passes its resolved slots down through context, as Modal does. Both
components split variant props off via the recipe rather than hand-picking
them (gotcha #37), so an app preset adding a `variant` group reaches every
slot including the options.

Deferred until something needs them: sections, multi-select, and async loading
via `useAsyncList` (the roadmap wants that for data-microbit-org's school
lookup).
A ComboBox's control is a text input, so unlike a Select it cannot show
anything but text for the current value — react-select did it with a custom
SingleValue. classroom's classroom-name pickers show an emoji beside the
chosen word, so the control needs a slot before the input.

The select recipe also needs its staticCss entry, as input has, so an app
preset's variant is generated whether or not a call site names it literally.
Call sites focus the field to report a validation failure — classroom's join
form focuses the first name part when the classroom is not found.
The popover defaulted to the bare input inside the control, so it hung off
the text baseline and was as narrow as the input rather than lining up under
the field. It now anchors to the control wrapper, and the recipe sizes the
card to `--trigger-width` — what a select should do, and what react-select
did.
A prop rather than a contentCss rule: RAC writes its own max-height inline
while positioning, which beats any class.
Exact for Select, whose trigger is the button RAC measures. For ComboBox the
var is the input's width — the control's content box, so narrower than the
field by its padding and border — and forcing it as `width` made the card
visibly narrower than the control. minWidth lets the card size to its content
instead, and a call site that needs it flush can say so through contentCss.
Select's SelectValue and ComboBox's input are the same thing — what shows the
current value — so they share a slot. ComboBox's input had its styling inline
instead, which meant an app restyling `value` (classroom draws the
placeholder black, as react-select did) reached a Select but not a ComboBox.
#38 is the checklist for the next app that retires react-select: opening on
click, prefix matching, the no-options message needing allowsEmptyCollection,
and gating on query length needing a real prop rather than display:none. Plus
the empty state rendering as a role="option" row, which quietly breaks tests
that count options.

#39: `--trigger-width` measures a ComboBox's input, not its control, so a card
sized from it comes out narrower than the field.

Select/ComboBox comes off the outstanding list; sections, multi-select and
useAsyncList remain.
RAC's --trigger-width measures the element it anchors to, which for a ComboBox
is the text input inside the control — so a card sized from it came out
narrower than the field by the padding and border. Visible in the library's own
storybook, and papered over in classroom by setting the width per site.

The ComboBox now measures its control and sets the width inline. State rather
than reading the ref while rendering: the popover is mounted from the first
render, before the ref is set, and nothing would re-render it — which is why
the obvious version of this quietly did nothing. A ResizeObserver keeps it
right when the field is responsive, guarded for jsdom.

Also adds stories for the props that arrived after the first pass —
startContent, maxHeight and the invalid state — and notes on the Combo story
that react-aria opens the list on typing rather than on click, with the
chevron as the click affordance. All six stories drive correctly in a browser.
Chakra's avatar: a circle showing a photo, the initials of a name, or a
generic glyph, plus a corner badge. classroom's class roster is the first
consumer (four sites); data-microbit-org has one.

The name-derived colour is Chakra's `randomColor({ string })` hash,
reproduced exactly so a migrating app's avatars keep the colours they had,
along with its brightness rule for white-or-dark text. Both arrive as inline
custom properties the recipe reads, for two reasons: an inline *property*
would beat a call site's `css` override, and a state selector (`&[data-…]`)
would outrank it on specificity anywhere cascade layers aren't in play —
which is every app still coexisting with Chakra. classroom's offline
students, greyed out from the call site, are exactly that case, and measured
it: gray.800 where Chakra gave gray.600.

The size variant sets the font size on the root and the label separately,
as Chakra did through one variable, because they turn out to be different
wishes: the root's is the em basis a badge measures against, the label's is
how big the initials are. classroom overrides one and not the other.
A list of selectable rows whose contents stay interactive — the reason to
reach for it over a ListBox, where an option is a leaf and a button inside
one is unreachable. Promoted from classroom's hand-rolled react-aria v3
hooks, which are what its class roster has been running on; ml-trainer has a
parked projects-page idea for the same shape.

Chakra had no equivalent, so the greys in the recipe are the family's
neutral list styling rather than a Chakra look to match. An app with its own
selection colour restates them, which classroom does.

`Key` and `Selection` are re-exported from system.ts: a call site handling
selection needs them and shouldn't have to import react-aria-components
itself.
A standalone list of choosable options — distinct from the `select` recipe's
list slots, which style the same react-aria primitive inside a dropdown card.
Options are leaves, so rows carrying their own controls still want GridList.
classroom's send-code dialog is the first consumer, replacing another
hand-rolled useListBox/useOption pair.

`control={false}` on Checkbox drops the box and the label wrapper with it, so
the children own the row and draw the selected state themselves — the
selectable-tile shape. That dialog's "all students" toggle is one: an avatar
that grows the same green tick its individual students do. Children may now
be a function of the checkbox's state, which is what makes that drawable.
#40 is the one classroom's roster port kept tripping over: gotcha #21's
"a flat utility override wins every state" is a post-kill-switch fact, and
during coexistence the layers are stripped, so recipe and call site argue on
specificity instead. Both halves were caught by measuring, not by reading —
an offline student's avatar text came out a grade too dark, and a selected,
hovered row kept a background it had been told to drop.

Also marks GridList, Avatar and ListBox built on the v1 surface list.
The last three things classroom needs from the library, all Chakra parity.

Tooltip's styles become a recipe because tooltip typography is set once for
all of them, not per call site: classroom's Chakra theme says `fontSize: md`,
and a `css` override at today's two call sites would quietly not apply to
tomorrow's.

Skeleton pulses between the same pair of custom properties Chakra used, so a
retinted skeleton animates between its own colours. Chakra's 0.4s fade-in of
the real content is not reproduced — wrap in `Fade` where it matters.

useDisclosure is a `useState` wrapper, worth having for the same reason
usePrevious and useClipboard are: it is the shape a migrating app's dialog
call sites are already written in. classroom has fourteen.
`styled.table` styles nothing when `styled` comes from @microbit/ui rather
than styled-system/jsx — Panda identifies its factory by the import, and a
re-export is not that module. The `styled(Component)` form is unaffected,
which is what makes it a trap: ml-trainer uses the re-export happily.

Found in classroom's About dialog, whose ported table lost every rule it had.
Measured against a Chakra build while porting classroom's two tooltips: the
radius was 6px where Chakra drew 2px, the text plain white where Chakra used
whiteAlpha.900, and the vertical padding twice Chakra's. The values had
drifted when the styles were written inline in the component; now they are a
recipe, they are worth being exact.

ml-trainer and python-editor take the correction with it — in their direction
too, since it is what their Chakra builds looked like.
Chakra's Icon rendered `verticalAlign: middle` on the svg itself; this one
did not, so an inline-block icon sat ~3px off wherever the surrounding line
box mattered. Panda's preflight sets it on every svg, which is why no app
noticed: it only shows in an app still coexisting with Chakra, whose
preflight is off. classroom measured it at its kill-switch, as the icons
moved back.
An app preset's globalCss entry replaces the base preset's for the same
selector rather than merging into it — unlike every other part of a preset.
classroom's kill-switch lost the body colour and the kerning to a two-line
font-smoothing addition, and the measurement was the only thing that said so.
`isOpen`/`onClose` become optional. Inside react-aria's `DialogTrigger` the
Modal reads the trigger's state instead, so a dialog with a single trigger
beside it needs no app state at all — the same shape MenuTrigger already has,
and the react-aria-native way to write a dialog.

`useDialogClose()` exposes the close function ModalCloseButton already used,
so a footer's own Cancel/Done button works whichever mode is driving it.

Verified in a browser (the new story): opens from the trigger, focus lands on
the dialog, and closing — from the close button or from a footer button —
restores focus to the trigger. The unit tests cover both modes; the focus
restoration is react-aria's and doesn't settle under jsdom, so it is asserted
in the browser rather than there.

Controlled stays the right answer for a dialog with more than one opener, one
opened from a menu item (a dialog cannot live inside a menu — it truncates
the collection, gotcha #33), or one opened from a handler. classroom, as it
happens, has no dialog that qualifies for the trigger form: of its fourteen,
six are opened from inside a handler, two from menu items, one is prop-drilled
into a child, and the remaining five have more than one opener — including
both toolbar buttons, each of which is duplicated as a mobile menu item.

Also fixes gotcha #42's formatting, which prettier had mangled.
Five small things from an external review of the migration's library work:

- ComboBox's `css` doc said "the input"; the class lands on the control, as
  Select's does. Says so now, and points at the recipe's `value` slot for the
  input itself.
- Select silently ignored `indicator={null}` — `ReactNode` accepts null, so
  the type promised something the `??` didn't honour, and ComboBox supports
  it. Select honours it too now, with a line on why a chevron-less select is
  rarely what you want: it is the only thing marking the trigger as a
  dropdown, where a ComboBox's text input speaks for itself.
- `initials` was too broad a name to export from the package root; it is
  `avatarInitials`. Nothing depended on it yet.
- Modal and Spinner each had their own copy of the `data-*` filter; it is one
  internal helper now.
- Checkbox's `control?: false` made `control={true}` a type error for no
  reason. Plain `boolean`, defaulting to true.
The <img> mounted immediately, so an avatar with a photo showed an empty
coloured circle while it loaded and the browser's broken-image glyph forever
if the URL was bad — with an onLoad but no onError, and an isLoaded that went
stale when `src` changed. On a roster of flaky photo URLs that is a plain
regression from Chakra, whose Avatar showed the initials or the icon until
the image was ready and went on showing them when it never arrived.

So the photo loads out of band, as Chakra's `useImage` did: the element is
only mounted once the load succeeds, which is what keeps a broken image out
of the circle rather than merely covering it up. A new `src` starts again,
so an avatar cannot keep showing the previous person.

Tests drive the loader for all three transitions (loading → loaded, loading →
failed, and a changed src), including that the name-derived background gives
way to the photo. Verified in a browser too — the Fallbacks story now carries
a loaded photo and a broken URL side by side, with the photo inlined so the
story needs no network.
Making both optional for DialogTrigger lost a real compile error: `isOpen`
without `onClose` type-checked, and the close button then silently did
nothing, falling back to a trigger context that isn't there. Every call site
in all three apps is controlled, so that error was protecting all of them.

`ModalProps` is a union again — both halves or neither. The plain union broke
the one pattern that forwards modal props (`Omit<ModalProps, "children">`,
three of ml-trainer's dialogs): a spread cannot be matched against a union,
because nothing tells TypeScript which half an `isOpen?: boolean` satisfies.
So the controlled half is exported as `ControlledModalProps`, which is what a
forwarding shell should say — and reads better than the union did there.

**Breaking for forwarders**: a component typed `Omit<ModalProps, "children">`
becomes `Omit<ControlledModalProps, "children">`. Three files in ml-trainer,
none in classroom or python-editor, and it fails loudly at compile time.

The type-level tests are the enforcement: `tsc` runs them, and the
`@ts-expect-error` on a half-specified Modal fails the build if that error
ever stops being reported.
listBox: ["*"],
input: ["*"],
radio: ["*"],
select: ["*"],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is quite heavy I think, I'll come back to this and see if we want per app config as to what CSS/components we generate.

@@ -0,0 +1,179 @@
/**

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I kinda suspect a lot of this is incidental from react-select rather than carefully chosen so one to revisit via the playground and when use grows beyond classroom (will happen with data migration).

@microbit-matt-hillsdon
microbit-matt-hillsdon marked this pull request as ready for review August 3, 2026 10:33
@microbit-matt-hillsdon microbit-matt-hillsdon changed the title WIP: classroom motivated changes Classroom motivated changes Aug 3, 2026
@microbit-matt-hillsdon
microbit-matt-hillsdon merged commit c86a136 into main Aug 3, 2026
1 check passed
microbit-matt-hillsdon added a commit that referenced this pull request Aug 3, 2026
Two findings from classroom's leaf-primitive port.

#30: Chakra's reset carries normalize's `hr { box-sizing: content-box }` and
Panda's preflight sets border-box on everything with no `hr` exception, so an
`<hr>` with an explicit height plus top/bottom borders silently changes height
at the flip. The zero-size-hr double-edge trick (1px on all four sides of a
0-width hr, the side borders reading as one 2px rule) is exactly that shape and
was in three apps; Divider's `thickness` variant is height-stable instead.
classroom's logo divider was 35px rather than the 33px its code asked for, and
was the only pixel difference across five screens.

#9/#17: confirmed from the other direction that a literal utility-named prop
does survive a plain wrapper — `h={23}` extracted and emitted `height: 23px`,
matching Chakra's unitless-number handling exactly. So #9's real scope is
non-literal values and non-utility prop names, which is worth saying plainly
next to the wrapper advice it seemed to contradict.
@microbit-matt-hillsdon
microbit-matt-hillsdon deleted the classroom-migration branch August 3, 2026 10:34
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.

1 participant