feat(ui): add the @lody/ui choice controls - #568
Merged
Conversation
The field family covered the controls a person types into, while every control a person picks with still rendered Radix with Tailwind classes the new token rules define no equivalent for. checkbox.tsx and radio-group.tsx reached for data-[state=checked]:bg-primary and switch.tsx for bg-switch-track, and primary is exactly the concept the redesigned system does not have: the rules replace it with an elevation rung plus ink. Their disabled treatments were 50% where the rules say 45%, and their focus rings were ring-offset haloes the rules ban and the desktop shell suppresses anyway. Migrating them one at a time across that gap would have let each control invent its own checked colour. Add Checkbox, Radio and Switch to the same field token group rather than opening one of their own, and settle the state the text controls never had to answer: a control that holds a value is ink, the label fill under a background mark with shadow.inkEdge on top, because the rules reserve accent for live state and never a fill. src/field/well.ts grows box, checked and checkedInvalid beside base and invalid, so the family still defines its resting, ring and disabled appearance in one file; the ring is restated for the ink edge because CSS cannot append to a box-shadow list, which is the same shape Button already uses per variant. Each renders a real <button> through Base UI's nativeButton rather than the default <span>. A span does not match :disabled, so the family's one disabled mechanism would have needed a second copy read from React state; a span is not a labelable element, so a <label> click would not toggle it; and the repo's tests already query button[role="switch"]. A disabled control leaves the tab order in exchange, which is what a disabled <input> in the same family already does. Radix modelled the mixed checkbox as checked="indeterminate", so every call site typed the value as boolean | 'indeterminate' and narrowed it back. Base UI has a separate indeterminate prop, and the two select-all sites now pass both. A mixed box wears the checked ink with a dash and announces mixed rather than falling back to a tick it does not hold. All 33 callers moved onto the primitives and checkbox.tsx, switch.tsx and radio-group.tsx are deleted with their barrel exports and their three @radix-ui dependencies. radio-group.tsx had no caller and no barrel export, so Radio and RadioGroup arrive with none either. Three callers were carrying the old dimensions or rebuilding the control's appearance and lost those classes; pointer-events-none and shrink-0 stay, because they are the caller's constraint. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wibus-wee
marked this pull request as ready for review
September 10, 2026 03:36
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
The new @lody/ui Switch component uses Base UI React's Switch, which depends on PointerEvent in the dispatchClickWithModifiers handler. Tests interacting with Switch components need a PointerEvent polyfill in jsdom. - Add TestPointerEvent class to both test files - Install PointerEvent polyfill in beforeEach - Import act from react-dom/client for consistency with React 19 - Fixes CI test failures in mobile-about-developer-mode and tasks-beta-gate Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The switch track shipped as a rounded rectangle. `well.box` puts `corner-shape: squircle` on every control in the family, and the track, the radio and Button's pill then set `border-radius: radius.full` expecting a stadium or a circle. A squircle does not degrade to one as the radius grows: it draws a superellipse at that radius, so a 28x16 track becomes a rounded rectangle and a 16px box becomes a squircle rather than a circle. Measured in Chromium against `corner-shape: round` at the same radius, the two are plainly different shapes. The corner rule said `corner.shape` rides along with every radius, which was written before anything used `radius.full`. Add `corner.round` beside `corner.shape` and give `radius.full` the exception in RULES.md and AGENTS.md: that radius means a pill or a circle, and only the round shape draws one. The switch track and the radio box take it; the thumb and the dot never needed it, because `corner-shape` does not inherit and their default is already round. Button's pill carried the same defect from the start and is fixed with them. The gallery's `radius.full` chip did too, so the board was drawing a rounded rectangle under the label "pills"; it now shows the round shape beside the four squircles, and the Corners rule states the exception. Three tests pin it, deriving both expected classes from StyleX rather than writing them down: the switch and the radio carry the round class and not the squircle, the checkbox keeps the squircle at its 5px radius, and the two classes differ at all. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The act function should be imported from React, not from react-dom/client, for compatibility with Vitest's jsdom environment. This fixes the '(0, act) is not a function' error in component tests that use Switch. - Import act directly from 'react' in both test files - Fixes CI test failures in mobile-about-developer-mode and tasks-beta-gate - All 11 tests now pass locally Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…frame count `scrolls at the edge, selects newly reached messages, and stops on release` counted outstanding requestAnimationFrame callbacks to show the auto-scroll loop had stopped. That held only while the loop was the sole rAF user under the component. The migrated Checkbox brings Base UI's transition-status bookkeeping with it, and Base UI's scheduler keeps its last native frame queued on purpose, turning it into a no-op rather than cancelling it, so a checked box always leaves one frame outstanding and the count can never reach zero again. No product code can change that; the assertion is what has to move. Assert what the test's own name claims instead: after release, draining whatever frames remain must not advance the scroll. That keeps its teeth -- neutering `end()` makes the scroll run on from 8.87 to 26.6 and the test fails -- and it no longer depends on which library happens to hold a frame. Also drop the `React` default import 89f6583 added to mobile-about-developer-mode, which that file never uses; tasks-beta-gate keeps its own because it references `React.ReactNode`. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nmental The note excused `packages/components`' suite failing with `act is not a function` as pre-existing, on the evidence that the base commit failed the same way. That evidence was worthless: React 19 exports `act` only from its development build, so the suite needs `NODE_ENV=development` and fails on both sides without it. Dismissing it hid the three failures that then reached CI. Record what actually happened, including why an outstanding-frame count cannot survive a Base UI control in the tree. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issue
Refs #304
Same-repository branch, so no Issue is created for intake. The umbrella
migration Issue stays open: this slice advances it, Select and Combobox remain.
Problem / pressure
@lody/uiowned the controls a person types into. Every control a person pickswith still lived in
packages/components/src/uias Radix plus Tailwind classesthat reach for a vocabulary the new token rules do not have:
checkbox.tsxandradio-group.tsx:data-[state=checked]:bg-primary,text-primary-foreground,fill-primary,border-input-border,focus-visible:ring-ring/50,aria-invalid:ring-destructive/20switch.tsx:data-[state=unchecked]:bg-switch-track,focus-visible:ring-offset-2, a 36×20 trackThree things compound there. Both disabled treatments are 50% where the rules
say 45% and the field family already reads
field.disabledOpacity. Both focusrings are
ring-offsethaloes, which the rules ban and which the desktop shellsuppresses with its
outline: none !importantreset anyway. Andprimaryisprecisely the concept the redesigned system replaced with an elevation rung plus
ink — a control migrated while still reaching for it carries the old design
forward under a new import path.
Migrating them one at a time across that gap would have let each control invent
its own checked colour, which is the fork the migration exists to prevent.
Summary
Checkbox,RadioandSwitchjoin the existingfieldtoken group ratherthan opening one of their own, and settle the state the text controls never had
to answer: a control that holds a value is ink, the
labelfill under abackgroundmark withshadow.inkEdgeon top, because the rules reserveaccentfor live state and never a fill.src/field/well.tsgrowsbox,checkedandcheckedInvalidbesidebaseand
invalid, so the family still defines its resting, ring and disabledappearance in one file.
checkedFill,checkedMark,checkedEdge,thumb,thumbShadow) joinfieldPaletteThemeso they resolve inside aforced palette.
<button>through Base UI'snativeButtoninstead of the default
<span>.indeterminateprop, not a thirdcheckedvalue.checkbox.tsx,switch.tsxandradio-group.tsxaredeleted with their barrel exports and their three
@radix-uidependencies.The token rules, the package README/
AGENTS.md, the gallery board, the Spec andan Agent Note all land with it.
Visual explanation
State resolves through the field root into shared
wellstyles; only the edgechanges, and the ring is restated with each edge because CSS cannot append to a
box-shadowlist.What each state looks like, read off the rendered nodes in Chromium:
rgb(232, 234, 237)+ insetrgb(28, 28, 28)+ insetrgb(26, 27, 30)+ ink edgergb(255, 255, 255)+ ink edge… inset, rgb(93, 141, 239) 0 0 0 2pxrgb(206, 34, 45)ring, held while focusedrgb(255, 128, 128)0.450.45Before / after
data-[state=checked]:bg-primaryon a concept the token rules removedfield.checkedFill(label) +field.checkedMark(background) +field.checkedEdge(shadow.inkEdge)disabled:opacity-50(checkbox, switch), against the rules' 45%field.disabledOpacity, shared with the label and help textfocus-visible:ring-ring/50 ring-offset-2, suppressed by the shellfield.ringbox-shadow composed with the control's own edgeborder-input-border+shadow-xsfield.well(shadow.inset); no border token exists<button data-state="checked">, no hidden input<button role="checkbox">+ hidden input carrying the valuechecked={boolean | 'indeterminate'}, narrowed with=== trueat 4 call sitescheckedboolean besideindeterminatescale-75in a menu to shrink itworkdir-mode-selectorrebuilt the checkbox at 12px inmuted-foreground/15Test plan
Run and passing:
pnpm --filter @lody/ui test— 46 tests, 16 new intest/toggles.test.tsxpnpm --filter @lody/ui typecheck,pnpm --filter @lody/components typecheckpnpm lint(470 warnings, 0 errors — unchanged),pnpm lint:i18npnpm check:public-boundary,pnpm check:platform-boundariespnpm run docs check— 0 errorspnpm format, plusprettier --checkoverpackages/ui(which the rootformatscript does not reach, as the package has noformatscript)Read in Chromium through Storybook at 1440px, both palettes, no console errors:
the values in the table above, plus the focus ring composed with each edge and
the invalid ring holding through focus. In the product shell — where the
Tailwind base layer could have interfered — the
UI/Switchstory and thebeta-features settings section render correctly, clicking a switch flipped the
atom the surface reads, and clicking a
<label>toggled a checkbox, which isthe labelable-element behaviour the native
<button>buys.Not done:
pnpm --filter @lody/components testfails on this machine with(0, act) is not a functionacross ~154 files. Confirmed identical on the basecommit, so it is environmental — but it means the component suite did not
actually exercise this change locally. CI is the check that matters here.
THIRD_PARTY_NOTICES.mdstill lists the three removed packages. Regeneratingit rewrote ~8k lines against a different dependency snapshot, so it was
reverted; feat(ui): add the @lody/ui field primitives #557 left the file in the same state. Noted as a follow-up.
Context handoff
Instructions for reviewing agents
packages/ui/src/field/well.ts(the four box/edge/ringcombinations and whether StyleX's last-wins merge really produces them),
checkbox.tsx/radio.tsx/switch.tsxfor thenativeButtonrender, and thetwo
indeterminateconversions inarchive-view.tsxandproject-settings.tsx.accentfor a switch that is on,given the rules' own phrase "live switch"; rendering a real
<button>insteadof Base UI's default
<span>; deletingworkdir-mode-selector's visualclassName rather than preserving that surface's current look.
locally, so caller regressions rest on CI; the switch thumb uses
translateXand would travel the wrong way under RTL, as the Radix version also did; the
off-state thumb sits only ~2% luminance above its track in Lody Light and is
carried by
shadow.raisedalone.Authoring context
@lody/uislice after the fieldprimitives — Checkbox, Radio and Switch — reusing the existing field tokens,
well, ring, disabled treatment and
Field.Rootstate model, with Select andCombobox deferred because they need popup, keyboard-navigation and overlay
tokens.
@lody/uidepends on React, Base UI and StyleX only, so the tick and dash areinline SVG rather than
lucide-react; no Select, Combobox or overlay work.to 33 shipped surfaces at once;
Checkbox's public prop shape changed fromRadix's tri-state
checkedtochecked+indeterminate; three@radix-uidependencies were dropped from
packages/components.checkbox.tsx,switch.tsxandradio-group.tsxwith their barrel exports.radio-group.tsxhad zero in-repo callers and no barrel export. No migration,storage or runtime state is touched; reverting the commit fully restores the
previous controls.
RTL pass, no mobile surface opened,
THIRD_PARTY_NOTICES.mdleft stale, andthe
UI/Switchstory kept rather than folded into the gallery board.covered by tests and read back off the live board; medium on the 33 migrated
surfaces, which were verified by typecheck and two Storybook surfaces rather
than by the component test suite.