Skip to content

feat(ui): add the @lody/ui choice controls - #568

Merged
wibus-wee merged 6 commits into
feat/ui-v2from
feat/build-checkbox-radio-switch
Sep 10, 2026
Merged

feat(ui): add the @lody/ui choice controls#568
wibus-wee merged 6 commits into
feat/ui-v2from
feat/build-checkbox-radio-switch

Conversation

@wibus-wee

Copy link
Copy Markdown
Member

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/ui owned the controls a person types into. Every control a person picks
with still lived in packages/components/src/ui as Radix plus Tailwind classes
that reach for a vocabulary the new token rules do not have:

  • checkbox.tsx and radio-group.tsx: data-[state=checked]:bg-primary,
    text-primary-foreground, fill-primary, border-input-border,
    focus-visible:ring-ring/50, aria-invalid:ring-destructive/20
  • switch.tsx: data-[state=unchecked]:bg-switch-track,
    focus-visible:ring-offset-2, a 36×20 track

Three things compound there. Both disabled treatments are 50% where the rules
say 45% and the field family already reads field.disabledOpacity. Both focus
rings are ring-offset haloes, which the rules ban and which the desktop shell
suppresses with its outline: none !important reset anyway. And primary is
precisely 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, Radio and Switch join the existing 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.
  • Five new colour-valued tokens (checkedFill, checkedMark, checkedEdge,
    thumb, thumbShadow) join fieldPaletteTheme so they resolve inside a
    forced palette.
  • Each control renders a real <button> through Base UI's nativeButton
    instead of the default <span>.
  • Mixed is a separate indeterminate prop, not a third checked value.
  • All 33 callers moved; checkbox.tsx, switch.tsx and radio-group.tsx are
    deleted with their barrel exports and their three @radix-ui dependencies.

The token rules, the package README/AGENTS.md, the gallery board, the Spec and
an Agent Note all land with it.

Visual explanation

State resolves through the field root into shared well styles; only the edge
changes, and the ring is restated with each edge because CSS cannot append to a
box-shadow list.

flowchart LR
  Root["Field.Root<br/>name · disabled · validity"]
  Root -->|state callback on className| CB["Checkbox"]
  Root --> RD["Radio"]
  Root --> SW["Switch"]
  CB --> Box
  RD --> Box
  SW --> Box
  Box["well.box<br/>field.background + field.well<br/>:focus-visible → + field.ring"]
  Box -->|checked / on| Ink["well.checked<br/>field.checkedFill + field.checkedEdge<br/>:focus-visible → + field.ring"]
  Box -->|invalid| Inv["well.invalid<br/>field.well + field.invalidRing"]
  Ink -->|invalid| CInv["well.checkedInvalid<br/>field.checkedEdge + field.invalidRing"]
Loading

What each state looks like, read off the rendered nodes in Chromium:

state Lody Light Vesper geometry
off rgb(232, 234, 237) + inset rgb(28, 28, 28) + inset box 16×16 r5, track 28×16 pill
on / mixed rgb(26, 27, 30) + ink edge rgb(255, 255, 255) + ink edge thumb 12×12, inset 2, travel 12
focus … inset, rgb(93, 141, 239) 0 0 0 2px accent ring, same shape 2px, tight, no offset
invalid rgb(206, 34, 45) ring, held while focused rgb(255, 128, 128)
disabled opacity 0.45 opacity 0.45
packages/ui/src/field/
├── field.tokens.stylex.ts   +boxSize/boxRadius/markSize/dotSize/switch*/checked*/thumb*
├── well.ts                  +box +checked +checkedInvalid   (base/invalid unchanged)
├── checkbox.tsx             new — Base UI Checkbox + inline tick/dash SVG
├── radio.tsx                new — Base UI Radio + RadioGroup
└── switch.tsx               new — Base UI Switch + Thumb

Before / after

Before After
data-[state=checked]:bg-primary on a concept the token rules removed field.checkedFill (label) + field.checkedMark (background) + field.checkedEdge (shadow.inkEdge)
disabled:opacity-50 (checkbox, switch), against the rules' 45% one field.disabledOpacity, shared with the label and help text
focus-visible:ring-ring/50 ring-offset-2, suppressed by the shell 2px field.ring box-shadow composed with the control's own edge
border-input-border + shadow-xs field.well (shadow.inset); no border token exists
Radix <button data-state="checked">, no hidden input Base UI <button role="checkbox"> + hidden input carrying the value
checked={boolean | 'indeterminate'}, narrowed with === true at 4 call sites checked boolean beside indeterminate
switch 36×20 with a 16px thumb; scale-75 in a menu to shrink it 28×16 with a 12px thumb — the checkbox's height, so settings rows line up
workdir-mode-selector rebuilt the checkbox at 12px in muted-foreground/15 the primitive, unstyled by the caller

Test plan

Run and passing:

  • pnpm --filter @lody/ui test — 46 tests, 16 new in test/toggles.test.tsx
  • pnpm --filter @lody/ui typecheck, pnpm --filter @lody/components typecheck
  • pnpm lint (470 warnings, 0 errors — unchanged), pnpm lint:i18n
  • pnpm check:public-boundary, pnpm check:platform-boundaries
  • pnpm run docs check — 0 errors
  • pnpm format, plus prettier --check over packages/ui (which the root
    format script does not reach, as the package has no format script)

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/Switch story and the
beta-features settings section render correctly, clicking a switch flipped the
atom the surface reads, and clicking a <label> toggled a checkbox, which is
the labelable-element behaviour the native <button> buys.

Not done:

  • No test asserts a rendered appearance; Chromium only; no mobile surface opened.
  • pnpm --filter @lody/components test fails on this machine with
    (0, act) is not a function across ~154 files. Confirmed identical on the base
    commit, 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.md still lists the three removed packages. Regenerating
    it 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

  • Review focus: packages/ui/src/field/well.ts (the four box/edge/ring
    combinations and whether StyleX's last-wins merge really produces them),
    checkbox.tsx/radio.tsx/switch.tsx for the nativeButton render, and the
    two indeterminate conversions in archive-view.tsx and project-settings.tsx.
  • Decisions to challenge: ink rather than accent for a switch that is on,
    given the rules' own phrase "live switch"; rendering a real <button> instead
    of Base UI's default <span>; deleting workdir-mode-selector's visual
    className rather than preserving that surface's current look.
  • Plausible failures / evidence gaps: the component test suite did not run
    locally, so caller regressions rest on CI; the switch thumb uses translateX
    and 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.raised alone.

Authoring context

  • User goal / directives: open the next @lody/ui slice after the field
    primitives — Checkbox, Radio and Switch — reusing the existing field tokens,
    well, ring, disabled treatment and Field.Root state model, with Select and
    Combobox deferred because they need popup, keyboard-navigation and overlay
    tokens.
  • Constraints / non-goals: no new token group; no second state vocabulary;
    @lody/ui depends on React, Base UI and StyleX only, so the tick and dash are
    inline SVG rather than lucide-react; no Select, Combobox or overlay work.
  • Risk-bearing decisions: the checked appearance is a design decision applied
    to 33 shipped surfaces at once; Checkbox's public prop shape changed from
    Radix's tri-state checked to checked + indeterminate; three @radix-ui
    dependencies were dropped from packages/components.
  • Destructive or irreversible behavior: deletes checkbox.tsx,
    switch.tsx and radio-group.tsx with their barrel exports.
    radio-group.tsx had zero in-repo callers and no barrel export. No migration,
    storage or runtime state is touched; reverting the commit fully restores the
    previous controls.
  • Deliberately not done or tested: no rendered-appearance assertions, no
    RTL pass, no mobile surface opened, THIRD_PARTY_NOTICES.md left stale, and
    the UI/Switch story kept rather than folded into the gallery board.
  • Unknowns / confidence: high on the primitives and tokens, which are
    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.

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
wibus-wee marked this pull request as ready for review September 10, 2026 03:36
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T08:16:00.675622Z 74f9030 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

web-flow and others added 5 commits September 10, 2026 04:03
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>
@wibus-wee
wibus-wee merged commit a1b6755 into feat/ui-v2 Sep 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants