@astryxdesign/core
Breaking Changes
- DropdownMenuRadioGroup now takes a required
labelprop that names the group for assistive tech (applied as aria-label), replacing the previous optionalaria-label/aria-labelledbypassthrough -- renamearia-label="..."tolabel="..."(passaria-labelledbyvia base props instead when a visible label already exists). This also covers the ContextMenu/Breadcrumb re-exports (ContextMenuRadioGroup, BreadcrumbMenuRadioGroup). Also fixes ContextMenu to close the menu on Tab per the APG menu pattern. - Core — the authoring surfaces move to
@astryxdesign/cli/authoring.@astryxdesign/core/authoring(createIntegration/createPageTemplate/createBlockTemplate/createComponentDoc/createFunctionDoc/createDocand their types) and@astryxdesign/core/config(createConfig+AstryxConfig) are removed. The doc-type vocabulary re-exported from@astryxdesign/core(ComponentDoc,ReferenceDoc,ComponentPropDoc,ComponentTranslationDoc, …) is now a deprecated alias that re-exports from@astryxdesign/cli/authoringand will be removed next release. Author docs/configs/integrations as plain objects and import types from@astryxdesign/cli/authoring;astryx upgraderepoints existing imports automatically. - Remove long-deprecated compatibility APIs from core and CLI. Run
astryx upgradefirst to migrate the supported replacements for authoring imports, Dialog logical positions, Switch label spacing, and Table root props.
New Features
- Carousel: add
hasLoopfor wrap-around scrolling (next at the end returns to the start, prev at the start jumps to the end; navigation buttons stay active at both edges) and ahandleRefimperative handle (CarouselHandle) exposingscrollNext,scrollPrev,scrollTo(index),canScrollNext(), andcanScrollPrev()for programmatic control. - Center: add
padding,paddingInline,paddingBlock(spacing-scale inner padding) props. These match the existingpaddingprops onStack,Card,LayoutContent, andLayoutPanel, so centered page content no longer needs inlinestyle={{}}orxstylewrappers for basic padding. - ComplexSelector: add a rich custom selector shell with accessible button/popover behavior, async change actions, and optional grid keyboard navigation.
defineTheme: makecolor.accentoptional (#2279)
A theme can now restyle the neutral ramp (neutralStyle,contrast) without adopting an accent. An accent-less config seeds the neutral palettes from the default accent's hue but leaves--color-accent,--color-accent-mutedand--color-on-accentungenerated, so they fall through to the token defaults — the same fall-throughexpandColorScalealready applies to status, categorical and on-dark tokens. Configs that pass an accent are unchanged, token for token.- Dialog: add logical
start/endoffsets to thepositionprop and deprecate the physicalleft/right.start/endmap toinset-inline-start/inset-inline-end, so a positioned dialog mirrors correctly under RTL (start hugs the inline-start edge — left in LTR, right in RTL). The physicalleft/rightstill work unchanged and never mirror (non-breaking); they are now@deprecatedand will be removed in a future major. When both a logical offset and its physical counterpart are set, the logical one wins. A codemod (migrate-dialog-position-to-logical, v0.2.1) rewritesposition={{left, right}}to{{start, end}}. - DropdownMenuCheckboxItem now composes CheckboxInput so its checkmark matches CheckboxListItem and the standard checkbox theming slots apply. The checkbox stays decorative — the menu row keeps role="menuitemcheckbox" and owns the checked state.
- DropdownMenu now accepts an
alignmentprop for matching Popover/HoverCard positioning parity. - DropdownMenu: expose themeable slots for the section heading, menu divider, submenu indicator icon, and checked radio dot (
astryx-dropdown-menu-section-heading,astryx-dropdown-menu-divider,astryx-dropdown-menu-indicator-icon,astryx-dropdown-menu-radio-dot) so themes can style them directly instead of relying on structural selectors. ContextMenu inherits these via shared item rendering. - Add a ghost trigger variant for Selector and MultiSelector for toolbar-style controls, with ghost status messages detached by default.
- Field/FieldStatus: add
astryx-input-status-iconandastryx-field-status-icontheme targets on the field status glyph, so consumers can recolor, resize, and restyle it — per status — viadefineThemeinstead of a fragile descendant selector or raw CSS.astryx-input-status-iconsits on the on-field icon shared by all bordered inputs across theattachedandtooltipstatus variants and reflectsdata-size/data-status;astryx-field-status-iconsits on the detached message box's leading icon and reflectsdata-type. Purely additive — default rendering is unchanged. - Markdown: expose per-block spacing to theming. Every block type now renders a stable theme target —
astryx-markdown-heading,-paragraph,-list,-codeblock,-blockquote,-table,-hr, and-image— so a theme can tune the gap around any block (marginBlockStart/marginBlockEnd) viadefineThemeinstead of overriding global spacing tokens or reaching for fragile[role="paragraph"]-style descendant selectors. Each target reflectsdata-density(so spacing can differ perdefault/compact), and the heading target additionally reflectsdata-level(1–6) for per-level spacing. Targets apply only to the default render path — a customcomponents.heading/code/blockquote/hr/imagecontinues to own its own styling. Purely additive — default rendering is unchanged. - Pagination: add an
inputvariant — an editable page-number box (aNumberInput, so it clamps to[1, totalPages]with integer-only semantics) flanked by first/last («/») buttons, renderingPage [ n ] / N. Navigation is page-based via the existingonChange. The leading noun is set with an openpageLabelprop (defaults to the localized "Page"; passpageLabel="Row"to relabel it). Also adds astepprop controlling how many pages the prev/next buttons advance per click (default 1, clamped to range); when greater than 1 the buttons' accessible names reflect the stride. AddschevronsLeft/chevronsRighticons. The first/last/prev/next carets now also carry a hover tooltip (the same localized, step-aware label already used as their accessible name), so sighted users get the affordance the icon-only buttons previously exposed only to assistive tech. (#4248) - ProgressBar: add an opt-in
marksprop that draws fixed target lines on the track at values in the same 0..max scale asvalue(e.g. a goal or threshold). Marks stay visible whether progress is below or past them; each mark requires alabel(its accessible name, revealed via a tooltip on hover/focus), and marks are ignored in indeterminate mode. The mark tick is directly themeable via theprogressbar-marktarget — a theme setsbackgroundColor,width, andheighton it (a larger height makes a "flag" tick that overhangs the bar symmetrically above and below). The mark tooltip is loaded lazily, so a ProgressBar with no marks bundles no tooltip code. Namedmarks(with aProgressBarMarktype) to match themarksprop on Slider. - Icon registry:
registerIcons()now accepts arbitrary extension keys (not just built-inIconNames), so libraries can augment the icon map with their own keys. AddgetExtendedIcon(name, fallback)— resolves an extension key, preferring a theme-registered icon over a caller-supplied default. This lets library-shipped icons (e.g. the labRichTextEditorToolbar'srichtext:*glyphs) be overridden per-theme without forking. - SelectableCard: pressing Enter now toggles selection, in addition to Space, when the card is focused
- Selector & MultiSelector: the dropdown search field is now a
TextInput, so it gains that component's built-in affordances — a leading search magnifier (startIcon) rendered inside the field and a trailing clear (✕) button (hasClear) that appears once a query is typed and resets + refocuses on click. The field now shares TextInput's border, focus ring, and sizing, so it matches every other Astryx input instead of being a bespoke control. No new props or theme targets. Non-breaking, but note the magnifier is a new default glyph, so existinghasSearchdropdowns gain a leading icon. - Add SSR-friendly theme and icon registry resolution so semantic icons can resolve from a registered theme name without relying on React context.
- Table:
astryx-table-cellandastryx-table-header-cellnow reflect the active row density asdata-density(compact/balanced/spacious), so a theme can override cell padding per density viadefineTheme. Previously the density split lived entirely in internal StyleX classes with nodensity:*hook on the cell target, so acomponents: { 'table-cell': {...} }entry could only set one padding for all densities — it could not, for example, hold the inline inset constant while varying only the block padding per density. The targets now carry the hook ({className: 'astryx-table-cell', visualProps: ['density']}), enablingcomponents: { 'table-cell': { 'density:balanced': { paddingBlock: '12px' } } }. Purely additive — default padding is unchanged. - Table:
useTableTreeDatagains an opt-inhasRowClickExpansionprop. When set, clicking anywhere on an expandable row toggles it, in addition to the chevron. Clicks on interactive cell content or a text selection are ignored, leaf rows stay inert, and it is a no-op on flat data. (#4142) - Text & Heading:
coloris now theme-extensible.TextColoris derived from a newTextColorMapinterface (same technique asButtonVariantMapetc.), so a theme can add custom text colors —astryx theme buildgenerates the module augmentation when it sees newcolor:*values on Text/Heading overrides, and consumers can augmentTextColorMapmanually for type safety. A custom color renders as a stable class (astryx-text.<color>/astryx-heading.<color>) that theme CSS paints, falling back to theprimaryStyleX baseline so it never renders unstyled. Built-in colors are unchanged. - Timestamp: the hover surface is now a single copyable hover card for every timestamp that shows one. Relative timestamps and
tooltipEntries-configured timestamps share one card, replacing the old read-only tooltip; the default single row carries the full absolute time and is itself copyable.
EachtooltipEntriesrow opts into a copy button viaisCopyable(defaultfalse) — so a card can mix human-readable, read-only rows with a copyable machine value (e.g. show local and UTC for reading, but only let readers grab thesystem_date_timevalue). Copyable rows render their copy button in a dedicated trailing action column so the buttons align down one column regardless of value width; that column is only reserved when some row is copyable, so a fully read-only card carries no trailing gutter. The card's labels use thesupportingtext role (the secondary, quieter register that is Timestamp's own default) and values thebodyrole. - Timestamp: add a
relative_shortformat — the compact sibling ofrelative. It uses the same tier boundaries and present/clock-skew handling but renders abbreviated units for space-constrained surfaces (chat metadata, dense tables, chips):now,30s ago,5m ago,2h ago,1d ago,3mo ago,2y ago, andin 5mfor future times. Months render asmo(notm) so they never collide with minutes; the short form is always numeric (noyesterdayidiom). Likerelative, it keeps the full absolute date as its accessible name and gets the hover tooltip and live updates. Additive — existing formats are unchanged. - Timestamp: rename the recently added
system_unixformat tounix_seconds. The value is absolute Unix time in whole seconds since the epoch — not a wall-clocksystem_*rendering — so it does not belong to thesystem_*family; the explicit unit name also leaves room for a futureunix_millis. Behavior is unchanged (zone-independent epoch seconds). This renames a format value that only just shipped, before it has consumers. - Timestamp: two additions. (1) A new
system_unixformat renders the value as Unix time in whole seconds since the epoch (e.g.1771520400) — an absolute, zone-independent machine value, useful as a copyabletooltipEntriesrow alongside human-readable zones. It joins thesystem_*machine-readable family and, being absolute, ignores any tooltip time zone. (2) The copyable hover card's copy button now shows a visibleCopytooltip on hover/focus (flipping toCopiedafter a copy, in step with the icon), so the affordance is discoverable for sighted users; the fullCopy <value>string remains the button's aria-label for assistive tech. Both additive — no change to existing formats or default rendering. - Add
useContainerReveal— a headless hook for revealing (or concealing) content when its container is hovered or focused. CSS-driven (no hover state in JS, no re-render on hover) and accessible by construction: revealed content stays in the accessibility tree and tab order, reveals on keyboard focus-within, and stays visible on touch. Callers spreadgetContainerProps()on the container andgetContentRevealProps()on each child; no StyleX authoring required.Thumbnail'sshowRemoveOn="hover"now uses this hook internally (no API change).
Fixes
- AppShell: make the skip-link target focusable (tabIndex={-1}), localize the skip-link label via the i18n catalog, and expose the header region as a banner landmark
- CheckboxList: each option is a single tab stop — the checkbox is the option's only focusable control (WCAG 4.1.2). The row is now an enlarged click/tap target that delegates surface clicks to the checkbox via a new
interactiveRefprop on Item/ListItem (the useClickableContainer pattern), replacing the internal invisible row button.interactiveRefis mutually exclusive withonClick/href. - Resizable, TabMenu: two collection ARIA minors (WCAG 4.1.2) — Resizable's collapsed handle clamps
aria-valuenowtoaria-valueminand announces a localized "Collapsed" viaaria-valuetext, and TabMenu overflow options aremenuitemradiowitharia-checked(APG menu-button single-select) instead ofmenuitem+aria-current. - core: preserve state indication for painted controls (Switch, CheckboxInput, RadioList, SegmentedControl, ToggleButton, Skeleton) under forced colors / Windows High Contrast (WCAG 1.4.11)
- i18n: localize remaining hardcoded assistive-tech strings (AvatarGroup overflow label, CodeBlock copy announcement, Button loading announcement, MetadataList show more/less, Table row-expansion context-menu actions, keyboard hint)
- i18n: add
@astryx.step.*catalog keys (goToStep,goToStepWithStatus,status.completed/status.warning/status.error) backing the lab Stepper's localized status text and clickable-step accessible names. - Lightbox: add keyboard zoom (Enter/Space on the image,
+/-) and arrow-key panning while zoomed, with polite announcements (WCAG 2.1.1) - Selector: convey MultiSelector select-all partial state in its accessible name, mark Selector/MultiSelector empty-state messages presentational inside the listbox, and remove Typeahead's collapsed input from the Tab order while a token is shown
- Toast: announce toasts via the persistent singleton live regions instead of per-toast regions that mount together with their content
- theme: guarantee WCAG contrast for generated color token pairs — text-on-surface pairs are asserted at >= 4.5:1 and non-text UI pairs at >= 3:1 (WCAG 1.4.3/1.4.11), with
--color-border-emphasizedtone-bumped in generation until it clears 3:1 against the generated surface - Token: render the remove button as a sibling of the link instead of nesting it inside the anchor when both
hrefandonRemoveare provided. The token surface now delegates to the link viauseClickableContainer, so clicking anywhere on the token (including with middle-click or cmd/ctrl+click to open in a new tab) activates the link, while the remove button keeps handling its own clicks. - theme build: generated custom Button variants now type-check through the public
@astryxdesign/core/Buttonsubpath. - Use spacing tokens for ChatComposerDrawer bar handle dimensions.
- ChatLayout no longer shows a phantom scrollbar in self-scroll mode when messages don't fill the viewport. The root is now a flex column: the message area flexes to fill the space the composer dock doesn't need, so the sticky dock's natural height is part of the 100% instead of overflowing past it by exactly the dock height. Long conversations still scroll and the dock still sticks; external-scrollRef mode (fixed dock) is unchanged.
- Deprecate the
isRtloption onuseListFocusanduseGridFocus. Right-to-left arrow-key direction is now auto-detected from the container, so the explicit override is redundant and will be removed in an upcoming major — omit it and RTL is handled automatically. - DropdownMenu now reports uncontrolled native open/close transitions and restores focus to the trigger after native popover dismissals.
- DropdownMenu: a submenu trigger no longer shows a second highlight when hovered while another item still holds focus — hover now moves the single focus-driven highlight onto the trigger, matching regular menu items
- CheckboxInput & Switch: clicking the field description now forwards to the control (the whole label area is one hit target), while clicks on interactive content inside a description (links, buttons) are left alone. No new prop or accessibility-tree change — the description stays a sibling of the label, so it isn't folded into the control's accessible name.
- FieldLabel: localize the "Required"/"Optional" indicator through the i18n runtime instead of hardcoding English, so consumers can translate it via
InternationalizationProvider(#4508). - useContainerReveal: eliminate the exit flicker on the default (non-layout-preserved) reveal. Hidden content flips
position: static -> absolutediscretely, which previously snapped it out of layout flow at full opacity before the fade could run. The flip now participates in the transition withtransition-behavior: allow-discreteand a state-conditional delay, so it stays in flow until the opacity fade finishes on exit while remaining immediate on entry. Content stays in the accessibility tree and tab order throughout. - Selector and MultiSelector: with
statusVariant="detached", the on-field status icon is no longer shown inside the trigger. The detached message box already renders its own leading status icon, so the field keeps its chevron indicator instead of duplicating the glyph — matching the bordered inputs. - Dynamic
import()specifiers now get their mandatory.jsextension in the published ESM dist —babel-plugin-add-extensionsonly rewrote static import/export declarations, so the lazy Tooltip specifier inText,HeadingandTimestampshipped extensionless and strict-ESM consumers (Rspack, webpackfullySpecified, Node ESM) failed to resolve any component importing them. A new post-build gate (scripts/check-fully-specified.mjs) now fails any build whose dist ships an extensionless relative specifier. (#4569) - TopNavMegaMenu: keep the desktop mega-menu panel within the viewport — cap its height to the space below the nav (scrolling internally) and clamp its width — so a tall or wide menu no longer overflows the screen edge and clips content
- Lightbox: make backdrop click dismissal actually reachable
The dismiss check only matched clicks on the dialog element itself, but the layout container fills the entire transparent dialog, so clicks on the dark area around the media always landed on the container and never closed the lightbox. Clicks on the container now dismiss too, and a pan drag that ends over the backdrop is ignored. - Markdown streaming perf tests declare explicit timeouts matching their own budgets, instead of relying on vitest's 5s default
- MetadataList: a numeric
columnsvalue is honored with stacked labels.columns={3}previously fell back to the responsiverepeat(auto-fill, minmax(280px, 1fr))grid whenever labels were stacked (the default for multi-column lists), so the documented fixed column count only worked withlabel={{position: 'start'}}. The grid template now covers both label positions —repeat(n, 1fr)for stacked labels,repeat(n, auto 1fr)for side labels — and resolves through a StyleX dynamic style instead of an inlinestyleobject. - MultiSelector: remove the trigger button's own focus outline so it no
longer doubles the field wrapper's focus ring. The wrapper renders a single:focus-withinring, matchingSelectorand the other bordered inputs. - NumberInput: hide the browser's native number spinners so the field matches the component's own visual treatment across browsers, and stop a focused wheel gesture (which steps the value) from also scrolling an ancestor container. Keyboard stepping and the
spinbuttonrole are unchanged, so there is no accessibility impact. - Pagination: mirror the prev/next chevrons under RTL with CSS (the shared
scaleX(-1)mirror) instead of reading the ambient direction in JS. The controls now flip purely from an ancestor'sdir, matching Calendar and the rest of the library — so they render correctly on the server with no hydration flash. No API change;aria-labels are unchanged. - Popover: expose wrapper role and modal options so non-dialog popup content can own its semantics.
- Add a shared
rtlStyles.centerInline(blockOffset)helper for horizontally centering an absolutely-positioned, auto-width element on the inline axis, with an optional block-axis offset folded into the same transform. It intentionally uses physicalleft: 50%+translateX(-50%)— both reference the same physical edge, so the pair is direction-symmetric and centers identically in LTR and RTL. A logicalinsetInlineStart: 50%anchor would flip in RTL while the physical translate does not, shifting the element off-center by its own width. This is the one case where physicalleftis correct, so the single sanctionedno-physical-propertiessuppression lives in the helper rather than at each call site.
The@astryx/no-physical-propertiesrule now recognises thisleft: '50%'+ centeringtranslateidiom and points offenders at the helper instead of wrongly suggesting a logical rename. - The RTL physical→logical migration is complete, so promote the
@astryx/no-physical-propertieslint rule fromwarntoerrorin both the recommended and strict tiers. This gates against future physical-property regressions now that the core package is clean (the one sanctioned physical suppression lives inrtlStyles.centerInline). - RTL Phase 4c — make three animated/interactive behaviors direction-aware under RTL: the ProgressBar indeterminate bar now slides along the reading flow (right → left) instead of always physically left → right; the Switch thumb mirrors on toggle (off-thumb on the reading-start side, on-thumb on the reading-end side, per Material/iOS convention); and horizontal Layer enter animations (Popover/DropdownMenu/HoverCard/Selector placement start/end) now nudge in from the correct physical side. Vertical Layer entrances are unchanged (direction-neutral). LTR behavior is identical.
- Complete the RTL physical→logical CSS migration across the core package: the final components (Avatar, Banner, Calendar, Chat composer, Chat composer drawer, Markdown, Popover, Slider, Resizable) now use CSS logical properties (
insetInlineStart/End,borderStart*/End*radii,textAlign: 'end') instead of physicalleft/right, so they mirror correctly under RTL. The Avatar status dot's outward-pushtransformis now direction-aware, so it hugs the bottom-inline-end corner (bottom-right in LTR, bottom-left in RTL) instead of pulling inward under RTL.
The Popover close button, vertical Slider track/thumb, and ResizeHandle centered grab-zone/pill now consume the sharedrtlStyles.centerInlinehelper — fixing an RTL regression where a logicalinsetInlineStart: 50%anchor combined with a physical centeringtranslateshifted the element off-center by its own width. - TextArea: the
<textarea>now spans the full input container, with icons, status/spinner, and the character counter as absolutely-positioned overlays. The native resize grip sits in the container's bottom-right corner and the scrollbar covers the whole field. ThemaxLengthcounter moved inside the container, anchored bottom-right beneath the text (#4233). - Thumbnail: show the placeholder when the image fails to load
The docs promise a placeholder on load failure, but the img had no error handling, so a broken src rendered a broken image indefinitely. The component now tracks the errored src and falls back to the placeholder, retrying when src changes. - TreeList arrow-key navigation now follows visual direction in RTL: ArrowLeft expands and ArrowRight collapses under
dir="rtl"(mirrored from LTR). Detected automatically; LTR is unchanged.
Documentation
- Soft-deprecate useTableRowExpansion and useTableRowExpansionState in favor of the tree plugin (useTableTreeData + useTableTreeState). The hooks still work; JSDoc @deprecated tags and the docs point to the migration guide. Removal will come in a later release.
- Document the
@astryxdesign/coreStyleX peer dependency — add@stylexjs/stylexto the Getting Started / Quick Start install commands in both READMEs, and add anastryx initnext-steps reminder to ensure the@stylexjs/stylexpeer dependency is met, with a pointer toastryx doctor. StyleX is the styling runtime every component calls, and not all package managers auto-install peers. - Surface the React 19 peer-dependency requirement everywhere a user would look for it (root README, core README, docsite hero, and the CLI getting-started guide), and add a sync test that keeps those surfaces naming the same React major as the core peer range.
- Add a migration guide from useTableRowExpansion to useTableTreeData + useTableTreeState (before/after example plus a config mapping), since the two tree plugins are converging.
Contributors
Thanks to everyone who contributed to this release:
- @AKnassa
- @arham766
- @athz
- @bhamodi
- @cixzhang
- @freddymeta
- @HelloOjasMutreja
- @humbertovirtudes
- @imdreamrunner
- @jiunshinn
- @josephfarina
- @nynexman4464
- @potatowagon
@astryxdesign/cli
Breaking Changes
- CLI — authoring is consolidated into a single entrypoint,
@astryxdesign/cli/authoring, that exposes only TYPES (the plain objects authors write) and PARSERS (the CLI's load-boundary validators). Zod is sealed inside each parser and never exported. - Remove long-deprecated compatibility APIs from core and CLI. Run
astryx upgradefirst to migrate the supported replacements for authoring imports, Dialog logical positions, Switch label spacing, and Table root props.
New Features
- CLI human (non-
--json) output now renders through a small, documented formatter kit: consistent, plain-ASCIIkey: valuerecords/sections that mirror--jsonand are greppable by field. Every command was migrated onto it (a lint rule keeps output funneled through the singleemitsink), andastryx --helpdocuments the output contract.--jsonoutput is unchanged. (#4686) defineTheme: makecolor.accentoptional (#2279)
A theme can now restyle the neutral ramp (neutralStyle,contrast) without adopting an accent. An accent-less config seeds the neutral palettes from the default accent's hue but leaves--color-accent,--color-accent-mutedand--color-on-accentungenerated, so they fall through to the token defaults — the same fall-throughexpandColorScalealready applies to status, categorical and on-dark tokens. Configs that pass an accent are unchanged, token for token.
Fixes
- theme build: generated custom Button variants now type-check through the public
@astryxdesign/core/Buttonsubpath. - Remove the
@xds/theme-default→@astryxdesign/theme-neutralcollapse from the v0.1.0 upgrade codemods (module-specifiers, css-surfaces, and declare-module).theme-defaultwas dropped at the v0.1.0 scope move, so no v0.1.x consumer imported it — the collapse was dead and could rewrite unrelated source (including@xds/theme-default/theme.cssCSS imports) to a@astryxdesign/theme-neutralpackage the app never declared. The@xds/theme-daily→theme-neutralcollapse (and itsdefaultTheme→neutralThemeexport remap) is unchanged. - cli — confine user-controlled file paths, close DoS vectors, and repair paths broken by the authoring reorg (#4637)
- cli hardening pass — validate inputs at the API layer, close path-safety gaps, and prevent agent-docs content loss. The API is a public surface (
@astryxdesign/cli/api), so guards that lived only in the CLI wrapper are pushed into the API.
Path safety (the guard the write commands all depend on): - cli — rename the
search/buildverbose flag to--verbose, resync the bundled themes, and fixunwrap-authoring-factoriesedge cases (#4639) astryx doctor's peer-dependency check is now version-aware and names scoped packages correctly. Two problems are fixed: (1) the install hint was built withname.split('@')[0], which for a scoped peer like@stylexjs/stylexreturned an empty string, printing a barenpm installwith no package; and (2) the check only verified a peer was present, not that its installed version satisfied the declared range — so an out-of-range version (e.g.@stylexjs/stylex@0.10.1against a^0.19.0peer) was reported as satisfied. The check now flags out-of-range peers and its fix pins the required range, e.g.npm install @stylexjs/stylex@^0.19.0.- theme build: validate component override keys from documented theming targets so subtargets like Chat bubbles and SideNav items no longer warn as unknown.
astryx theme build: hyphenated component-override keys now resolve their built-in visual-prop values, and theKNOWN_COMPONENTSprop lists match what each component renders (#4109)
loadKnownValuesmapped a theme key to its core component directory by stripping non-letters from only the directory name, so a hyphenated key (text-input,dropdown-menu,app-shell, ...) never matched itsTextInput/DropdownMenu/AppShelldir and the built-in prop values were silently dropped. It now strips non-letters from both sides before comparing, so hyphenated keys resolve. TheKNOWN_COMPONENTSvisual-prop lists are also synced to each component'stheming.targets[].visualProps(e.g.text-input/date-input/number-input/time-input:size,status;side-nav:mode;aspect-ratio:shape), correcting stale/empty entries.
Documentation
- Document the core codemod staging workflow and add release-time automation that promotes
transforms/nextcodemods into the resolved release version folder. - Document the
@astryxdesign/coreStyleX peer dependency — add@stylexjs/stylexto the Getting Started / Quick Start install commands in both READMEs, and add anastryx initnext-steps reminder to ensure the@stylexjs/stylexpeer dependency is met, with a pointer toastryx doctor. StyleX is the styling runtime every component calls, and not all package managers auto-install peers. - Surface the React 19 peer-dependency requirement everywhere a user would look for it (root README, core README, docsite hero, and the CLI getting-started guide), and add a sync test that keeps those surfaces naming the same React major as the core peer range.
Other Changes
- The
create*factories are removed (createConfig,createIntegration,createComponentDoc,createFunctionDoc,createDoc,createPageTemplate,createBlockTemplate,createCodemod,createConfigCodemod). Author a plain object and stamp itstypedirectly ({type: 'component', ...},{type: 'page', ...},{type: 'code', ...}); config and integration manifests are plain objects with no discriminant. - Import authoring types from
@astryxdesign/cli/authoring— the doc typesComponentDoc,HookDoc,ReferenceDoc,TemplateDoc, and the project-file typesAstryxConfig,AstryxIntegration,AstryxCodemod. The old split surfaces (@astryxdesign/cli/{config,doc,integration,template,codemod}and the authoring exports of@astryxdesign/core) are superseded. - Doc field types are renamed to explicit, domain-prefixed names so the surface reads clearly:
PropDoc → ComponentPropDoc,ThemingTarget → ComponentThemingTarget,ComponentVar → ComponentThemingVar,DerivedVar → ComponentThemingDerivedVar,ElementDescriptor → ComponentSlotElement,GroupDoc → ComponentGroupDoc,TranslationDoc → ComponentTranslationDoc,ExampleDoc/AnatomyElement/BestPractice/PlaygroundConfig → Component*, andContentBlock/TokenPreviewType → Reference*. The authorable entry types (ComponentDoc/HookDoc/ReferenceDoc/TemplateDoc) are unchanged. astryx upgrademigrates you automatically. Three codemods ship in this release:unwrap-authoring-factoriesrewrites everycreate*call to the plain stamped object,migrate-authoring-importsrepoints the import specifiers to@astryxdesign/cli/authoring, andrename-authoring-doctypesapplies the doc field-type renames (imports, type references, and JSDoc@typerefs).- CLI — the public
@astryxdesign/cli/apitype surface is now generated from the runtime JSDoc, and the injectable logger is consolidated into oneLogger.
Consumer-visible changes to@astryxdesign/cli/api(types only — runtime imports are unchanged): - Precise return types.
component,docs,blog,discover,build,swizzle,upgrade,init, andthemeBuildpreviously resolved toPromise<any>; they now return their precise{ type, data }response unions. Code that leaned onanymay surface new (correct) type errors. - Response types are now exported by name — e.g.
ComponentDetailResponse,SearchResponse,UpgradeRunResponse— alongsidethemeAdd/themeList/listThemesand a new sharedloggervalue +Loggertype. - Breaking: the per-command return-union aliases
ComponentResult,DiscoverResult,DocsResult,HookResult, andTemplateResultare no longer exported. UseAwaited<ReturnType<typeof component>>(still works), or import the member response types directly. theme build --out/<file>, thevalidate-integrationmanifest roots (components/templates/codemods), andlayout --fileare now confined withassertWithin. An escaping integration root reports a validation issue instead of importing and executing files outside the package;layout --fileis also size-capped (5 MB) and rejects non-files, so a stream like/dev/zerocan't exhaust memory.- Fuzzy-match (Levenshtein), the layout value parser, and the layout expander gained bounds — a very long search query, a deeply nested attribute value, and a huge repeat count (
Box*999999999) can no longer spin the CPU, blow the stack, or exhaust the heap. - Docs topic lookup uses a null-prototype map so
__proto__/constructoras a topic name can't bypass the unknown-topic guard. The shipped getting-started docs and the sandbox registry generator point at the current CLI source path again (both broke in the authoring reorg). assertWithinnow canonicalizes symlinks (realpath of the deepest existing ancestor) — a symlink inside the project root pointing outside no longer lets a write escape. Also rejects a NUL byte in the path. This closes the escape for every command that writes through the guard (swizzle/template/upgrade/theme/layout/agent-docs).search(): non-positive/non-integerlimit, empty query, unknown--type→ERR_INVALID_ARGUMENT(previouslylimit: 0returned the full unclamped set).swizzle(): the component name is sanitized so../separators can't escape the--outputbase.swizzle()import rewriting: dynamicimport('../Sibling/…')is now rewritten (was left pointing at a non-existent sibling in the output dir); a two-levels-up asset import (../../locales/x.json) maps to the exported subpath instead of the invalid<pkg>/..; and../theme/tokens.stylexkeeps its full subpath (the StyleX compiler needs the dedicated./theme/tokens.stylexexport — collapsing it to<pkg>/themebroke StyleX resolution). Component-local.stylexfiles that aren't subpath exports keep the working barrel collapse.template()copy: refuses to clobber withoutoverwrite: true(ERR_FILE_EXISTS); adds anoverwriteoption.upgrade(): the--pathscan dir is confined to cwd (--applyrewrites files in place).init(): template scaffold refuses to clobber an existingpage.tsx(ERR_FILE_EXISTS); an unknown--agentnow throwsERR_UNKNOWN_AGENT(was silently ignored).layout: rejects an unknown--form(ERR_INVALID_OPTION) and empty expression (ERR_INVALID_ARGUMENT).layout expand: text payloads containing<,>,{, or}(e.g.Text"5 < 3") are emitted as JSX string-expression children so the generated TSX is valid — previously they produced syntactically-broken output.layout expand: a top-level repeat or group that expands to multiple sibling elements (B"x"*3,(B"a" + B"b"), an outlinerepeatblock) is now wrapped in a fragment — previously the generated TSX had adjacent root elements with no parent and failed to compile (the wrapper decision counted AST roots instead of expanded elements).layout(expand/check): an empty expression now surfacesERR_MISSING_ARGUMENTand a missing--filesurfacesERR_FILE_NOT_FOUND(was a genericERR_UNKNOWN/ a rawENOENTerrno, with a stack leak in human mode).layoutparser: a pathologically deep compact expression (V > …nested past 512 levels) is rejected with a locatedERR_LAYOUT_PARSEinstead of blowing the call stack and surfacing a rawRangeError(→ERR_UNKNOWN).layout check --form …printers: a string containing a quote (e.g. a Buttonlabel="Don't panic") now round-trips — the printer picks a delimiter the string doesn't contain instead of always single-quoting, so the emitted compact/outline surface re-parses (was producing an unparseable token).resolveTheme: a non-stringastryx.themein package.json (number/array/object/boolean) degrades to null instead of crashingastryx componentwith a rawTypeError(parity with the empty-string / unknown-slug paths).jsonOut: serializes the envelope BEFORE marking the emission handled, so if a command returns unserializabledata(circular ref / BigInt — an author bug) the bin error boundary still emits a JSON error envelope instead of leaving a--jsonconsumer with empty stdout.- package scanner: a dependency's
astryx.docsthat is a non-string (number/array) is skipped instead of crashing the whole scan with a rawTypeError, and adocspath that escapes its own package dir is skipped rather than surfacing foreign docs; a non-string packagenameis coerced to a string. component --package <pkg> --showcase/--blocks: route to the right leaf instead of falling back tocomponent.detail.discover/docsleaves: empty query/section errors instead of matching everything via.includes('').docs()/discover(): a non-stringtopic/section/querynow throws a stable coded error (ERR_UNKNOWN_TOPIC/ERR_UNKNOWN_SECTION/ERR_INVALID_ARGUMENT) instead of a rawTypeErrorthe CLI downgraded toERR_UNKNOWN(parity with thecomponent/hooknon-string guards).blog()detail: a non-string slug throwsERR_INVALID_ARGUMENT(was a rawTypeErrorthe CLI downgraded toERR_UNKNOWN), and fails fast before any network fetch.hook()/component()dispatchers: a non-stringnameorcategorythrows a coded error (ERR_UNKNOWN_HOOK/ERR_UNKNOWN_COMPONENT/ERR_UNKNOWN_CATEGORY) instead of a rawTypeErrorwith no.codefrom the leaf's.toLowerCase()/.replace(...).theme add: a write failure where an ancestor of the target dir is a file now surfacesERR_WRITE_FAILED(themkdirmoved inside the write try/catch) instead of leaking a raw fs errno (EEXIST/ENOTDIR) + absolute path.validate-integration: a path-unsafe[package]spec (../absolute) is reported as aninvalid_package_specdiagnostic instead of crashing with a raw stack (human) / genericERR_UNKNOWN(--json).doctor: no longer crashes (raw stack in human mode /ERR_UNKNOWNin--json) when multipleastryx.config.*files coexist — it reports aconfigFAIL. Version-alignment skips (info) instead of a spurious drift WARN with aNaN.undefined.xfix when either version isn't comparable semver (e.g.workspace:*).manifest: subcommands are sorted by name (same stability guarantee the top-level command list makes), so reordering.command()calls can't silently change the agent-facing manifest.build: the CLI wrapper now propagates the API's errorcodeinto the--jsonenvelope (bogus--type/ non-positive / non-integer--limit→ERR_INVALID_ARGUMENTinstead of a genericERR_UNKNOWN), and delegates--limitvalidation to the API (parity withsearch).layout check: exits1in BOTH--jsonand human mode for an invalid (but parseable) layout — the exit code no longer depends on the output mode, so it works as a CI gate / agent check without parsing stdout.upgradeconfig codemods: afindConfigPaththrow (multipleastryx.config.*files) is surfaced as a structured per-codemod error instead of crashing the whole upgrade run — config codemods run before the strict loader, so this restores the per-codemod isolation every other failure path honors.- CLI dispatch: the belt-and-suspenders postAction "completed without emitting an envelope" error carries a
code(ERR_UNKNOWN) so every error envelope is branchable oncode. toErrorEnvelope/AstryxError: attachsuggestionsonly when it's a real array.injectXdsBlock/removeXdsBlockno longer drop, duplicate, or orphan user content on malformed managed blocks (END-before-START, duplicate/nested blocks, or a start marker with no end). They locate a single well-formed block (END searched after START) and refuse to touch an ambiguous/half-written file instead of corrupting it.- The codemod source scan no longer follows symlinks (a symlinked file under the scanned path could rewrite its target OUTSIDE the project) and skips generated-output dirs (dist/build/out/.next/coverage) — codemods rewrite source, not artifacts or dependencies.
resolvePackageDirrejects an integration spec that isn't a bare package name (no.., no absolute, must stay in node_modules) — a config spec can no longer point the loader at an arbitrary module.- A broken integration manifest (throws on import or fails schema validation) no longer crashes
Project.load(and thus every command). It's recorded and surfaced viaissues(), restoring the documented skip+warn policy; other integrations still load. - The
--radius-*,--shadow-*/--elevation-*, and--color-*token-migration codemods no longer rewrite a longer consumer-defined token that merely shares a prefix (e.g.--radius-container-custom→--radius-3-custom,--radius-innermost→--radius-0most,var(--shadow-10)→--shadow-base0,--color-positive-custom→--color-success-custom). The boundary lookahead was binding only to the last alternative in the pattern (and two codemods had no boundary at all); it now wraps the whole alternation, so only exact token names migrate. migrate-badge-children-to-labelno longer emits a duplicatelabelprop when the badge already has one (<XDSBadge label="x">Active</XDSBadge>produced an invalidlabel="x" label="Active"); it now skips a badge that already declareslabel.readDocMetano longer reads agroup:/hidden:field nested inside apropDescriptionsblock (a docsZh/docsDense translation export) as the component's group — that leaked a translated prop description as a group key in the default Englishcomponent --list(e.g. a Chinese string appeared as a group). The field regexes now match top-level fields only (<=2 spaces).astryx search/buildverbose output was unreachable: the boolean--detailflag collided with the root program's value-taking--detail <level>, sosearch button --detailerroredargument missing. The boolean is now--verbose(the global--detail <level>is unchanged).- The themes bundled for
astryx theme addhad drifted from source — theneutralbundle was missing a WCAG AA light-modetext-secondarycontrast fix and a StatusDot color block, soastryx theme add neutralscaffolded a theme below AA. All bundles are regenerated to match source, guarded by a new drift test. - The
unwrap-authoring-factoriesupgrade codemod produced broken output for a shorthandtypeproperty (emitted{'component'}) and for no-argument factory calls (left a call referencing the just-removed import). Both now emit the correct plain object.
Contributors
Thanks to everyone who contributed to this release:
- @AKnassa
- @cixzhang
- @ejhammond
- @imdreamrunner
- @jiunshinn
- @joeyfarina
- @josephfarina
@astryxdesign/build
@astryxdesign/theme-butter
@astryxdesign/theme-chocolate
@astryxdesign/theme-gothic
@astryxdesign/theme-matcha
@astryxdesign/theme-neutral
Fixes
- neutral theme: darken light-mode
--color-text-secondaryfrom neutral-500 (#737373) to neutral-600 (#525252). 500 only reached 4.19:1 on the T95 body background (#f1f1f1), just under WCAG AA 1.4.3 (4.5:1); 600 clears it. Dark mode is unchanged.
Contributors
Thanks to everyone who contributed to this release:
@astryxdesign/theme-stone
@astryxdesign/theme-y2k
Contributors
Thanks to everyone who contributed to this release:
- @AKnassa
- @arham766
- @athz
- @bhamodi
- @cixzhang
- @ejhammond
- @freddymeta
- @HelloOjasMutreja
- @humbertovirtudes
- @imdreamrunner
- @jiunshinn
- @josephfarina
- @nynexman4464
- @potatowagon
- @rubyycheung
Full Changelog: v0.2.0...v0.3.0