v0.4.0
Astryx 0.4.0 — all @astryxdesign/* packages ship at this version.
npx astryx upgrade --apply@astryxdesign/core
Breaking Changes
- DropdownMenu's two item modes are peers again. Compound mode gains a
DropdownMenuDividercomponent (aliased asContextMenuDividerandBreadcrumbMenuDivider), which the data path also renders, so{type: 'divider'}and<DropdownMenuDivider />produce identical DOM, spacing, and theme target. Data mode gainsendContentanddescription, so anitemsrow can carry a shortcut hint or secondary text without dropping to compound mode. Itslabelwidens fromstringtoReactNode, matching compound mode: the narrowing existed only because rows were keyed by label, and they no longer are (#4953).
The bare names now belong to those components, so the data-mode option types take theDatasuffix their siblingDropdownMenuItemDataalready carries:DropdownMenuDivider→DropdownMenuDividerData,ContextMenuDivider→ContextMenuDividerData,BreadcrumbMenuDivider→BreadcrumbMenuDividerData. TypeScript cannot re-export a value and a type under one name from a single barrel, so the rename is what makes the components exportable at all. Runastryx upgrade --applyto rewrite the type imports; a missed one fails at compile time rather than silently. - Remove the
dropdown-menu-radio-dottheme target. Menu radio rows draw the shared radio indicator now, so the dot is the indicator's dot: targetradio-indicator-dot(the legacyradio-dotname still matches it too). The row's circle keeps itsdropdown-menu-radiotarget, so only the dot moved. (#4890)
Runtime themes are not validated — a theme keyed on the removed target keeps compiling and silently stops matching — soastryx upgradenow carriesrename-dropdown-menu-radio-dot-target, which rewrites the key and theastryx-dropdown-menu-radio-dotclass. The new target is app-wide rather than menu-only (there is no menu-only dot element left to address), so the codemod leaves a TODO at each site it rewrites. - useTableRowExpansion is now a detail-panel plugin: it expands a full-width panel below a row via renderExpanded(item), and useTableRowExpansionState is removed. For hierarchical/tree tables (child rows that reuse the parent columns), migrate to useTableTreeData + useTableTreeState. See the migration example on the useTableRowExpansion docs. (#4609)
Codemod:npx astryx upgrade --applyrunsmigrate-table-rowexpansion-to-tree, which rewrites tree-modeuseTableRowExpansioncall sites ontouseTableTreeData+useTableTreeState.
New Features
-
Avatar: the fallback surface (initials and default icon) is now a direct theme target via the stable
astryx-avatar-fallbackclass. Theme its background, text color, font weight, and per-size font size through theavatar-fallbackcomponent key (e.g.components: { 'avatar-fallback': { base: { backgroundColor: '...' }, 'size:sm': { fontSize: '...' } } }), replacing the internal--_avatar-fallback-*derived vars. (#4716) -
CodeBlock: the built-in copy button is now a themeable ghost
IconButtonwith a default "Copy code" tooltip, reachable via the stableastryx-codeblock-copy-buttonclass (theme it through thecodeblock-copy-buttoncomponent key). Restyle or keep the copy control without turning it off and re-implementing it. The tooltip stays "Copy code" after copying — the copy→check icon flip is the confirmation. (#4867)
[feat] NewuseClipboardhook (@astryxdesign/core/hooks): the shared copy-to-clipboard behavior — clipboard write, a transientisCopiedflag with its reset timer, and an optional polite screen-reader announcement. CodeBlock and Timestamp now build their copy buttons on it; reach for it directly for copy affordances that are not a plain icon button. -
CodeBlock: add
codeblock-headerandcodeblock-titletheme targets on the header row and the title/language-label element. A theme can now restyle the header (e.g. padding) and the title (e.g. font size) directly, instead of reaching them through structural> div:first-child > div > spanselectors that reverse-engineer the header layout. Both reflect thesize/language(/container) visual props like the root. (#4943) -
DateInput, DateRangeInput, and DateTimeInput now accept a
weekStartsOnprop that sets the first day of the week in the calendar popover (0 = Sunday … 6 = Saturday, or a three-letter day name like"mon"). It forwards to the underlying Calendar, whose default stays Sunday, so existing usage is unchanged. (#4745) -
Selector, MultiSelector and Typeahead expose their empty ("No results found") state as a themeable target (#4756, #4862) —
astryx-selector-empty-state,astryx-multi-selector-empty-stateandastryx-typeahead-empty-state. Themes can restyle the empty state without the fragile structural selectors consumers previously had to reach for. (The Selector search field is a TextInput, so its placeholder is reachable today via.astryx-text-input::placeholder; a Selector-scoped placeholder seam would require a TextInput change and is left as a possible follow-up.) -
EmptyState: add
empty-state-titleandempty-state-descriptiontheme targets on the title heading and the description. A theme can now restyle the title and description directly (e.g. font size, color, pervariant) instead of reaching them through structural> div:has(> :is(h1..h6))selectors that reverse-engineer which element is which. (#4942) -
Every clearable input now renders its clear (✕) affordance through the shared
InputClearButton, so the glyph is themeable in one place via theastryx-input-clear-icontarget instead of a per-component target or a fragile descendant selector. The component-specificastryx-{date-input,date-range-input,selector,multi-selector}-clear-icontargets still render for a deprecation window — migrate toinput-clear-icon. The clear glyph is now a consistent secondary-color icon with a ghost-button hover affordance across the whole family. (#4876) -
The input family (TextInput, NumberInput, DateInput, DateRangeInput, DateTimeInput, TimeInput, TextArea, Tokenizer) now reflects its disabled state on the root theming target as
data-disabled="disabled"plus a.disabledvariant (only when disabled), so a theme can gate its own hover/border treatment on the disabled state — mirroring the existingstatus/sizereflection — instead of relying on structural:has(input:disabled)CSS. This closes a documented theming gap for downstream consumers. (#4794) -
useLayer takes an
offsetfor clearance from the anchor, derived from the resolved placement, and the layer wrappers stop hand-rolling it (#4803) -
DropdownMenu rows take two new options (#4953).
DropdownMenuItemtakeshasCloseOnSelect, so a plain action can report its result on the item instead of closing the menu.DropdownMenuItemDataandDropdownMenuSectiontake an optionalid, the row's stable React key for a menu whose items reorder or filter (also reaching MoreMenu, ContextMenu and Breadcrumbs, which share the type). -
DropdownMenuItem now accepts a
variantprop ('default' | 'destructive');'destructive'renders the label, description, and icon in the error color for dangerous actions like Delete. The data-drivenitemsAPI accepts the samevariantfield, and because ContextMenu shares the menu-item data shape, context-menu items get it too. Defaults to'default', so existing menus are unchanged. (#4753) -
MultiSelector: dropdown option rows are themeable through a single (#4628)
multi-selector-optiontarget, carrying the row'ssizeand itsselect-all,selectedanddisabledstates — so a theme can express "selected option at large" or restyle just the Select All row. Row typography moved from the label span onto the row, so one override reaches both the fallback label andrenderOptioncontent; custom option content now inherits the row's font and disabled color. -
NumberInput: render a text-backed spinbutton that supports formatted display values, explicit wheel and keyboard stepping, and opt-in trailing increment/decrement buttons. Existing wheel stepping remains enabled by default and can now be disabled with
isWheelEnabled={false}(#4896). -
ComplexSelector and MultiSelector: add
astryx-complex-selector-popupandastryx-multi-selector-popuptheme targets on the popup surface, so a theme can style the popup — background, border, radius, elevation, padding — throughdefineThemeinstead of a structural selector or a fork. Both components already targeted their trigger but nothing in the popup, which is the part that has to match the rest of an app's menus. The target sits on the popup's content box rather than the layer element:useLayerzeroes the layer's borders, padding and background, so the content box is the surface that actually paints. Purely additive — default rendering is unchanged. (#4991) -
TextInput, TextArea, NumberInput: add
isReadOnly. The value is shown at full opacity and still submits with the form, but cannot be edited — the "visible, locked, still sent" case thatisDisableddeliberately does not cover, since disabled controls are excluded from submission. Read-only fields are not dimmed and stay in the tab order, matching the nativereadonlysemantics they compile to;isDisabledtakes precedence when both are set, and the clear button is hidden while read-only. The state is reflected on the root theming target asdata-readonly="readonly", alongside the existingdata-disabled, so a theme can paint it without structural:has(input:read-only)CSS.isReadOnlyalready existed on CheckboxInput, CheckboxList, and PowerSearch; the remaining text-ish inputs (DateInput, DateRangeInput, DateTimeInput, TimeInput, Tokenizer) do not have it yet. (#4816) -
Selector/MultiSelector: two additive theming seams (#4626, #4627). A
selector-checktheme target on the selected-row checkmark lets themes restyle or hide it (e.g. to compose their own selected indicator viarenderOption) instead of relying on a structural sibling selector, anddata-disablednow reflects on the trigger for theme-driven disabled styling. Rotation styles remain on the indicator-icon target. Default appearance is unchanged. -
Table:
contextMenuActionsnow accept avariant: 'destructive'for dangerous row/column actions (e.g. Delete), rendered in the error color to match ContextMenu. (#4864) -
TextArea: theme the text inset by writing
paddingInlineon thetextareacomponent key — it now drives the internal--_textarea-inline-paddingvar instead of landing on the wrapper. The wrapper stays flush (padding: 0), so the native resize grip keeps its true-corner position and the start icon, status, and character counter stay aligned to the text. Adds areplacesoption to derived var entries for the general "map a property onto a var without emitting it on the class element" case. (#4793) -
Add themeable indicators — the componentized check, checkbox, and radio visuals.
defineTheme({indicators: {check: RadioIndicator}})replaces one by name, and every component drawing it follows. (#4712)
Theme targets now follow the component-name convention:checkbox-indicator,radio-indicator,radio-indicator-dot. The old names (checkbox,radio,radio-dot) are still emitted on the same element, so existing themes keep working — migrate at your convenience; they go away in the next major.Migration: menu radios use those shared targets now.
dropdown-menu-radio-dotis removed — targetradio-indicator-dot;astryx upgraderewrites it for you. -
TreeList: two additive changes. (1) A fully flat tree — one with no expandable items at all — now renders its rows flush instead of reserving an empty chevron-alignment column that nothing lines up under; any tree that has at least one expandable item keeps the same per-level alignment as before, so only fully flat trees change shape. (2) Adds a themeable
--tree-list-row-gapfor the inter-row gap, defaulting to a subtle2px(var(--spacing-0-5)) separation — matching the inter-row gapListandDropdownMenualready ship — so this shifts the default spacing of every tree by that amount; set it on thetree-listtarget to widen or close it. The gap rides collapse-proofpadding-blockon the row wrapper (not the paintabletree-list-itemtarget), and the connector guides span it automatically without overhanging the last row. (#4540)
Fixes
-
AlertDialog: correct the inline role and pin initial focus (#4887).
TheisInlinepreview path no longer rendersrole="alertdialog". That role promises a modal interruption — focus trap, inert page, explicit dismissal — and the inline path is an always-present, non-modal preview with none of it. It now rendersrole="group", keeping the title and description associated througharia-labelledby/aria-describedby.The cancel button now carries
data-autofocus, so the documented "initial focus goes to the cancel button" behavior is pinned instead of depending on cancel happening to be the first focusable node in the footer. Docs now name and link the WAI-ARIA APG Alert Dialog pattern the component implements, and gain an anatomy section. -
AppShell: two a11y fixes to the shell chrome (#4944).
The mobile top bar rendered for a sidenav-only layout is now abannerlandmark, matching the header region of a layout that has atopNav. Previously the page's landmark structure changed depending on which nav slots it filled: a screen-reader user on a small viewport got no banner region at all. When abannerslot is present the existing header keeps the role, so there is still exactly one.The skip link now draws the shared Astryx focus ring instead of the browser default outline, so it follows
--color-accentand matches every other focusable surface in a custom theme. -
Avatar: fallback initials no longer break for names containing emoji or other multi-codepoint characters. (#4750)
-
ChatLayoutScrollButton: the default (label-less) state now renders as icon-only, with the translated "Scroll to bottom" string as the accessible name only. It was previously missing
isIconOnlyon its innerButton, so Button's default (visible-text) contract rendered the translation as clipped visible text inside the circular button instead. (#4854) -
Chat: the dictation and scroll buttons now carry their
chat-dictation-buttonandchat-layout-scroll-buttontheme targets, and ChatSendButton no longer clobbers a consumer'sclassName(#4634). -
ChatToolCalls: hover backgrounds on grouped call rows (2+ calls) now keep their full
--radius-elementrounding instead of getting clipped flat on the inline edges.groupContentInner— theoverflow: hiddenclip boundary the expand/collapse height animation needs — was missing the padding/negative-margin pair that absorbs the row-level hover-background overhang, so the overhang extended past the clip boundary and got cut off. Matches the ungrouped single-call row, which has no such wrapper to clip it. (#4858) -
ComplexSelector's popup now keeps its 4px clearance from the trigger whenplacement="above", matchingplacement="below"andPopover. The popup's margin was set onmarginBlockStartonly, which is correct for a popup opening downward but leaves zero clearance on the edge that matters when it opens upward. (#4861) -
ComplexSelector: the trigger's focus ring is now keyboard-only. It was drawn from
:focus-within, which also matches a mouse click — open the popover with the mouse, dismiss it with the mouse, and the restored focus left a pointer user staring at a keyboard affordance. It now uses the shared:has(:focus-visible)ring, which also brings the outline to the documented 3px offset. (#4935) -
DateInput and DateTimeInput no longer steal focus when the open calendar is dismissed by clicking another control. Clicking the field to open the calendar, then clicking the time input (DateTimeInput) or any other element, kept yanking focus back to the date input because the popover's close handler always refocused it. It now restores focus only when the dismiss left focus detached (Escape, or a click on empty space), so a click that lands focus elsewhere is respected. (#4974)
-
TextInput, TextArea, NumberInput: a disabled field is no longer submitted with the form when
disabledMessageis set. Showing the reason tooltip requires swapping the nativedisabledattribute foraria-disabled+readOnly, so the message stays discoverable by pointer and keyboard — but read-only fields still serialize intoFormData, and these three kept theirname, so a locked field posted its value. They now withhold thenamewhile disabled, matching CheckboxInput and Switch (which forward the name only when enabled) and the hidden-input carriers in Selector, MultiSelector, Slider, and Tokenizer (which mirrordisabled). Adds form-participation coverage to all three so the guarantee is pinned. (#4811) -
DropdownMenu/MoreMenu: opening with a pointer no longer highlights the first item as if it were selected (#4477). Initial focus now follows the input modality: keyboard opens (Enter/Space/ArrowDown on the trigger) still focus the first enabled item per the APG menu-button pattern, while pointer opens focus the menu container itself so the first ArrowDown moves to item 1. Synthesized clicks (detail 0, e.g. screen reader activation) and programmatic controlled opens keep the first-item focus behavior. Covers data-driven items mode, compound mode, and MoreMenu, which share the open path.
-
EmptyState: the rest spread sits before the contract
role, so a consumer can no longer clobber the landmark role the component guarantees (#4826). -
Indicator: a falsy
childrenno longer deletes the state mark. The busy idiom a host actually writes —children={isBusy && <Spinner/>}— passesfalsewhen it is not busy, andfalseis neithernullnor caught by??, so all three indicators took the children path, rendered nothing in it, and dropped the checkmark, the checkbox tick and the radio dot on every selected row. They now useisRenderable, so only children that actually render replace the mark.0still counts as content, since it renders the character "0". (#4913)
CheckIndicator's children slot also reserves the glyph's box and carries its color, so swapping a Spinner in no longer shifts the row or loses the disabled shade.Fixes #4893.
-
Consolidate general interactive focus outlines onto one definition — 2px
--color-accentat 3px offset, matching Design Conventions. (#4654)
Most general controls had drifted to a 2px offset; Button, Calendar, Dialog and Pagination were the ones still on spec. Their value wins, so a focus ring on the drifted components (Link, TabList, Token, TreeList, SegmentedControl, TopNav items) now sits 1px further from its control.Destructive buttons keep their error-colored ring, and
--button-focus-offsetis unchanged. Form and input focus treatments are out of scope. -
<Heading type="display-N">now sizes correctly under every theme, matchingText's behavior.generateTypeScaleComponents()only emittedlevel:N-keyed CSS rules forheading, with notype:display-Ncounterpart — so as soon as a theme suppliedtypography.scale, the generated theme-layer CSS'slevel:Nrule was the only one present and silently won regardless oftype, discarding the prop. A theme with no typography config was unaffected, which made the bug look intermittent. (#4859) -
theme:
color.contrast: 'high'now strengthens border tokens too — the emphasized border tone is pulled toward mid-scale (stronger against both light and dark surfaces) and the subtle hairline's alpha is doubled, so structural boundaries stay perceivable in high-contrast themes instead of only text/icons changing. (#4529) -
Icons render through
<Icon>and carry their component's theme target (#4838).
Styling-only wrappers around rotating icons are gone, and each rotation now sits on the icon element that already carries the component's theme target — so a theme reaches the glyph and its open/closed transform through one selector. No new theme targets: Selector, MultiSelector and ComplexSelector consolidate onto their existing*-indicator-icontargets, and the Table plugins and TreeList simply shed redundant wrapper elements.Where an RTL mirror sat on a separate parent element, it is folded into each state's transform (
scaleX(-1) rotate(...)) so one element carries both. In the Table plugins that mirror was inert —transformdoes not apply to a non-replaced inline box — so RTL disclosure chevrons now mirror correctly where they silently did not before.Registry glyphs in SideNav, TopNav, Collapsible, TreeList and Breadcrumbs now render through
<Icon>instead ofuseIcon()inside a hand-written<span>. Those spans were a weaker reimplementation of<Icon>, which already resolves the same glyph and renders a span carrying mergedclassName/style/xstyleplus theastryx-icontheme target. The converted sites gain that target, and the node count is unchanged.useIcon()keeps its place for the cases that resolve a glyph without rendering it: MoreMenu and ChatSendButton pass the node as a default for a consumer-overridable prop, which<Icon>cannot express.Also adds the
@astryx/no-wrapper-transformlint rule (warn) for<div>/<span>wrappers that exist to transform the icon inside them. -
Indicator: a caller can no longer un-hide or focus a decorative indicator (#4921, #4947).
IndicatorPropsnow omitsaria-hidden,role,aria-label,aria-labelledbyandtabIndex— passingroleortabIndexis a compile error — and each indicator emits its ownaria-hiddenafter{...rest}, so a forwarded one cannot win. Un-hiding an indicator had it announced next to the control that owns the accessible name, saying the same thing twice; a tab stop on one is a focusable node inside a hidden subtree, an axearia-hidden-focusviolation.Nothing is stripped: every other prop, including a forwarded
aria-label, still reaches the DOM, where it is inert inside anaria-hiddensubtree. Note that TypeScript exempts hyphenated JSX attributes from excess-property checking, so the type alone cannot rejectaria-*; the attribute order is what enforces it.tabIndexis a plain identifier, so its omission stands on its own.Also corrects two doc claims: a replacement must render
childrenwhen they will actually draw something (isRenderable, notchildren ?? mark), and "passingroleis a compile error" holds for a literal attribute — a spread bypasses excess-property checking.Fixes #4918.
-
A DropdownMenu item closes the menu on activation even when it carries no
onClick, and a data-mode row that changes its own label keeps its identity instead of remounting and dropping focus (#4953) -
Fix
mergeRefscleanup so object refs are cleared and callback refs without
cleanup functions still receivenullwhen a merged ref returns cleanup (#4901). -
MoreMenu forwards
placementandalignmentto its DropdownMenu. Both were part of the underlying menu's API but were dropped on the floor by the wrapper, so an overflow menu — the one component whose job is a trailing-edge affordance — could not ask to be end-aligned; it only looked right when the layer happened to collision-flip. Defaults are unchanged: MoreMenu passes the props straight through, so DropdownMenu's'below'/'start'still apply. (#4952) -
Pagination: vertically center the prev/next caret icons. The RTL mirror wrapped each chevron in a
display: contentsspan, which dropped the icon out of the button's flex-centering context so the glyph sat a few pixels high. The mirror transform now rides on theIcondirectly viaxstyle, so the icon stays a centered flex child and still flips under RTL — no wrapper element. (#4723) -
ProgressBar: a theme can size the target mark again without
!important. The mark'swidth/heightwere plain StyleX declarations, so aprogressbar-markoverride only landed where@layer astryx-themeoutranks the component atomics — in a source-build app that compiles StyleX withoutuseCSSLayersthe atomics are unlayered and beat every theme rule, leaving no way to resize the tick but an unlayered!importantrule. The dimensions now travel as derived vars with no competing declaration, so the samedefineThemeentry lands in either build. Theme authoring is unchanged; a mark's color is still a plain declaration and still depends on the layer order. (#4970) -
ProgressBar marks take their color from what they sit on: the fill variant's on-color inside the filled area, the emphasized divider color out on the track (#4741)
-
CommandPalette, ComplexSelector and ContextMenu: a consumer's
onClick/onMouseEnteris composed with the component's own handler instead of being overwritten by it, and{...props}no longer lands after the props the component must control (#4725). -
CheckboxInput, Switch: a
requiredcontrol that is disabled with adisabledMessageno longer blocks the whole form from submitting. Showing the reason tooltip swaps the nativedisabledattribute foraria-disabled, which leaves the control subject to constraint validation — so an unchecked required checkbox (or an off required switch) the user has been told they cannot touch made the form permanently unsubmittable, with the browser reporting a validation error against a control they had no way to satisfy. Both now detach from the form viaform=""while focusable-disabled, matching a natively disabled control and the treatment RadioListItem already applied. Enabled controls are unaffected — a required, unchecked checkbox still blocks submission as it should. (#4815) -
useScrollLock: coordinate concurrent locks with a shared counter, so overlays closing out of order no longer unlock the body early or leave it stuck locked. (#4788) -
Selector: keep the selected option text aligned with the closed trigger across every menu position by measuring untransformed layout geometry during the popover entry animation. (#4802)
-
Drop the shared trigger-icon wrapper in Selector, MultiSelector and ComplexSelector — each trigger icon is now the element that carries its own box, colour and theme target. (#4846)
The wrapper set a 16px box and--color-icon-secondaryon a span with no theme target of its own, shared by two different affordances: the status glyph and the disclosure chevron.<Icon>already provides both (size="sm"is the same 16px box,color="secondary"the same token), so the wrapper only stood between a theme and the icons — and made the two affordances share a node they never should have shared. -
Selector selects by typing, matching a native select (#3764)
Typing a printable character on a focused, closed Selector now selects the matching option — tab to a state picker, press "C", get "CA" — instead of doing nothing until the menu is opened. Repeated presses cycle through options sharing a first letter, and spaces count as match characters ("new y" reaches "New York"). With the menu open, typing moves the highlight and Enter commits, as before. WithhasSearch, typing on the closed trigger opens the popup and seeds the search input.Matching reuses the shared
useTypeaheadhook, so Selector behaves like the other collections (menus, listboxes). Because a match committed from the closed trigger changes the value without opening the popup or moving focus, the new selection is announced throughuseAnnounce.useComboboxno longer implements typeahead itself; callers that want it composeuseTypeaheadand run it ahead of the combobox key handler.Adopting the shared hook exposed two matching bugs in it, fixed here — so
DropdownMenu,ContextMenuandNavHeadingMenuimprove too. A single-character search now starts after the current item, as native<select>and the APG pattern do, instead of only advancing on a repeated press: pressing a letter that the focused item already begins with used to do nothing at all. And with nothing focused the search now genuinely starts at the top, rather than wrapping onto the last item first. Characters composed with Option/Alt (Option+a→ "å") count as typeahead again, so accented labels stay reachable. -
SideNav:
footercontent now centers when the nav is collapsed, matching howchildrenalready centers.stickyBottomCollapsed(the collapsed-rail wrapper forfooter) was missingalignItems: 'center', which its siblingscrollableCollapsed(the collapsed-rail wrapper forchildren) already had — so full-width footer content (e.g. an icon-only button) stretched to the collapsed rail's width instead of centering. (#4852) -
SideNav: the collapsed icon-only
SideNavHeadingtrigger with amenuno longer omits its popover's anchor. The trigger's ref callback wasn't forwarding tousePopover'striggerRef, so the menu popover had no CSS anchor to position against and fell back to the viewport corner instead of opening next to the trigger. (#4850) -
Stepper: localize the "Optional" step affordance via the new
@astryx.step.optionalmessage key so it translates like the rest of the component. No visual change in English. (#4872) -
useStreamingTextno longer renders a broken glyph (a lone surrogate, or a partial ZWJ emoji sequence) for one frame when its fixed-code-unit reveal cadence happens to land inside a surrogate pair or multi-codepoint emoji. The rendered slice now snaps back to the nearest grapheme cluster boundary viaIntl.Segmenter(with a surrogate-pair-safe fallback where it's unavailable); the reveal cadence itself is unchanged. Also corrected the hook's doc comment, which inaccurately described the cadence as advancing on word/syntax boundaries — it always advanced by fixed code units. (#4866) -
TextArea: no longer reserves trailing space for the on-field status icon when
statusVariant="detached". The detached variant surfaces its status glyph in the message box below the field and renders no on-field icon, so the reserved inset pushed the text in for an icon that never appeared. Trailing space is now reserved only when the spinner or on-field status icon actually renders. (#4940) -
TextArea: remove the duplicate wrapper padding so the text and native resize grip sit flush to the edge. The wrapper's
padding: 0shorthand was being overridden by the shared input-wrapper longhands, leaving the inset applied twice; it now zeroes with matching longhands. (#4813) -
TopNavMegaMenu: fix the hover-then-click flicker where clicking a nav item after hovering dismissed the mega menu. The trigger is registered as the native invoker for its
popover="auto"panel and uses a Vercel-style hover→click guard, so the click that naturally follows a hover confirms and pins the panel open instead of toggling it shut. Native outside-click, Escape dismissal, and sibling-popover exclusivity are preserved. Click/keyboard opens are pinned (persist past mouse-leave); hover opens stay transient. Keyboard activation (Enter/Space) always opens and moves focus into the panel, while touch/click without a preceding hover toggles cleanly (#3121) -
TreeList typeahead now cycles through same-letter matches instead of stalling, and searches from the top when no treeitem is focused. (#4844)
-
BaseTypeahead(and everything built on it —Typeahead,Tokenizer,PowerSearch's content-search field) no longer misinterprets the Enter keydown that commits an IME composition (Korean/Japanese/Chinese input) as "accept the highlighted suggestion". Previously that keydown both selected the highlighted result and cleared the input, so the still-composing syllable landed in the freshly-cleared field and became its own spurious second selection on the next Enter. Also guarded the Enter-to-save handler inPowerSearchEditPopover, which had the same gap when typing a CJK filter value. (#4860) -
useLongPress: cancel the pending long-press when a second finger joins mid-press. Previously
onTouchStartandonTouchMoveonly checkedtouches.lengthon their own event, so a second finger arriving after a single-finger press had already started the timer (e.g. a pinch-to-zoom gesture) fell through thetouches.length !== 1guard without ever clearing it —onLongPresscould still fire with the stale first-finger point mid-gesture. No API change. (#4735) -
useContainerReveal scopes the reveal by inheritance instead of a marker pool: no dev warnings on lists longer than six rows, and isEnabled now takes effect after mount (#4955)
Documentation
- AppShell: the two worked examples of the
mobileNavescape hatch passedtitletoMobileNav, which does not accept it:MobileNavPropsomits the nativetitleattribute and the drawer heading prop isheader. Copying either example produced a type error and a drawer with no heading. Both now sayheader. The doc also gains an anatomy list and accessibility guidance covering the landmark structure AppShell owns. (#4944) - AspectRatio: document the sizing contract and add the missing anatomy. The box takes its width from its container and derives its height from the ratio, so constraining only the height clamps it off ratio (pass
width: 'auto'alongside) and a shrink-to-fit parent collapses it to zero width. Both are now in the component JSDoc and inbestPractices, along with the single-child expectation: withfitset, every direct child is stretched to fill the box. The image-gallery example block now usesvar(--radius-element)instead of a raw8. (#4984) - StatusDot: document the builder's accessibility responsibilities in the usage dos and don'ts. A color-only dot is not fully accessible in isolation, so the guidance now says to use it as a binary present/absent signal, pair it with a label, carry the status as a shape via an icon, and — if neither fits — convey the status through an accessible alternative. (#4737)
Other Changes
DropdownMenuItemData— the shape of one entry in aDropdownMenu/ContextMenu/MoreMenuitemsarray — is now sourced fromDropdownMenuItemProps(Pick) instead of restatingicon,onClick,isDisabled, andvariantby hand, andrenderDropdownItemsforwards the whole item toDropdownMenuItemrather than copying it field by field. The data and compound APIs describe the same item, so they can no longer drift — exposing another item prop to the data API is now one key in thePick. The type is structurally identical to before (labelis still narrowed tostring, since the renderer keys rows by it) and rendering is unchanged. (#4809)- Remove 15
<div>/<span>wrappers that existed only to style the single Astryx component inside them (Carousel, Lightbox, MobileNav, Pagination, Switch, TopNav, TopNavMegaMenu, Table row-expansion menu icon); the styles now sit on that component's own root viaxstyle— or, for Pagination's page-size Selector, its documentedwidthprop. No API change, but the rendered DOM has one fewer node at each site, so anything selecting on that structure is affected:patch, not[breaking], because the removed nodes were internal implementation with no documented contract, no theme target, and no stable class. Two rendering defects the wrappers were causing are fixed as a side effect: the Lightbox prev/next chevrons and the Pagination first/last chevrons were 2.5-3px off their button's vertical centre. (#4775)
@astryxdesign/cli
Breaking Changes
- DropdownMenu's two item modes are peers again. Compound mode gains a
DropdownMenuDividercomponent (aliased asContextMenuDividerandBreadcrumbMenuDivider), which the data path also renders, so{type: 'divider'}and<DropdownMenuDivider />produce identical DOM, spacing, and theme target. Data mode gainsendContentanddescription, so anitemsrow can carry a shortcut hint or secondary text without dropping to compound mode. Itslabelwidens fromstringtoReactNode, matching compound mode: the narrowing existed only because rows were keyed by label, and they no longer are (#4953).
The bare names now belong to those components, so the data-mode option types take theDatasuffix their siblingDropdownMenuItemDataalready carries:DropdownMenuDivider→DropdownMenuDividerData,ContextMenuDivider→ContextMenuDividerData,BreadcrumbMenuDivider→BreadcrumbMenuDividerData. TypeScript cannot re-export a value and a type under one name from a single barrel, so the rename is what makes the components exportable at all. Runastryx upgrade --applyto rewrite the type imports; a missed one fails at compile time rather than silently.
New Features
-
Add the
migrate-table-rowexpansion-to-treecodemod (runs onastryx upgrade): rewrites the removeduseTableRowExpansionStatetree pattern touseTableTreeState+useTableTreeData. Detail-panel usage (renderExpanded) is left untouched. (#4884) -
Add a self-documenting layer to the CLI: typed, colocated
.doc.mjsfor every command, every@astryxdesign/cli/apifunction, and every authored schema (config, integration, codemod, the response envelope, and the doc-types themselves). Adds theFunctionDoc,SchemaDoc,CommandDoc, andEnumDocauthoring types with sealed parsers. (#4714)
Every command's--helpand itsastryx manifestentry are now built from that command's colocatedCommandDocvia adefineCommandconverter, so the docs and the CLI can no longer describe different things. The migration is behavior-preserving: help text, command output, error paths, and exit codes are byte-identical.The CLI README's command, error-code, and response-type tables are now generated from the manifest and the
EnumDocs, correcting real drift — the error-code table listed two codes that do not exist and omitted several that do, and the command table was missingblog,build,layout, andvalidate-integration.Kept honest by a drift harness (docs vs the live CLI),
check:cli-structure(each doc-type andapi/leaf ships its full file set), and lint rules for the CLI's layering. -
Add themeable indicators — the componentized check, checkbox, and radio visuals.
defineTheme({indicators: {check: RadioIndicator}})replaces one by name, and every component drawing it follows. (#4712)
Theme targets now follow the component-name convention:checkbox-indicator,radio-indicator,radio-indicator-dot. The old names (checkbox,radio,radio-dot) are still emitted on the same element, so existing themes keep working — migrate at your convenience; they go away in the next major.Migration: menu radios use those shared targets now.
dropdown-menu-radio-dotis removed — targetradio-indicator-dot;astryx upgraderewrites it for you.
Fixes
-
The generated agent cheat sheet hardcoded a shell recommendation ("Full page → AppShell; sidebar nav → SideNav", "pick the shell (AppShell / Layout+LayoutPanel)"), which answers a question that depends on the app archetype and duplicates guidance
astryx docs layoutalready maintains. The two layout rules now send agents to that doc instead, so shell choice, region budgets, and the responsive contract have one source of truth. (#4772)
The rule cites the command rather than the docsite URL, in the block's establishedastryx <cmd>form that the header maps to the project's real invocation (pnpm exec astryx,npx @astryxdesign/cli, …).astryx docsreads the docs shipped inside the installed version, so an agent can't be shown an API that release doesn't have. -
The
migrate-grid-minchildwidth-to-columnscodemod bailed without changes when a<Grid>had bothcolumnsandminChildWidth, leaving the now-invalidminChildWidthprop in place and failing type-checking on 0.3.0. (#4792)
Whencolumnsis a numeric literal, it now migrates losslessly to the 0.3.0 object form. This mirrors the old (0.2.0) Grid runtime, whereminChildWidthdominated and the numericcolumnscapped the column count underauto-fit:<Grid columns={3} minChildWidth={280}>becomes<Grid columns={{minWidth: 280, max: 3, repeat: 'fit'}}>. Object or dynamiccolumnsvalues remain a deliberate bail. -
The documented
hookexample referenceduseToggle, which is not a hook in the design system — running it failed withERR_UNKNOWN_HOOK. It now usesuseFocusTrap. (#4742)
This shipped in two places a consumer sees:astryx manifest --json, which agents read to learn the CLI, and thehookCommandDoc that feeds--help. Replaced in both. -
CLI internals: a true
foundation/bottom layer, and generated./authoringtypes (#4736).
foundation/no longer importsapi/, and ESLint now enforces that direction alongside the existingauthoring/andapi/rules. Two things were reaching upward:Projectpulled template discovery out ofapi/template, whose adapter importedProjectstraight back, and bothProjectandintegration-warningsimportedvalidateLoadedIntegrationfrom thevalidate-integrationcommand. Neither was misplaced logic, just misplaced files — the adapter now lives atfoundation/discovery/template-adapter.mjsand the validators atfoundation/integrations/validate-contributions.mjs. To be precise:Projectand the template adapter still import each other, so that module cycle remains, contained within foundation instead of spanning two layers. Behavior-preserving — the CLI's observable surface is byte-identical across 84 invocations.The published
./authoringtype declarations are now generated from their JSDoc instead of hand-written, the same way./apialready works. The 13 hand-maintained.d.mtsfiles are gone;scripts/sync-api-types.mjsemits both trees atprepack, stamped@generated. A hand-written declaration shadows the JSDoc in its.mjs, so it could disagree with the implementation and still compile — and both failure modes had shipped: a missing declaration made a strict consumer resolve that parser asany, and a staleparseDocreturn union silently droppedSchemaDoc,CommandDocandEnumDoc. Also fixesparseFunction, a bare re-export ofparseHookthat publishedHookDocinstead of the generalFunctionDoc. -
Scaffolding a template that references demo video (e.g.
LightboxVideo) no longer replaces the video source with the image placeholder data URI, which the generated<video>element couldn't play.stripTemplateAssetRefs()treated every demo-media reference as an image regardless of extension; video extensions (.mp4,.webm,.mov,.ogv) are now stripped to an emptysrcinstead — there's no equivalent self-contained inline placeholder for video, so the scaffolded example is honest about needing the builder to supply their own file rather than pointing at something that can't play. (#4863) -
Stepper templates: the scaffolded Stepper blocks gain the a11y, theming and responsive-label hardening from the component audit, and their doc blocks match what they render (#4917).
-
cli: add
theme build --icons-specifierso the generated module's icon import can be fully specified (#4620)
The generated theme module imports the icon registry rather than inlining it, because the registry holds React elements.astryx theme buildscraped that specifier out of the TypeScript source and emitted it verbatim, so./icons— valid TypeScript, invalid ESM — reached the generated.js. Every published theme's/builtentry therefore failed to load in Node, including under Vite SSR and Next.js Pages Router, while bundlers papered over it by guessing the extension.No single extension is correct: the same source compiled by tsup lands at
icons.mjsin a package with no"type"field and aticons.jsin one with"type": "module", and the generator runs before the compile step that produces either. The caller knows; now it can say so. Without the flag the specifier is emitted unchanged, so the default no---outflow — where the neighbour is an uncompiledicons.tsxthat only a bundler can resolve — is unaffected.The seven theme packages now declare
--icons-specifier ./icons.mjsin their build scripts.
Other Changes
- The scaffolded login pages use
Center'spaddingprop instead of a hand-writtenvar(--spacing-6)style object (#4764). - Self-host template demo imagery in the repo instead of streaming it (#3973)
from the internallookaside.facebook.comCDN. - Template demo images are now committed under
apps/docsite/public/template-assets/and referenced by root-relative/template-assets/*paths (previously Meta-internal CDN URLs invisible to external contributors). stripTemplateAssetRefsstill swaps these paths for the inlinedata:URI
placeholder on scaffold, so generated projects render with zero setup and no network dependency — no image is ever copied into a scaffolded project.
@astryxdesign/build
Fixes
-
build: import
node:fsstatically so the Vite plugin's package discovery survives the ESM build (#4972)
astryxStylex()'s config plugin discovered installed@astryxdesign/*packages withrequire('node:fs'). The./viteexport ships only an ESM bundle (dist/vite.mjs, esbuildformat: 'esm'), where esbuild lowersrequireto a shim that throwsDynamic require of "node:fs" is not supported— always, since nativerequirenever exists under ESM. The surroundingtry/catchswallowed the throw, sooptimizeDeps.excludesilently fell back to['@astryxdesign/core']and every other installed Astryx package stayed eligible for Vite pre-bundling, which stripsstylex.create/defineVarscalls and causes runtime errors.The discovery now uses a static
import fs from 'node:fs', which esbuild preserves as a real ESM import. A regression test compilesvite.tswith the same esbuild options asbuild.mjsand runs the discovery in a childnodeprocess, since in-process test runners provide arequireshim that masks the bug.
@astryxdesign/theme-neutral
Fixes
--radius-noneno longer overrides to0.25rem.--radius-noneand--radius-fullare documented as always fixed (never scaled by a theme), matching@astryxdesign/core's own defaults — this theme's radius group bump swept--radius-nonealong with it by mistake. Anything opting out of rounding via--radius-noneunder this theme now renders with a true0pxradius again, instead of a silent 4px. (#4856)
Contributors
Thanks to everyone who contributed to this release:
@AKnassa @alex-js-ltd @athz @cixzhang @czarandy @ejhammond @ernestt @freddymeta @HelloOjasMutreja @humbertovirtudes @imdreamrunner @is-jain @jiunshinn @josephfarina
Full Changelog: v0.3.0...v0.4.0