From c958d0c36395a497f43fda7124f15f6dd1243ecb Mon Sep 17 00:00:00 2001 From: Wibus Date: Tue, 15 Sep 2026 09:04:21 +0000 Subject: [PATCH 1/2] feat(ui): add the @lody/ui Toggle, ToggleGroup and Toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control that stays pressed, a set of them answering to one value, and the bar that holds them, sharing one `toggle` token group. On is the well rather than ink. The rules give a stored state ink, and ink is what a control that already sits in a well becomes when it is on — a Switch's off state occupies the well, so on has to leave it. A toggle rests on nothing, so the well is free, and sinking into it is the plainest thing this system can say about a button that went down and stayed. It also keeps a bar of eight from reading as eight primary buttons. A set is not a Tabs strip with its track removed: a strip picks what you see, so it is one control with one pill sliding across it, while a set stores what is on and two of its members can be pressed at once. A bar draws nothing at all — not even the line a table draws — and exists to make a row of controls one tab stop the arrow keys walk. Migrates the task body's format bar and the tasks view's "Show" chips, and deletes the dead Radix `toggle.tsx` and its dependency. Model: claude-opus-5[1m] --- .../feature/2026-09-15-ui-toggle-toolbar.md | 280 ++++++++++++++++ locales/en.json | 2 + locales/zh_CN.json | 2 + packages/components/package.json | 1 - .../tasks/task-body-selection-toolbar.tsx | 204 +++++++----- .../src/components/tasks/tasks-workspace.tsx | 57 ++-- packages/components/src/ui/toggle.tsx | 44 --- packages/ui/AGENTS.md | 129 ++++--- packages/ui/README.md | 143 ++++++-- packages/ui/package.json | 3 + packages/ui/src/gallery/gallery.tsx | 314 ++++++++++++++++++ packages/ui/src/index.ts | 6 + packages/ui/src/separator/separator.tsx | 11 +- packages/ui/src/theme/theme.tsx | 2 + packages/ui/src/toggle/set.ts | 32 ++ packages/ui/src/toggle/surface.ts | 182 ++++++++++ packages/ui/src/toggle/toggle-group.tsx | 75 +++++ .../ui/src/toggle/toggle.tokens.stylex.ts | 86 +++++ packages/ui/src/toggle/toggle.tsx | 93 ++++++ packages/ui/src/toggle/toolbar.tsx | 130 ++++++++ packages/ui/src/tokens/RULES.md | 69 ++++ packages/ui/test/gallery.test.tsx | 21 ++ packages/ui/test/toggle.test.tsx | 251 ++++++++++++++ pnpm-lock.yaml | 27 -- specs/ui-primitives.md | 42 +++ 25 files changed, 1924 insertions(+), 282 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-15-ui-toggle-toolbar.md delete mode 100644 packages/components/src/ui/toggle.tsx create mode 100644 packages/ui/src/toggle/set.ts create mode 100644 packages/ui/src/toggle/surface.ts create mode 100644 packages/ui/src/toggle/toggle-group.tsx create mode 100644 packages/ui/src/toggle/toggle.tokens.stylex.ts create mode 100644 packages/ui/src/toggle/toggle.tsx create mode 100644 packages/ui/src/toggle/toolbar.tsx create mode 100644 packages/ui/test/toggle.test.tsx diff --git a/.agents/notes/implemented/feature/2026-09-15-ui-toggle-toolbar.md b/.agents/notes/implemented/feature/2026-09-15-ui-toggle-toolbar.md new file mode 100644 index 000000000..fe107c02d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-ui-toggle-toolbar.md @@ -0,0 +1,280 @@ +# UI toggle and toolbar: a control that stays pressed, and the row that holds it + +Status: implemented +Translation: pending + +## Abstract + +`@lody/ui` had a control for every value a form stores and none for the state an +interface holds without storing it — bold, wrapped lines, this filter — so +twenty-two files in `packages/components` each drew their own `aria-pressed` +button, and the fills they chose to mean "on" run to a dozen unrelated washes +(`bg-muted-foreground/20`, `bg-primary/[0.12]`, `bg-white/14`, and several that +change nothing but the text colour). This note records the family that replaces +them — `Toggle`, `ToggleGroup` and `Toolbar`, sharing one `toggle` token group — +and its single real design decision: **on is the well, not ink**, because ink is +what a control that _already sits in a well_ becomes when it is on, and a toggle +rests on nothing. It also records why a set of toggles is not a `Tabs` strip +with the track removed, and why a bar that draws nothing at all is still a +primitive. Two callers are migrated whole and the dead Radix `toggle.tsx` and +its dependency are removed; the rest of the product's pressed buttons are left +to the surfaces that own them, because a half-migrated row of controls is the +inconsistency this work exists to remove. The board showed that in the dark +palette it is the label colour rather than the fill that carries the state. + +## Problem + +Every rung of this system had a component except the one a person leaves pressed. + +`Switch`, `Checkbox` and `Radio` cover a value a form stores. `Tabs` covers one +thing shown out of several. Neither is what a formatting bar, a file viewer's +strip of actions or a row of filter chips needs: a button that is on right now, +that has no name, belongs to no `Field.Root`, and cannot be invalid. + +So the product drew its own, twenty-two files of it, and the appearances had +diverged as far as appearances can. A survey of the fills within six lines of an +`aria-pressed` attribute in `packages/components/src` finds `bg-muted`, +`bg-muted/30`, `bg-muted/40`, `bg-muted/60`, `bg-muted/80`, +`bg-muted-foreground/20`, `bg-secondary`, `bg-secondary/50`, `bg-primary/10`, +`bg-primary/15`, `bg-primary/[0.05]`, `bg-white/10` and `bg-white/14` — thirteen +answers to one question. Two of the worst are in +`sessions/session-file-content-view.tsx`, where "wrap long lines is on" and +"annotation is on" are said with `text-foreground` against +`text-muted-foreground` and nothing else: a 24px icon button whose entire state +is a shade of gray, standing in a row of eight that are never pressed. + +`packages/components/src/ui/toggle.tsx` — the Radix wrapper with a `cva` of two +variants — had reached zero in-repo callers some time earlier and had not been +deleted, so `@radix-ui/react-toggle` was still a production dependency of a +package that never imported it. + +## Decision + +One family, one token group, `toggle`: a control that stays pressed, a set of +them answering to one value, and the bar that holds them. They are one family +for the reason `disclosure` covers three layouts of one idea — how far apart two +of these stand, and what "pressed" looks like, cannot become three decisions in +three files. + +### On is the well, not ink + +This is the decision the rest follows from, and it is the elevation rules read +carefully rather than literally. + +`src/tokens/RULES.md` says ink is for a stored state: the primary button, +checked, on. A toggle does store a state, so the literal reading gives it ink — +`label` fill under `background` text. That reading is wrong, and the reason is +visible in the controls the rule was written for. A Switch's **off** state is +already the well: `wellBackground` under `shadow.inset`. A Checkbox's empty box +is the same. On has to leave the well, because the well is taken, and ink is +where it goes. Ink is therefore not "what a stored state looks like" — it is +what a control that already sits in a well _becomes_ when it is on. + +A toggle rests on nothing at all. It is a ghost Button when it is off: no fill, +`secondaryLabel`, `hoverFill` under the pointer. The well is free, so it sinks +into it, which is also the most literal thing this system can say about a button +that went down and stayed down — depth without lines, which is the construction +rule at the top of the same file. And it is what keeps a formatting bar of eight +from reading as eight of the most important thing on the screen, which is what +eight ink-filled buttons would be. + +The second half of the state is the label: `secondaryLabel` off, `label` on. A +toggle that is off is _about_ the thing it acts on; one that is on **is** the +thing. That pair matters more than it looks — see what the board showed. + +A toggle also does not bob. The motion rules give a press `translateY(1px)` and +a dropped `shadow.inkEdge`, which is a raised thing going down and coming back. +This one goes down and stays, so what changes is the surface under it. + +### The ladder, and where it comes from + +Sizes are `mini` 24, `small` 28, `medium` 32 and `large` 36 — the control ladder +plus the 24px step a file viewer's strip of actions needs, which is the same +ladder `Button` carries. It is taken from `control` in `scales.stylex.ts` rather +than from `button`: a Button and a Toggle in one bar line up because both read +the same scale, not because one component's group reads another's, which is the +rule that keeps a menu from reaching for `field.background`. + +### A set is not a strip + +`ToggleGroup` and `Tabs` hold the same three words and are not the same part. + +A strip picks what a person **sees**. It is a single control, so it can be — and +is — a sunken track with one thing raised out of it and one pill sliding between +the choices. A set stores what is **on**, and two of its members can be pressed +at once, which no single sliding pill can say. So a set has no track and each +member sinks on its own. Asked for one choice out of several, with `multiple` +left off, it _still_ draws no track: the same set with `multiple` on has to look +like itself, and a component that changed its construction with a boolean would +be two components sharing a name. + +The size and the shape are stated once on the set and read from a context in +`src/toggle/set.ts`, the way `Tabs.List` states them for its tabs. The context +lives in its own file because both parts need it and either importing the other +to reach it would close a cycle. + +### A bar that draws nothing + +`Toolbar` has no fill, no shadow, no radius, and not even the line a table +draws. It is a row of controls on whatever surface the product already had +there, so a bar inside a popover is not a second surface inside one. + +It is a primitive anyway, and what it is for is the keyboard. A row of eight +icon buttons is eight tab stops unless something says otherwise, and a person +tabbing past a formatting bar should pass it rather than walk it. Base UI's +composite makes the bar one stop, gives the arrow keys the walking, and steps +over a control that cannot be used rather than stopping on it. That is the whole +argument for it not being a `div` with a gap. + +Two gaps say what belongs with what: `toggle.groupGap` (4) between the members +of one cluster or set, `toggle.barGap` (8) between clusters and either side of +the line between two. The line is `Separator`'s — the one line these rules allow +— and the bar states its orientation rather than the caller, so a horizontal bar +cannot end up divided the wrong way. `separator.tsx` now exports its styles so +there is one definition of that hairline rather than two. + +`Toolbar.Button` is Base UI's, unstyled, the way every trigger in this package +is: joining the walk is all it does, and what arrives is `render={}` +or `render={ +