elements: rebuild the styled tier on useRender grafts (rename ui to elements)#214
Merged
Conversation
…lements)
The old ui tier wrapped Base UI parts directly, fusing styling to Base UI's
API. Every part in the tier is now a plain styled intrinsic element built
with useRender + cva, importing nothing from @base-ui/react beyond
useRender/mergeProps. Base UI (and base) behavior grafts onto the styled
element in components via render — behavior part outer, styled part as the
render target — and components owns every Root, provider, portal, context,
and default.
Since the tier is just styled elements now, "ui" stopped describing it:
renamed to elements (src/elements, @plane/propel/elements/*, Elements/
story titles).
Also:
- dedupe styled elements repeated across 3+ families into internal/
(positioner, backdrop, arrow, popup-viewport, overlay-title,
overlay-description, listbox-popup, listbox-item)
- drop base/text-area (Field.Control render={<textarea/>} covers it);
keep base/composite, grafted in components/list
- rewrite the AGENTS.md protocol (rules 1, 1a, 2, 4a, 6a) for the model
The composer's formatting bar passed bare lucide icons to ToolbarButton/ ToolbarToggle, so they rendered at lucide's default 24px instead of the 14px compact glyph the toolbar sizes through its ToolbarItemIcon slot (--node-size). Wrap each icon like the Toolbar story does — the two bars now render identically, matching the Figma comment box.
|
📚 Storybook preview: https://pr-214-propel-storybook.vamsi-906.workers.dev |
The indicator flipped a chevron-down 180deg (down closed, up open). Disclosure carets read better pointing into the collapsed content: rotate the caret -90deg while closed (inline-end, RTL-mirrored like the list section trigger) and back to 0 when the panel opens.
The bar has no intrinsic width (w-full root, flex-1 min-w-0 track), so the bare Default/Indeterminate renders collapsed to 0px under the centered layout — Default showed only its 60% label and Indeterminate showed nothing. Wrap the meta in the same w-64 the sibling stories (and Meter) already use.
Every listbox family was rendering broken rows: - The item grid (grid-cols-[1rem_1fr]) places children positionally, and Base UI unmounts ItemIndicator while unselected, so unselected combobox/select rows slid their text into the 1rem marker column and wrapped at min-content (us-central-1 across three lines). - Autocomplete has no indicator at all, so every suggestion wrapped. Give ListboxItem a layout axis: "indicator" keeps the marker-column grid for combobox/select, "plain" is a flush flex row for autocomplete. Graft the indicators with keepMounted so the gutter holds on every row, and hide the glyph off data-selected in the styled marker instead.
Base UI ships multi-value support on Combobox (multiple + a Chips/Chip/ChipRemove anatomy), Select (multiple), and ToggleGroup (multiple); CheckboxGroup is inherently multi. Cover each: - combobox: new styled elements parts ComboboxChips (wrapping multiselect input frame on the shared field surface), ComboboxChip (neutral tag with the roving-focus ring), and ComboboxChipRemove; Multiple stories in both tiers wire them through Combobox.Value with remove/append interaction twins - select: Multiple story — the trigger joins the selected labels and every selected row keeps its check - toggle-group: the elements Multiple story was hidden behind !dev/!autodocs tags and drew Bold/Italic with alignment glyphs — now visible with the right icons, plus a components-tier Multiple story; interaction twins assert additive selection
The slot vocabulary had drifted: four suffixes (Icon/Indicator/Slot/Node) covered three concepts, menu and context-menu named the same slots differently, two families hand-rolled the glyph sizing the node-slot class already owns, and the node props were named by position+type instead of the slot they fill. Parts, following Base UI's suffix usage (Icon/Indicator/Group): - InputIconSlot -> InputIcon, CheckboxInlineStartNode -> CheckboxIcon - MenuItemSelectedIndicator -> MenuItemIndicator (mirrors Select/Combobox ItemIndicator; ContextMenuItemIndicator was already right), MenuItemSubmenuIndicator -> MenuSubmenuTriggerIndicator - ToastCloseSlot -> ToastCloseGroup (node wrappers are Groups, like ToastActionGroup) - TableCellSlot deleted - it was nodeSlotClass verbatim, so TableCell composes the internal NodeSlot like Tab does Props, named for the slot they fill (Base UI names by usage: placeholder/title/description): - single Icon slot -> icon (banner, checkbox, tab, accordion trigger, menu/context-menu rows) - dual Icon slots -> startIcon/endIcon (button, anchor-button, badge, pill, input-field, table-cell) - MenuItemTrailing -> trailing, MenuLabelMeta -> meta Mechanics: every glyph slot now sizes through nodeSlotClass + --node-size (list, navigation-menu, combobox, breadcrumb, toast status, pill/icon-button spinners) and every decorative slot bakes aria-hidden (list item icon, navigation-menu icon). Rule 6e in AGENTS.md records the convention.
Sweep of the public surface against the protocol rules before the first publish: - PopoverContent/MenuContent exposed a width prop — a native attribute name rule 6b bans. Renamed the axis to sizing (values unchanged: anchor/sm/md/lg/auto), including the internal OverlayPanel axis and the exported types (MenuContentSizing, PopoverContentSizing). - The slot renames had left their Props types behind: CheckboxIcon still shipped CheckboxInlineStartNodeProps, and likewise InputIcon, MenuItemIndicator, MenuSubmenuTriggerIndicator, ToastCloseGroup. All five now match their parts. - The toast's stopgap Solid* status glyphs sat unexported inside components/toast, violating the every-file-is-public index rule; they now live in internal/solid-icons (they'll be replaced by the Foundations icon set anyway), with StatusIconProps renamed SolidIconProps. - elements/field forgot to export FieldItemContent and FieldLabelGroup; the components tier was importing them by file path around the index. - Toast and Tooltip stories now declare their subcomponents like every other compound family. - tabs' underline cvas renamed to match their parts (tabUnderlineBarVariants etc.); input stories drop the old "IconSlots" wording. - The index rule now names the two blessed private-file exceptions (family context modules, .shared compositions) so the next sweep doesn't trip on them. vp check, the full story suite (119 files / 544 tests), attw, and publint are all green.
The Default story ran its play in the visible story — the last one doing so; every other family splits tests into !dev/!autodocs/!manifest twins.
…ories Consumers of the components tier no longer import @base-ui/react for anything — the acceptance check is that no components story needs it. Composed ready-mades where anatomy was heavy: - select: SelectTrigger (value + chevron baked, icon overridable), SelectLabel, SelectList, SelectItem (check indicator + ItemText baked) - autocomplete: AutocompleteInputGroup (clear/trigger as role-named control props, icon slot), AutocompleteList, AutocompleteItem, AutocompleteEmpty - combobox: ComboboxInputGroup, ComboboxChips (data-driven chips with a required localizable removeLabel), ComboboxList, ComboboxItem, ComboboxEmpty - navigation-menu: full anatomy wrapped (List/Item/Trigger with the caret baked/Content/Link/Viewport) - toolbar: ToolbarToggleGroup now grafts Base UI ToggleGroup itself Propel-named passthroughs for the behavior-only roles stories still reached into Base UI for: AlertDialogTrigger/Close, DrawerTrigger/Close, ContextMenuTrigger/Separator, MenuTrigger/Group/Separator, the breadcrumb and menubar triggers, PreviewCardTrigger, and DirectionProvider (RTL apps compose from propel too). Index comments telling consumers to compose @base-ui/react directly are gone. Stories: every visible story is product-focused (render only; the last inline play — menubar — moved to a hidden twin earlier); new showcase stories cover the previously unshown visual axes (text-area magnitude/ surface/resize, number-field/select-field/field-family magnitudes, radio+checkbox group densities, context-menu tones, toggle/toggle-group/ slider/otp-field/avatar-group/scroll-area magnitudes), and components/list finally has a story (navigation + collapsible section + roving-focus twin). vp check, 120 story files / 574 tests, attw, and publint are green.
The bordered form-control frames all composed the shared field-control-surface (border/focus/danger) but re-spelled everything above it, and had drifted: input and text-area carried a hover fill, border shift, transition, and focused-hover correction that autocomplete, combobox, and number-field silently lacked; the inner inputs were spelled four ways (only the plain input had the accent caret); disabled state used :has(:disabled) in some frames and data-disabled in others. Two internal class helpers now own the shared design: - control-group: the focus-within surface + transition + hover pack + focused-hover correction + BOTH disabled selector forms - control-input: transparent field, primary text, placeholder color, accent caret, disabled cursor/text input, text-area, autocomplete, combobox (input row and chips frame), and number-field recompose from them, keeping only their own geometry (width, alignment, gap, radius, magnitude). Net effect: the pickers gained the input's hover/transition treatment and every inner input gained the accent caret. DX: ComboboxInputGroup gets an icon slot prop (new elements ComboboxIcon, mirroring AutocompleteIcon's placeholder-tint-brightens-on-focus look) so a search-style combobox composes the same way as autocomplete.
Seventeen Family-Icon parts were byte-duplicates of the node-slot chrome (seven literally bare nodeSlotClass, the rest differing only by a tint or a local node size), four spinners were identical, and the disclosure carets had re-spelled the same rotation per family — with real drift: breadcrumb implemented the same end-closed/down-open affordance on an opposite glyph convention, and the menu/context-menu submenu pointers were byte-identical twins. Following the ListboxItem precedent (rule 4a), the duplicates are gone: - internal Icon (tint/magnitude axes, both defaulting to inherit — the first sanctioned rule-12 default) replaces Button/AnchorButton/ IconButton/Badge/Pill/Toggle/ContextMenuItem/ToolbarItem/ AccordionTrigger/MenuItem/MenubarTrigger/Select/Checkbox/Input/ BreadcrumbTrigger/MenuSearch/ListItem icons. Families with genuinely distinct styling keep their part (toast/banner/alert-dialog tones, avatar's scale, autocomplete/combobox focus-brightening, navigation-menu's Base-UI-named rotating Icon). - internal DisclosureIndicator carries ONE glyph convention — pass a chevron-down; motion: disclose (end closed, down open), pointEnd (submenu), flip (dropdown) — reading data-panel-open/data-popup-open from the trigger's group. Replaces the accordion/collapsible/ list-section/breadcrumb/menu-submenu/context-menu-submenu carets; visuals unchanged. - internal Spinner replaces the four identical animate-spin slots. DX: the icon-shaped controls (IconButton, Toggle, ToolbarButton, ToolbarToggle) now wrap bare svg children in the slot themselves, so consumers write <IconButton aria-label><X/></IconButton> with no inner part. Rule 6e records all of it.
The controls disagreed on what a magnitude means: input was padding-based md/lg/xl, autocomplete and select ran sm/md/lg at 28/32/36 but differed on the text size of the SAME step (select md was text-13, autocomplete md text-14), the otp digits ran the same pixel scale one label off (32/36/40 as sm/md/lg), and combobox had no axis at all (fixed 36px). internal/control-group now exports THE scale — sm 28/text-13/14px glyphs, md 32/text-14/16px, lg 36/text-14/16px, xl 44/text-16/20px — and input, autocomplete (uniform px-3 now), select trigger, combobox (new required magnitude axis on the input group and chips frame, threaded through the ready-mades and combobox-field), and otp (relabeled md/lg/xl; the old xl grows 40->44) compose from it. autocomplete-field/combobox-field thread their FieldMagnitude straight through instead of hardcoding. Deliberately left: text-area's magnitude is a typography-only scale for a multiline control; number-field's heights stay coupled to its stepper IconButton squares.
- ToolbarInput: an inline text input in the toolbar's roving tab order (a filter box). Invented design pending a Figma spec, flagged for polish: a miniature field on the shared control surface at self focus, height tracking the toolbar's density (24/28px), text-13 with the standard placeholder color. Styled elements part + components graft + a WithFilter story so design can review it in situ. - FieldControl: Base UI's Field.Control passthrough so hand-composed fields register their control (name/validation/data-invalid) without a Base UI import. - MenuViewport: Base UI's content-morph container grafted onto the shared relative popup viewport, for menus that transition content in place. With these, every rendering Base UI part has a propel counterpart; the only unwrapped surface is the advanced escape hatches (floating-ui-react, unstable-use-media-query) by choice.
A 37-agent audit fetched each Base UI component docs page, mapped its
hero + feature examples against our stories, and filled the gaps —
~60 new stories with hidden interaction twins across the tier: async
search and grouped/fuzzy/limit/autoHighlight autocomplete (plus a
command-palette composition), detached-trigger and close-confirmation
and nested flows for dialog/alert-dialog/drawer/menu, form async/server/
schema validation, checkbox form integration and nested parent tri-state,
open-on-hover menus, toolbar links, and more. All async examples are
deterministic (in-story delays, Status live regions).
Real findings the new tests forced out:
- Form external errors deadlocked: field-level error props marked the
field invalid, Form blocks submission while invalid, and only submission
cleared the error state. FieldHelperText now leaves the Base UI error
channel open when no explicit error is passed (unmatched FieldError), so
Form errors display and clear through Base UI's own lifecycle; the
validation harnesses drop the double plumbing.
- ToolbarLink rendered a <button>: grafting Base UI's anchor part onto the
button-tag styled element loses the tag. elements/toolbar gains a real
<a> item part sharing the item chrome (rule 6c), and the ready-made
grafts onto it.
- DialogBody scroll regions need keyboard access (axe): the tall-content
story now carries tabIndex={0} with the rationale.
- SelectTrigger/Avatar/Collapsible API gaps from the audit: Select.Value
placeholder threaded through the baked trigger; Avatar/WorkspaceAvatar
gain the fallback delay prop; Collapsible passes keepMounted/
hiddenUntilFound to its panel.
Test-semantics lessons encoded in the twins: Base UI validates onBlur
only once a field is dirty; nested dialogs/alert-dialogs make the parent
popup inert (out of the a11y tree); entry/exit transitions need settling
before visibility assertions; live-region text carries an invisible
word-joiner briefly after mount (regex matchers); axe flags floating-ui
focus guards while popups are open (suppressed per-story with rationale).
Rules 2b/2c rewritten in AGENTS.md: elements stories are pure UI-config
showcases (behavior stories live in components; sweep pending), and
hook-using stories use the named-function render pattern.
Suite: 120 files / 717 tests green; check, attw, publint clean.
Every elements story is now a static styled-part showcase: parts rendered directly with every visual axis and every state pinned via the data-*/ aria attributes Base UI would set — zero @base-ui/react imports in the tier (the one family the sweep missed, input-field, done by hand). Hidden CSS canaries assert the pinned selectors actually compile; behavior plays moved to components stories where they weren't already covered (autocomplete/combobox invalid propagation, form constraint validation, list section disclosure, otp invalid, tooltip shortcut, banner dismiss, dialog non-dismissable, avatar delayed fallback) and hook stories use the named-function render pattern (rule 2c). Fixes the sweep forced out: - Tailwind v4's rotate-*/translate-* compile to the standalone CSS rotate/translate properties — canaries asserting transform read none; they now read the right property. - ContextMenuItem's selected check was invisible: the styled indicator hides its glyph unless data-selected is present, and the conditional render never set it. Real visual bug, now pinned. - Calendar's nav-button dimming keyed off :disabled, but react-day-picker sets only aria-disabled on nav — the style could never fire; the contract now keys off aria-disabled. - Static showcase a11y: multiple pinned DrawerHeaders are banner landmarks (wrapped per-mock in labeled sections); pinned-open table scroller needs tabindex; pinned hover/disabled states trip axe color-contrast on visuals it never sees live (suppressed per-story with rationale — the tertiary-on-hover-fill token is flagged for design); select twins close their popup before the axe pass so Base UI's focus guards don't trip aria-hidden-focus. API gaps the sweep reported, deferred as the follow-up punch list: the ready-made Button cannot render a custom tag (nativeButton unexposed, consumer render silently clobbered); menu/context-menu checkbox+radio rows lack ready-mades; grouped OTP slots aren't expressible; the LinearProgress label part has no components surface; DialogPopupMagnitude and FieldMagnitude per-axis types aren't re-exported. 120 files / 785 tests green; check, attw, publint clean.
…pings The fourteen light-mode neutral primitives move to the new design values (near-achromatic at the light end, darker and slightly varied in hue through the mid/dark steps), and the light text mappings step darker to match: --txt-placeholder neutral-800 -> neutral-900, --txt-disabled neutral-700 -> neutral-800. The dark and contrast blocks already sat at those mappings. Full story suite (including the axe contrast gate across every visible story) passes under the new palette. Closes #215
The last gaps between propel's components tier and Base UI's capability
surface, all pre-1.0-cheap and post-1.0-breaking:
- Button threads a consumer render into the styled element (it was
silently clobbered while the type accepted it) and exposes
nativeButton, so rendering as another tag works through the ready-made:
<Button nativeButton={false} render={<div/>}>. Story + twin assert
role/tabindex/Enter/Space/click activation.
- MenuRadioItem joins MenuCheckboxItem as a ready-made (kept-mounted
radio dot reading MenuRadioGroup state), and context-menu gains BOTH
selection ready-mades — ContextMenuCheckboxItem and
ContextMenuRadioItem. The checkbox/radio indicators get their own
visibility contract keyed off Base UI's data-unchecked (the shared
itemIndicatorClass keys off data-selected, which these parts never
receive). Stories + twins assert menuitemcheckbox/menuitemradio
semantics.
- OTPField gains groups (e.g. groups={[3, 3]} renders 123-456 with an
OTPFieldSeparator; counts validated against length).
- LinearProgress gains label — Base UI's Progress.Label grafted onto the
styled LinearProgressLabel, which previously had no components surface.
- DialogPopupMagnitude and FieldMagnitude are re-exported per rule 9.
120 files / 794 tests green; check, attw, publint clean.
The element x look 2x2 (Button / Anchor / AnchorButton / ButtonAnchor)
collapses: everything interactive here is a Base UI Button graft, so ONE
component exists per LOOK and real navigation renders an <a> through the
same API (nativeButton={false} + render={<a href/>} — Base UI announces
the control as a button while the <a> keeps native navigation).
- Button and IconButton stay; both now thread a consumer render into
their styled element and expose nativeButton.
- ButtonAnchor is renamed AnchorButton (the link-text look) and follows
Button's conventions exactly: startIcon/endIcon sized to the link's
node scale, loading spinner with aria-busy + dimmed label (new
AnchorButtonLabel part), nativeButton/render threading. The link
chrome gains the inline-flex/gap/node-size layout the icons need.
- The old <a>-element families are deleted: AnchorButton (button chrome)
is Button-with-render now, Anchor (plain link) is
AnchorButton-with-render. AsLink stories + twins on both surviving
families pin the recipe and Base UI's role contract.
- Rule 6d rewritten: suffix = the look, prefix = a content trait, and
the element is never part of the name.
116 story files / 781 tests green; check, attw, publint clean.
- @base-ui/react 1.5.0 -> 1.6.0 (one migration: OTPFieldPreview graduated to the stable OTPField export), lucide-react 1.23, tailwindcss/@tailwindcss/vite 4.3.2, playwright 1.61.1, wrangler 4.106.0, oxlint/@oxlint/plugins 1.72, typescript-native-preview refresh. catalogMode: prefer moved the shared ranges into the workspace catalog. The vitest 4.1.9 / vite-plus 0.2.1 coupling and @types/node major are deliberately untouched. - Peer deps audited for publish: react/react-dom ^18||^19 is honest (no React-19-only APIs in shipped source — verified; base-ui itself peers ^17||^18||^19, its date-fns peers are optional), tailwindcss ^4 stays the one true peer, and @base-ui/react stays a regular dependency so consumers never manage it. - CI's recurring EAGAIN (fork fails spawning attw right after the ~120-entry dts build, ~1-in-4 pass rate) gets the cheap package-side mitigation: the build script retries once in place — by the second attempt the build's worker threads are reaped. The workflow file is out of this repo auth's scope. 116 story files / 781 tests green under 1.6; check, attw, publint clean.
vp run executes package scripts without a shell, so the previous 'vp pack || vp pack' passed the retry as literal arguments and never fired. The retry now lives in scripts/pack-retry.mjs: on the runner's intermittent fork-EAGAIN (os error 11, tail of the dts build) it waits five seconds for the worker threads to reap and runs vp pack once more.
The ring hard-coded determinate (value: number) while LinearProgress and Base UI accept null. value is now number | null: while indeterminate a fixed quarter arc shows and the svg spins it (keyed off the root's data-indeterminate through a named group), with aria-valuenow unset. Indeterminate story + hidden twin mirror the linear family's.
…n CI Root cause of the persisting EAGAIN, from the run logs: CI's job runs the 783-test browser suite and starts the build ONE SECOND later, while chromium's process tree is still being reaped — the dts build's spawn burst then forks into the pressure and fails. The previous retry made it worse in exactly that window: its sleep spawned a node child, which under EAGAIN itself fails to spawn instantly, so the second vp pack launched milliseconds after the first into the same pressure (the run log shows the failure and both retries sharing one timestamp). The sleep now blocks in-process (Atomics.wait — no fork needed), the script settles 10s BEFORE the first attempt when CI is set, and it makes three attempts 10s apart, logging each.
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.
Every part in the old
uitier wrapped the Base UI primitive it styled, which fused our styling layer to Base UI's API. This rebuilds the tier so each part is a plain styled intrinsic element (useRender+ a cva) importing nothing from@base-ui/reactbeyonduseRender/mergeProps. Base UI (andbase) behavior grafts onto the styled element incomponentsviarender— behavior part outer, styled part as the render target — andcomponentsnow owns every Root, provider, portal, context, and default.Since the tier is just styled elements now, "ui" stopped describing it, so the tier is renamed to
elements:src/elements,@plane/propel/elements/*subpaths,Elements/story titles.Along the way:
internal/primitives (positioner, backdrop, arrow, popup-viewport, overlay-title, overlay-description, listbox-popup, listbox-item)base/text-areais gone (<Field.Control render={<textarea />}>covers it);base/compositestays, grafted incomponents/listToolbarItemIconnow, so its formatting bar renders the same as the Toolbar story (and the Figma comment box)vp checkis clean and the full browser suite passes (119 files / 535 tests).