Skip to content

v0.4.0

Choose a tag to compare

@cixzhang cixzhang released this 13 Aug 21:47
· 334 commits to main since this release
110987b

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 DropdownMenuDivider component (aliased as ContextMenuDivider and BreadcrumbMenuDivider), which the data path also renders, so {type: 'divider'} and <DropdownMenuDivider /> produce identical DOM, spacing, and theme target. Data mode gains endContent and description, so an items row can carry a shortcut hint or secondary text without dropping to compound mode. Its label widens from string to ReactNode, 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 the Data suffix their sibling DropdownMenuItemData already carries: DropdownMenuDividerDropdownMenuDividerData, ContextMenuDividerContextMenuDividerData, BreadcrumbMenuDividerBreadcrumbMenuDividerData. 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. Run astryx upgrade --apply to rewrite the type imports; a missed one fails at compile time rather than silently.
  • Remove the dropdown-menu-radio-dot theme target. Menu radio rows draw the shared radio indicator now, so the dot is the indicator's dot: target radio-indicator-dot (the legacy radio-dot name still matches it too). The row's circle keeps its dropdown-menu-radio target, so only the dot moved. (#4890)
    Runtime themes are not validated — a theme keyed on the removed target keeps compiling and silently stops matching — so astryx upgrade now carries rename-dropdown-menu-radio-dot-target, which rewrites the key and the astryx-dropdown-menu-radio-dot class. 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 --apply runs migrate-table-rowexpansion-to-tree, which rewrites tree-mode useTableRowExpansion call sites onto useTableTreeData + useTableTreeState.

New Features

  • Avatar: the fallback surface (initials and default icon) is now a direct theme target via the stable astryx-avatar-fallback class. Theme its background, text color, font weight, and per-size font size through the avatar-fallback component 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 IconButton with a default "Copy code" tooltip, reachable via the stable astryx-codeblock-copy-button class (theme it through the codeblock-copy-button component 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] New useClipboard hook (@astryxdesign/core/hooks): the shared copy-to-clipboard behavior — clipboard write, a transient isCopied flag 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-header and codeblock-title theme 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 > span selectors that reverse-engineer the header layout. Both reflect the size/language(/container) visual props like the root. (#4943)

  • DateInput, DateRangeInput, and DateTimeInput now accept a weekStartsOn prop 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-state and astryx-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-title and empty-state-description theme targets on the title heading and the description. A theme can now restyle the title and description directly (e.g. font size, color, per variant) 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 the astryx-input-clear-icon target instead of a per-component target or a fragile descendant selector. The component-specific astryx-{date-input,date-range-input,selector,multi-selector}-clear-icon targets still render for a deprecation window — migrate to input-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 .disabled variant (only when disabled), so a theme can gate its own hover/border treatment on the disabled state — mirroring the existing status/size reflection — instead of relying on structural :has(input:disabled) CSS. This closes a documented theming gap for downstream consumers. (#4794)

  • useLayer takes an offset for 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). DropdownMenuItem takes hasCloseOnSelect, so a plain action can report its result on the item instead of closing the menu. DropdownMenuItemData and DropdownMenuSection take an optional id, 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 variant prop ('default' | 'destructive'); 'destructive' renders the label, description, and icon in the error color for dangerous actions like Delete. The data-driven items API accepts the same variant field, 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-option target, carrying the row's size and its select-all, selected and disabled states — 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 and renderOption content; 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-popup and astryx-multi-selector-popup theme targets on the popup surface, so a theme can style the popup — background, border, radius, elevation, padding — through defineTheme instead 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: useLayer zeroes 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 that isDisabled deliberately does not cover, since disabled controls are excluded from submission. Read-only fields are not dimmed and stay in the tab order, matching the native readonly semantics they compile to; isDisabled takes precedence when both are set, and the clear button is hidden while read-only. The state is reflected on the root theming target as data-readonly="readonly", alongside the existing data-disabled, so a theme can paint it without structural :has(input:read-only) CSS. isReadOnly already 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-check theme target on the selected-row checkmark lets themes restyle or hide it (e.g. to compose their own selected indicator via renderOption) instead of relying on a structural sibling selector, and data-disabled now reflects on the trigger for theme-driven disabled styling. Rotation styles remain on the indicator-icon target. Default appearance is unchanged.

  • Table: contextMenuActions now accept a variant: '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 paddingInline on the textarea component key — it now drives the internal --_textarea-inline-padding var 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 a replaces option 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-dot is removed — target radio-indicator-dot; astryx upgrade rewrites 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-gap for the inter-row gap, defaulting to a subtle 2px (var(--spacing-0-5)) separation — matching the inter-row gap List and DropdownMenu already ship — so this shifts the default spacing of every tree by that amount; set it on the tree-list target to widen or close it. The gap rides collapse-proof padding-block on the row wrapper (not the paintable tree-list-item target), and the connector guides span it automatically without overhanging the last row. (#4540)

Fixes

  • AlertDialog: correct the inline role and pin initial focus (#4887).
    The isInline preview path no longer renders role="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 renders role="group", keeping the title and description associated through aria-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 a banner landmark, matching the header region of a layout that has a topNav. 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 a banner slot 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-accent and 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 isIconOnly on its inner Button, 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-button and chat-layout-scroll-button theme targets, and ChatSendButton no longer clobbers a consumer's className (#4634).

  • ChatToolCalls: hover backgrounds on grouped call rows (2+ calls) now keep their full --radius-element rounding instead of getting clipped flat on the inline edges. groupContentInner — the overflow: hidden clip 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 when placement="above", matching placement="below" and Popover. The popup's margin was set on marginBlockStart only, 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 disabledMessage is set. Showing the reason tooltip requires swapping the native disabled attribute for aria-disabled + readOnly, so the message stays discoverable by pointer and keyboard — but read-only fields still serialize into FormData, and these three kept their name, so a locked field posted its value. They now withhold the name while disabled, matching CheckboxInput and Switch (which forward the name only when enabled) and the hidden-input carriers in Selector, MultiSelector, Slider, and Tokenizer (which mirror disabled). 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 children no longer deletes the state mark. The busy idiom a host actually writes — children={isBusy && <Spinner/>} — passes false when it is not busy, and false is neither null nor 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 use isRenderable, so only children that actually render replace the mark. 0 still 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-accent at 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-offset is unchanged. Form and input focus treatments are out of scope.

  • <Heading type="display-N"> now sizes correctly under every theme, matching Text's behavior. generateTypeScaleComponents() only emitted level:N-keyed CSS rules for heading, with no type:display-N counterpart — so as soon as a theme supplied typography.scale, the generated theme-layer CSS's level:N rule was the only one present and silently won regardless of type, 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-icon targets, 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 — transform does 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 of useIcon() inside a hand-written <span>. Those spans were a weaker reimplementation of <Icon>, which already resolves the same glyph and renders a span carrying merged className/style/xstyle plus the astryx-icon theme 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-transform lint 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).
    IndicatorProps now omits aria-hidden, role, aria-label, aria-labelledby and tabIndex — passing role or tabIndex is a compile error — and each indicator emits its own aria-hidden after {...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 axe aria-hidden-focus violation.

    Nothing is stripped: every other prop, including a forwarded aria-label, still reaches the DOM, where it is inert inside an aria-hidden subtree. Note that TypeScript exempts hyphenated JSX attributes from excess-property checking, so the type alone cannot reject aria-*; the attribute order is what enforces it. tabIndex is a plain identifier, so its omission stands on its own.

    Also corrects two doc claims: a replacement must render children when they will actually draw something (isRenderable, not children ?? mark), and "passing role is 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 mergeRefs cleanup so object refs are cleared and callback refs without
    cleanup functions still receive null when a merged ref returns cleanup (#4901).

  • MoreMenu forwards placement and alignment to 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: contents span, 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 the Icon directly via xstyle, 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's width/height were plain StyleX declarations, so a progressbar-mark override only landed where @layer astryx-theme outranks the component atomics — in a source-build app that compiles StyleX without useCSSLayers the atomics are unlayered and beat every theme rule, leaving no way to resize the tick but an unlayered !important rule. The dimensions now travel as derived vars with no competing declaration, so the same defineTheme entry 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/onMouseEnter is 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 required control that is disabled with a disabledMessage no longer blocks the whole form from submitting. Showing the reason tooltip swaps the native disabled attribute for aria-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 via form="" 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-secondary on 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. With hasSearch, typing on the closed trigger opens the popup and seeds the search input.

    Matching reuses the shared useTypeahead hook, 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 through useAnnounce.

    useCombobox no longer implements typeahead itself; callers that want it compose useTypeahead and run it ahead of the combobox key handler.

    Adopting the shared hook exposed two matching bugs in it, fixed here — so DropdownMenu, ContextMenu and NavHeadingMenu improve 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: footer content now centers when the nav is collapsed, matching how children already centers. stickyBottomCollapsed (the collapsed-rail wrapper for footer) was missing alignItems: 'center', which its sibling scrollableCollapsed (the collapsed-rail wrapper for children) 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 SideNavHeading trigger with a menu no longer omits its popover's anchor. The trigger's ref callback wasn't forwarding to usePopover's triggerRef, 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.optional message key so it translates like the rest of the component. No visual change in English. (#4872)

  • useStreamingText no 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 via Intl.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: 0 shorthand 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 in PowerSearchEditPopover, 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 onTouchStart and onTouchMove only checked touches.length on 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 the touches.length !== 1 guard without ever clearing it — onLongPress could 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 mobileNav escape hatch passed title to MobileNav, which does not accept it: MobileNavProps omits the native title attribute and the drawer heading prop is header. Copying either example produced a type error and a drawer with no heading. Both now say header. 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 in bestPractices, along with the single-child expectation: with fit set, every direct child is stretched to fill the box. The image-gallery example block now uses var(--radius-element) instead of a raw 8. (#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 a DropdownMenu / ContextMenu / MoreMenu items array — is now sourced from DropdownMenuItemProps (Pick) instead of restating icon, onClick, isDisabled, and variant by hand, and renderDropdownItems forwards the whole item to DropdownMenuItem rather 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 the Pick. The type is structurally identical to before (label is still narrowed to string, 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 via xstyle — or, for Pagination's page-size Selector, its documented width prop. 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 DropdownMenuDivider component (aliased as ContextMenuDivider and BreadcrumbMenuDivider), which the data path also renders, so {type: 'divider'} and <DropdownMenuDivider /> produce identical DOM, spacing, and theme target. Data mode gains endContent and description, so an items row can carry a shortcut hint or secondary text without dropping to compound mode. Its label widens from string to ReactNode, 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 the Data suffix their sibling DropdownMenuItemData already carries: DropdownMenuDividerDropdownMenuDividerData, ContextMenuDividerContextMenuDividerData, BreadcrumbMenuDividerBreadcrumbMenuDividerData. 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. Run astryx upgrade --apply to rewrite the type imports; a missed one fails at compile time rather than silently.

New Features

  • Add the migrate-table-rowexpansion-to-tree codemod (runs on astryx upgrade): rewrites the removed useTableRowExpansionState tree pattern to useTableTreeState + useTableTreeData. Detail-panel usage (renderExpanded) is left untouched. (#4884)

  • Add a self-documenting layer to the CLI: typed, colocated .doc.mjs for every command, every @astryxdesign/cli/api function, and every authored schema (config, integration, codemod, the response envelope, and the doc-types themselves). Adds the FunctionDoc, SchemaDoc, CommandDoc, and EnumDoc authoring types with sealed parsers. (#4714)
    Every command's --help and its astryx manifest entry are now built from that command's colocated CommandDoc via a defineCommand converter, 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 missing blog, build, layout, and validate-integration.

    Kept honest by a drift harness (docs vs the live CLI), check:cli-structure (each doc-type and api/ 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-dot is removed — target radio-indicator-dot; astryx upgrade rewrites 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 layout already 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 established astryx <cmd> form that the header maps to the project's real invocation (pnpm exec astryx, npx @astryxdesign/cli, …). astryx docs reads 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-columns codemod bailed without changes when a <Grid> had both columns and minChildWidth, leaving the now-invalid minChildWidth prop in place and failing type-checking on 0.3.0. (#4792)
    When columns is a numeric literal, it now migrates losslessly to the 0.3.0 object form. This mirrors the old (0.2.0) Grid runtime, where minChildWidth dominated and the numeric columns capped the column count under auto-fit: <Grid columns={3} minChildWidth={280}> becomes <Grid columns={{minWidth: 280, max: 3, repeat: 'fit'}}>. Object or dynamic columns values remain a deliberate bail.

  • The documented hook example referenced useToggle, which is not a hook in the design system — running it failed with ERR_UNKNOWN_HOOK. It now uses useFocusTrap. (#4742)
    This shipped in two places a consumer sees: astryx manifest --json, which agents read to learn the CLI, and the hook CommandDoc that feeds --help. Replaced in both.

  • CLI internals: a true foundation/ bottom layer, and generated ./authoring types (#4736).
    foundation/ no longer imports api/, and ESLint now enforces that direction alongside the existing authoring/ and api/ rules. Two things were reaching upward: Project pulled template discovery out of api/template, whose adapter imported Project straight back, and both Project and integration-warnings imported validateLoadedIntegration from the validate-integration command. Neither was misplaced logic, just misplaced files — the adapter now lives at foundation/discovery/template-adapter.mjs and the validators at foundation/integrations/validate-contributions.mjs. To be precise: Project and 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 ./authoring type declarations are now generated from their JSDoc instead of hand-written, the same way ./api already works. The 13 hand-maintained .d.mts files are gone; scripts/sync-api-types.mjs emits both trees at prepack, 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 as any, and a stale parseDoc return union silently dropped SchemaDoc, CommandDoc and EnumDoc. Also fixes parseFunction, a bare re-export of parseHook that published HookDoc instead of the general FunctionDoc.

  • 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 empty src instead — 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-specifier so 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 build scraped 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 /built entry 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.mjs in a package with no "type" field and at icons.js in 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---out flow — where the neighbour is an uncompiled icons.tsx that only a bundler can resolve — is unaffected.

    The seven theme packages now declare --icons-specifier ./icons.mjs in their build scripts.

Other Changes

  • The scaffolded login pages use Center's padding prop instead of a hand-written var(--spacing-6) style object (#4764).
  • Self-host template demo imagery in the repo instead of streaming it (#3973)
    from the internal lookaside.facebook.com CDN.
  • 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).
  • stripTemplateAssetRefs still swaps these paths for the inline data: 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:fs statically so the Vite plugin's package discovery survives the ESM build (#4972)
    astryxStylex()'s config plugin discovered installed @astryxdesign/* packages with require('node:fs'). The ./vite export ships only an ESM bundle (dist/vite.mjs, esbuild format: 'esm'), where esbuild lowers require to a shim that throws Dynamic require of "node:fs" is not supported — always, since native require never exists under ESM. The surrounding try/catch swallowed the throw, so optimizeDeps.exclude silently fell back to ['@astryxdesign/core'] and every other installed Astryx package stayed eligible for Vite pre-bundling, which strips stylex.create/defineVars calls 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 compiles vite.ts with the same esbuild options as build.mjs and runs the discovery in a child node process, since in-process test runners provide a require shim that masks the bug.

@astryxdesign/theme-neutral

Fixes

  • --radius-none no longer overrides to 0.25rem. --radius-none and --radius-full are documented as always fixed (never scaled by a theme), matching @astryxdesign/core's own defaults — this theme's radius group bump swept --radius-none along with it by mistake. Anything opting out of rounding via --radius-none under this theme now renders with a true 0px radius 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