开源可定制,适配各类智能体
为astryx打分
给出您宝贵的评分:
手机端可长按上方图片保存到相册,或点击「下载/分享」分享到微信
使用 astryx,你可以:
完全开源、支持自定义的标准化设计系统,原生适配各类AI智能体调用,可自由修改组件、样式与交互规范。
用户评论 (0)
2026年08月04日
2026年02月27日
2026年03月13日
2026年03月12日
2026年04月01日
2026年03月27日
2026年07月02日
2026年07月02日
v0.4.5
2026年08月19日
Astryx 0.4.5 — all @astryxdesign/* packages ship at this version.
npx astryx upgrade --apply@astryxdesign/core
New Features
-
BottomSheet:
snapPointsmakes the drag-to-resize stops the host's choice. A stop is the sheet's visible height, written as a viewport fraction (0.5), a percentage ('50%'), or a px length ('320px') — matchingheight, where a bare number is also px and a string carries its unit. Fractions and percentages re-resolve when the viewport changes, so a sheet keeps the stop the user chose across a rotation, and swapping the points under a resting sheet re-anchors it the same way. A stop of a quarter of the sheet or less is a peek: it slides away rather than reflowing into a sliver, and thins the scrim. Taller stops are working surfaces, so they lay their content out and keep the scrim full — previously the shortest stop was always a peek, which would have thinned the backdrop of a half-height sheet (#5203).
Behavior change, deliberate and not breaking: every sheet used to carry three built-in stops (14%, 50% and 92% of the viewport), so a drag could leave it resting somewhere the host never asked for. A sheet now opens and closes unlesssnapPointssays otherwise; passsnapPoints={[0.14, 0.5, 0.92]}to keep the old stops. No prop, type, or DOM output was removed or renamed, and swipe-to-dismiss, the height budgets, and mobile-keyboard accommodation are untouched. -
Astryx ships translations for 28 more locales.
packages/core/locales/went fromenandfr-FRto 30 files — Arabic, Catalan, Chinese (Simplified and Traditional), Czech, Danish, Dutch, Finnish, German, Greek, Hebrew, Hungarian, Italian, Japanese, Korean, Norwegian, Polish, Portuguese (Brazil and Portugal), Romanian, Russian, Serbian, Spanish, Swedish, Turkish, Ukrainian, Vietnamese and Afrikaans — covering every@astryx.*message the components announce or display (#5185).
Nothing changes unless you ask for it:InternationalizationProviderstill defaults to English, and the catalogs are loaded through the existing./locales/*.jsonexport. An app that already passes alocalenow gets translated component strings where it previously fell back to English.The catalogs come from Crowdin and are refreshed nightly (#5186), so a translation landing upstream reaches a release without anyone opening a PR by hand.
-
DateRangeInput / Calendar: add
maxRangeSpanandminRangeSpanto constrain the size of a selected range. Once a start date is picked, days outside the allowed window are disabled — e.g.maxRangeSpan={7}keeps the range within a 7-day window of the start (#5145). -
FormLayout: add
defaultOptionality— set a form-wide default ('optional'or'required') so only the exception carries a visible indicator. Under'optional'onlyisRequiredfields show one; under'required'onlyisOptionalfields do; a field that restates the default shows nothing. Under'required'the unmarked fields also exposearia-requiredso screen readers match what sighted users see — resolved onaria-requiredonly, never the nativerequiredattribute. Unset keeps today's per-field behavior (#4791). -
Add
astryx-input-clear-buttontheme target on the shared clear button wrapper. Themes can now control the clear button's height and hover independently of other ghost buttons — for example suppressing the hover fill or matching a different element size scale (#5093).
Fixes
- BottomSheet: a pull up from the scroll area now expands the sheet on iOS. Below the tallest detent, dragging up inside the content did nothing on a real device while the grab handle worked — the sheet took the gesture and then froze for the rest of the pull. iOS Safari raises PointerEvents for a finger under the same numeric id it puts in
Touch.identifier, so the drag the touch path started was keyed to a live pointer:beginDragcaptured that pointer, WebKit handed the capture straight back, and thelostpointercapturea millisecond later cancelled the drag. Touch-driven drags are now marked as such — they take no pointer capture, andlostpointercapture,pointercancelandpointermovefor that same finger no longer cancel, end or double-drive them. Browsers that keep the two id spaces apart were never affected, which is why this only showed up on device (#5178). - Calendar weekday headers now use compact CLDR stand-alone-short names for the selected locale, while preserving the existing
Su/Mo/TuEnglish labels. - Render generated id attributes on Markdown headings so Outline hash links scroll to their target. Heading slugs now come from parser helpers shared with parseOutlineFromMarkdown, and the components.heading override receives the generated id (#4765).
- StatusDot: pair each variant with a distinct built-in shape drawn from the system's semantic icon vocabulary — success a check, warning an exclamation, error a cross, neutral a ring, accent the plain filled dot — so status no longer relies on colour alone (WCAG 2.1 SC 1.4.1). The shapes mirror the marks
Banner/FieldStatusrender viadefaultIcons, a different axis of consistency fromAvatarStatusDot's presence shapes (the two share only the neutral ring, intentionally). The diagonal check and cross take a slightly heavier stroke so they stay crisp and distinct at 8px. Also adds aniconprop for API parity withAvatarStatusDot: a rendered icon replaces the built-in glyph, while booleans and empty renders are ignored socond && <Icon />stays safe. The built-in glyphs resolve through the icon registry under scopedstatusdot:<variant>keys (therichtext:*precedent), so themes can reshape a variant's mark everywhere viadefineTheme({icons})/registerIcons— including marks for augmented custom variants — while overrides of the standard 24px semantic icons deliberately do not leak into the 8px field. Themes can also target the new stableastryx-statusdot-glyphclass and itsdata-shapeattribute — a stroked inline<svg>painted from the dot'scurrentColor(#4373). Table's row-expansion chevron now mirrors correctly under RTL. It previously rotated on expand with no RTL handling at all, so the directional glyph pointed the same way regardless of text direction, matching the pattern already used byTreeListItem's chevron (#5153).
Contributors
Thanks to everyone who contributed to this release:
@athz @bhamodi @cixzhang @freddymeta @HelloOjasMutreja @imdreamrunner @jiunshinn @nynexman4464
Full Changelog: v0.4.4...v0.4.5
详细ChangeLogv0.4.4
2026年08月18日
Astryx 0.4.4 — all @astryxdesign/* packages ship at this version.
npx astryx upgrade --apply@astryxdesign/core
New Components
- Promote
BottomSheetandBottomSheetSwitcherfrom the canary-only Lab package to Core. The stable package now includes their existing native-dialog, drag-detent, transition, and mobile-keyboard behavior, plus Core documentation and examples (#5080).
New Features
-
astryx template --cdnwrites a working no-build-step CDN starter page (#5068).
A CDN starter is a template, so it joins the template family beside--skeletonrather than claiming a top-level command. It is a flag and not the positionalastryx template cdnbecause the positional resolves against everythingdiscoverAll()finds, where acdnid would shadow a discovered template.cdn.template.htmlloads Astryx from jsDelivr and esm.sh with no bundler, no install and no build step, with every CDN URL pinned to the Astryx version you have installed — an unpinned CDN URL resolves to whatever is latest and is cached hard, so a page written today breaks tomorrow without being edited. An existing file is never clobbered;--overwritereplaces it, and--jsonreturns the receipt.The annotations are the things that are load-bearing and silent when missing:
?external=react,react-dom(without it esm.sh bundles a second React and every hook throwsCannot read properties of null (reading 'useState')),react/jsx-runtimein the import map (the published bundle imports it; omitting it fails the page withFailed to resolve module specifier), and afont-familyonbody(nothing in the stylesheets sets a document font, soButton— which isfont: inherit— otherwise renders its label in the browser's default serif).Three more lessons came out of building a real app on it. The page now
<link>s the theme's webfont from Google Fonts, because the theme names Figtree and never loads it, so every viewer silently got the fallback stack (#5015 again). It imports the theme OBJECT and wraps in<Theme theme={neutralTheme} mode="system">, so light and dark follow the OS — thedata-astryx-themeattribute alone scopes the stylesheet but cannot switch modes. And#root:emptycarries a "Loading…" state, because ESM-from-CDN has real latency and a blank page reads as broken. Markup ishtm, with a comment saying it is optional andcreateElementis the dependency-free alternative.A recipe that is only read is a recipe that is only assumed to work, so CI renders it:
.github/scripts/cdn-template-smoke-test.mjsscaffolds the page with the real CLI and opens it in headless Chromium, failing on any console error, page error or failed request, and on a page that loads without rendering. -
DateTimeInput: expose
date-time-input-toggle-icon(calendar glyph, with open/closedstate) anddate-time-input-clock-icon(leading time glyph) theme targets, so a theme can size and color the leading icons — matching thedate-input-toggle-iconseam DateInput already offers (#5148). -
defineTheme:color.accentaccepts a[light, dark]tuple (#2279)
ColorScaleConfig.accentnow takes either a single hex or a[light, dark]tuple, matchingTokenValue. With a tuple,expandColorScalederives the light half of every generatedlight-dark()pair from the light seed's palettes and the dark half from the dark seed's, so each scheme gets a consistent derived palette (muted, on-accent, neutrals) instead of thetokens['--color-accent']workaround that skips scale generation. Single-string configs are unchanged, token for token. Also documents the precedence betweencolorandtokensfor accent-derived values:tokensentries win token by token, thevar(--color-accent)reference tokens follow a--color-accentoverride at runtime, and the baked--color-on-accentstays derived from thecolor.accentseed.
Fixes
- Banner:
endContentwraps to its own row on a narrow header instead of squeezing the title to one word per line (#5116). - BottomSheet: a swipe that scrolls to the end of the sheet's content and keeps pulling now expands the sheet, instead of stopping dead at the last line. The handoff used to be decided once, when the finger landed: a gesture that started mid-content stayed a scroll for its whole life, so the natural motion — swipe up through the list, reach the bottom, keep pulling — never reached the sheet. Reaching the end of the content is now enough. The sheet is anchored at the point where the content ran out, so only the travel past it moves the sheet, and the pull is left to the content when the finger comes back down or when there is no taller detent to expand into (#5172).
- BottomSheet: an upward pull at the bottom of scrolled content no longer hijacks the gesture when the sheet is already fully expanded. It used to hand off to a sheet drag with nowhere to expand to, producing a rubber-band the release threw straight back, and — because the handoff swallows the rest of the gesture — leaving the content unscrollable until the finger lifted, so dragging back down collapsed the sheet instead of scrolling. The bottom edge now hands off only when a taller detent exists (#5161).
- BottomSheet: a sheet resting at a detent now follows the viewport. Its detents were resolved to pixels at gesture time and never revisited, so rotating the device or resizing the window left the sheet frozen at the old geometry — a half-height sheet covering three quarters of a shorter window, and a peek detent whose slide-down could exceed the new viewport entirely, leaving a modal dialog on screen with no sheet in it. Snap fractions are also read from the layout viewport now, so the mobile keyboard no longer moves the detents out from under the sheet it is measuring (#5159).
- BottomSheet keeps the page still when the mobile keyboard reveals a field the browser focused itself (#5158)
- Screen-reader announcements are now localizable. MultiSelector, Selector, Typeahead, FileInput, Tokenizer, and Lightbox spoke several live-region messages in hardcoded English — selection and result counts, file selections, token add/remove, and gallery position — so they stayed English under an
InternationalizationProvider. They now resolve through the message catalog like the rest of the UI, and the counts use ICU plurals instead of appending an English "s", so locales with other plural rules read correctly (#4920). - The editable text fields in
Selector,MultiSelector,Typeahead,DateInput,DateTimeInput,TimeInput, andNumberInputno longer misinterpret the keydown that commits or cancels an IME composition (Korean/Japanese/Chinese input) as a command. Previously a composing Enter would select/toggle the highlighted option or commit a typed date, a composing Escape would exitTypeahead's edit mode, and a composing arrow would step a time or number value — all before the composition finished. Each field now lets the IME finish first, matching the guard already in place forBaseTypeaheadand the Chat composer (#4908). - MobileNav: keep the drawer rendered until the native dialog has actually closed (#4290)
displaywas driven by theisOpenprop, which flips during the commit, whiledialog.close()only ran afterwards from an effect — so every close calledclose()on a dialog that was alreadydisplay: nonebut still open and still in the top layer, and an open modal dialog blocks the whole document whether or not it is rendered. Safari 26.1 never un-blocked it, leaving the page inert with no JavaScript error.displaynow takes part in the transition withtransition-behavior: allow-discrete, including when React's<Activity mode="hidden">hides the drawer inside AppShell, and the unmount close moves into its own effect so the deferred close is no longer cut off by its own cleanup. The close delay is derived from the hold in effect rather than assumed, because that hold is--duration-medium— a theme value, which the shipped y2k theme sets to exactly the 250ms the delay used to hard-code. SwitchwithisLabelHiddenno longer reserves the label gap. The hidden label issr-only, but its wrapper stayed a flex item, so the row still painted the 8px gap beside it: the field box measured 8px wider than the track it contains, and a hidden-label switch stopped 8px inside the edge every neighbouring control lined up on. The gap now collapses with the label, so the field is exactly as wide as the painted track — matchingCheckboxInput, which already did this (#5112).- Inputs (
statusVariant="tooltip"): the focusable status button now opens its tooltip on hover insideTextArea, whose absolutely-positioned trailing slot ispointer-events: none. Keyboard focus already worked; pointer hover did not (#5147).
Other Changes
-
Clear the mechanically fixable ESLint suppressions from the Bottom Sheet promotion:
BottomSheetandBottomSheetSwitchernow use the React 19 context APIs (<Context>as provider,use()), the panel drops its duplicate body-element ref in favor of the one the gesture hook already tracks, anduseSheetGesturesreadsprefers-reduced-motionthrough the shareduseMediaQuerysubscription so an open sheet follows a preference change (#5155). -
Remove the UMD bundle — it could not work with any React this package supports (#5068).
dist/astryx.umd.jsis no longer built or published, and with it go theunpkgandjsdelivrpackage fields, the./astryx.umd.jsexport and thebuild:umdstep.Nobody has a migration to make, because there was no working configuration to migrate from. The bundle binds Astryx to
window.Reactandwindow.ReactDOM, and React 19 does not ship a build that defines them: "UMD builds removed: To load React 19 with a script tag, we recommend using an ESM-based CDN such as esm.sh."https://unpkg.com/react@19.2.0/umd/react.production.min.jsis a 404 where 18.3.1 is a 200. OurpeerDependenciesarereact >= 19.0.0, so every supported React is one without a global for the bundle to bind to — it documented a path that never had an entrance.If you were loading it with an older React anyway, load the same components as modules instead: an import map for
react,react/jsx-runtime,react-dom,react-dom/clientand@astryxdesign/core(pinned, with?external=react,react-dom), then one<script type="module">.astryx template --cdnwrites that page for you, pinned to your installed version and annotated; the recipe is also in the core README under "No build step (CDN)". -
isImeKeyEvent— the guard that stops an IME composition keystroke being read as a command — now lives at@astryxdesign/core/utilsalongside the other pure helpers, with the reasoning for its two signals written down in one place. It stays exported from@astryxdesign/core/hooksfor this release but is deprecated there: it is a plain predicate, not a hook, and that barrel is a'use client'boundary, so importing it fromhookspulls a server-safe function onto a client path. Move imports to@astryxdesign/core/utils; thehooksre-export will be removed in an upcoming major (#4907).
@astryxdesign/cli
New Components
- Promote
BottomSheetandBottomSheetSwitcherfrom the canary-only Lab package to Core. The stable package now includes their existing native-dialog, drag-detent, transition, and mobile-keyboard behavior, plus Core documentation and examples (#5080).
New Features
-
astryx template --cdnwrites a working no-build-step CDN starter page (#5068).
A CDN starter is a template, so it joins the template family beside--skeletonrather than claiming a top-level command. It is a flag and not the positionalastryx template cdnbecause the positional resolves against everythingdiscoverAll()finds, where acdnid would shadow a discovered template.cdn.template.htmlloads Astryx from jsDelivr and esm.sh with no bundler, no install and no build step, with every CDN URL pinned to the Astryx version you have installed — an unpinned CDN URL resolves to whatever is latest and is cached hard, so a page written today breaks tomorrow without being edited. An existing file is never clobbered;--overwritereplaces it, and--jsonreturns the receipt.The annotations are the things that are load-bearing and silent when missing:
?external=react,react-dom(without it esm.sh bundles a second React and every hook throwsCannot read properties of null (reading 'useState')),react/jsx-runtimein the import map (the published bundle imports it; omitting it fails the page withFailed to resolve module specifier), and afont-familyonbody(nothing in the stylesheets sets a document font, soButton— which isfont: inherit— otherwise renders its label in the browser's default serif).Three more lessons came out of building a real app on it. The page now
<link>s the theme's webfont from Google Fonts, because the theme names Figtree and never loads it, so every viewer silently got the fallback stack (#5015 again). It imports the theme OBJECT and wraps in<Theme theme={neutralTheme} mode="system">, so light and dark follow the OS — thedata-astryx-themeattribute alone scopes the stylesheet but cannot switch modes. And#root:emptycarries a "Loading…" state, because ESM-from-CDN has real latency and a blank page reads as broken. Markup ishtm, with a comment saying it is optional andcreateElementis the dependency-free alternative.A recipe that is only read is a recipe that is only assumed to work, so CI renders it:
.github/scripts/cdn-template-smoke-test.mjsscaffolds the page with the real CLI and opens it in headless Chromium, failing on any console error, page error or failed request, and on a page that loads without rendering. -
astryx theme buildtakes any number of theme files —astryx theme build themes/*.tscompiles them all in one process, so an app with several themes no longer hand-rolls a loop that re-enters the CLI once per theme. Outputs are byte-identical to the serial invocations; the run stops at the first failure and names the theme that failed. The CLI's Node floor (>=22.13) is now declared inengines, so a package manager can enforce it at install instead of the build failing later (#5121). -
defineTheme:color.accentaccepts a[light, dark]tuple (#2279)
ColorScaleConfig.accentnow takes either a single hex or a[light, dark]tuple, matchingTokenValue. With a tuple,expandColorScalederives the light half of every generatedlight-dark()pair from the light seed's palettes and the dark half from the dark seed's, so each scheme gets a consistent derived palette (muted, on-accent, neutrals) instead of thetokens['--color-accent']workaround that skips scale generation. Single-string configs are unchanged, token for token. Also documents the precedence betweencolorandtokensfor accent-derived values:tokensentries win token by token, thevar(--color-accent)reference tokens follow a--color-accentoverride at runtime, and the baked--color-on-accentstays derived from thecolor.accentseed.
Fixes
-
Bottom Sheet showcase block: the filter checkboxes are interactive again (#5157).
CheckboxInputis fully controlled —valueis required and the input only moves when the owner updates it. The showcase passed a literalvalue={false}with noonChange, so the three filters ("In stock", "On sale", "Free shipping") rendered but could never be toggled: on the docs site the first thing a reader tries in a Bottom Sheet does nothing, and anyone copying the block inherits three dead controls. Each filter now has its ownuseStateandonChange, matching the checkbox wiring already used in the Bottom Sheet Switcher showcase. -
An integration whose manifest fails to load is no longer silent. A manifest that throws on import — the common case being one still calling a
create*authoring factory, removed in 0.3.0 — contributes nothing, and the CLI treated that as if the package had never been configured:astryx discoveransweredNo integrations configured.whileastryx.config.mjsplainly configured one, and no command said a word. The only way to find out was to already suspect it and runvalidate-integrationby name. Meta's internal@nest/xds-metasat invisible to CLI discovery for a week that way, and the app team's conclusion was that the components did not exist (#5119).
The load error now counts as an integration issue, so the existing one-line stderr nudge fires oncomponent,templateandupgrade, anddiscover— the command whose whole job is listing integrations — nudges too, as doessearch.discoveralso stops reportingconfigured: falsefor a project that configured an integration that failed to load; the empty state now distinguishes "you configured nothing" from "what you configured contributed nothing", which is the distinctionmeta.configuredwas introduced to carry.Nothing becomes fatal: the warning is best-effort, stderr-only, suppressed under
--json, and never changes an exit code. Broken contributions are still skipped exactly as before.
Contributors
Thanks to everyone who contributed to this release:
@AKnassa @cixzhang @freddymeta @imdreamrunner @jiunshinn @nynexman4464
Full Changelog: v0.4.3...v0.4.4
详细ChangeLogv0.4.3
2026年08月17日
Astryx 0.4.3 — all @astryxdesign/* packages ship at this version.
npx astryx upgrade --apply@astryxdesign/core
New Features
- New string utilities:
characterCount,firstCharacter, andtruncateCharacters— replacements for.length,.charAt(0), and slice-based truncation that measure and cut user-visible strings by whole characters, so an emoji, flag, or accented letter counts as one and never gets split. Built onIntl.Segmenterwith a code-point fallback. - ComplexSelector: support ghost toolbar triggers, leading icons, popup alignment, and an imperative
handleRef(open/close/toggle/isOpen) for programmatic control. useContainerReveal: two ways to control the reveal without reaching into the hook's private custom properties.getContainerProps({hoverDelay})gates the reveal on pointer dwell — the hover-intent idea Tooltip and HoverCard already have asdelay— so a cursor sweeping down a list no longer lights up every row it grazes, andgetContainerProps({forceState})pins the container's trigger state when something else owns the interaction (a scroll, a drag, an open row menu). Per element,getContentRevealProps({forceVisibility})pins how one child looks regardless of its container. Still CSS-only: no hover state in React, no re-render. Keyboard and touch are untouched — focus always reveals,forceState: 'inactive'andforceVisibility: 'hidden'both yield to:focus-within.
Fixes
-
Banner: a dismissed banner no longer drops focus, a custom status no longer loses its ARIA role, and the info banner paints again under the neutral theme.
Dismissing unmounted the focused dismiss button, so focus landed on<body>and a keyboard user lost their place in the page. Banner now records where focus entered from and returns it there, the same handoffToastViewportmakes for a dismissed toast. Measured in Chromium:document.activeElementwasBODY, and is now the control the user tabbed in from.BannerStatusMapis documented as augmentable, but all four status lookups were closedRecord<BannerStatus, ...>maps. Adding the augmentation the docs show produced four TypeScript errors insideBanner.tsxitself, which a consumer cannot fix, and at runtime an unknown status resolved toundefinedfor its icon, its background and its ARIA role, so the banner stopped being a live region at all. The lookups are partial now: an unrecognized status renders with no status fill, no default glyph androle="status".A theme could not reach the banner's radius.
--_banner-radiuswas declared in the doc file and inderivedVarRegistry.ts, but no rule read it, so a theme'sborderRadiuson thebannertarget expanded into a variable nothing consumed. The four card-silhouette radii read it now, falling back to--radius-container.Under
@astryxdesign/theme-neutralthe info banner had no background at all, light or dark: the override setbackground-colordirectly and forced--color-accent-mutedtotransparent, and a plain CSS property written by a theme lands in@layer astryx-theme, which StyleX's@layer priority4outranks. Info now goes through--color-accent-mutedlike the other three statuses and like the stone theme already did.Also in this change:
children={false}(the ordinary{cond && <ul/>}idiom) no longer produces an expand toggle that opens an empty box, anddescription=""no longer leaves an empty 20px row, both viaisRenderable; a long unbroken word in the title or description no longer forces the page into horizontal scrolling at a 320px viewport, measured atdocument.scrollWidth529px before; and the content area's bottom border uses logicalborder-block-endalongside its inline siblings. -
Count and cut text the way people read it: the TextArea character counter (and its over-limit state and screen-reader announcements) counts user-perceived characters — an emoji is 1, not 2; PowerSearch token truncation no longer cuts an emoji or accented letter in half; Table's auto-generated headers capitalize astral-plane letters correctly; Avatar's initials now use the shared character utilities.
-
ComplexSelector: honor the
sm,md, andlgelement-height tokens exactly. -
TreeList's
variantaxis is themeable, and a new guard keeps every extensible axis honest.TreeListVariantMapinvites theme packages to add variants — its own JSDoc shows the module augmentation — butthemeProps('tree-list', {density})never passedvariant, so a custom variant type-checked, rendered, and produced no selector to style. It is passed now, and documented in the target'svisualPropssoastryx theme buildstops calling it an unknown prop.
packages/core/src/theme/extensibleAxes.test.tsis the third theming-drift guard, beside the ones coveringtargetsandvars/derived. Those two check what a component renders against what it documents; neither looked at the open prop unions, which is why this went unnoticed. For every*Mapthat types a component prop, it now asserts the three places that have to agree: the interface is declared in the index a consumer augments (a re-export is invisible to both module augmentation and the CLI), the prop is reflected throughthemeProps, and it is documented as a visual prop. It reads the TypeScript AST rather than the type checker, and holds the map's OWNER accountable — a component forwardingactionVariantorstatusVariantto the component that owns the map is not separately responsible for it.Registry maps that widen a set of NAMES rather than a visual prop (
IndicatorMap,IndicatorFamilyMap) are out of scope by construction, not by allowlist: the guard only considers maps whose alias types a prop on a*Propsinterface. -
Security: reject
javascript:,vbscript:anddata:text/htmlURLs in the Markdown parser, so untrusted markdown can no longer produce an executable link href or image src; and fixescapeRegExpinChatTokenizedText, whose character class closed early and left]and\unescaped, so token values containing them were injected raw into aRegExp -
extendsnow reaches the CSS. A theme that extended another built a stylesheet holding only the declarations it stated itself: the base's tokens, component overrides and surface rules were all absent, and because each theme is@scoped to its owndata-astryx-themevalue, loading the base's stylesheet alongside could not fill the gap either. Every consumer of an inheritance chain silently got stock geometry, elevation and type with a new palette painted over it (#5067). Nothing warned; the loss only showed up by diffing two generated stylesheets token by token.
The cause wastheme buildshadowing its own inputs. It writes<name>.jsnext to<name>.ts, and the loader resolved a plain./<name>specifier to that generated artifact before the source — so the second build of a family read the artifact, which carries nocomponentsand exports<name>Themerather than whatever the source exports. A named import that missed becameextends: undefined, anddefineThemetreated an absent base as no base at all. The loader now resolves source extensions first, which is also the resolution the author's TypeScript sees, so the CSS a build emits matches the theme that type-checked.Three things behind it are fixed too, so the failure cannot come back by another route.
defineThemethrows whenextendsis present but is not a theme, naming the likely cause, instead of inheriting nothing — the one behavior change here, and it turns a silent stylesheet into a build error. A theme'sonDark/onLightsurfaces and its__inputTokensare now inherited like its tokens and components were, so a child no longer reverts its base's inverted-surface customizations to the defaults or loses its[light, dark]tuples. And a built theme module now carries the resolvedcomponentsand surfaces alongside its tokens, so extending one — the./builtsubpath every shipped theme exposes — is no longer lossy.theme buildalso stopped hand-picking fields when it re-resolves a plain object theme file, which droppedextends,colorandsyntaxon the way in.An extended theme is flat: everything it inherits is resolved into its own output, and its stylesheet stands alone. Measured on a 14-theme family (one base, 13 palettes extending it): each palette went from 25 custom properties and no component rules to the base's full 175 and 70, with its own colours still winning.
@astryxdesign/cli
Fixes
-
The unloaded-font advisory is a notice, not a warning. A theme file cannot load a font — Astryx sets
--font-family-*and loading is the app's job — so #5045's advisory fires on any theme naming a webfont, including a perfectly correct one. As a warning that made a clean build read as defective, and it put the shipped template permanently in violation of its own "compiles with no warnings" guard (#5079 had to allowlist the template's two font names in that assertion).
Thetheme.buildreceipt now separates the two:warningsare defects the author should fix,noticesare advisories about a correct theme. The font advisory moves tonoticesand to stdout with the rest of the build's progress; stderr stays for defects. The template guard is back towarningsbeing empty, and no longer needs to know which fonts the template names.Programmatic callers reading
data.warningsfor font advisories should readdata.notices; the message text is unchanged. -
extendsnow reaches the CSS. A theme that extended another built a stylesheet holding only the declarations it stated itself: the base's tokens, component overrides and surface rules were all absent, and because each theme is@scoped to its owndata-astryx-themevalue, loading the base's stylesheet alongside could not fill the gap either. Every consumer of an inheritance chain silently got stock geometry, elevation and type with a new palette painted over it (#5067). Nothing warned; the loss only showed up by diffing two generated stylesheets token by token.
The cause wastheme buildshadowing its own inputs. It writes<name>.jsnext to<name>.ts, and the loader resolved a plain./<name>specifier to that generated artifact before the source — so the second build of a family read the artifact, which carries nocomponentsand exports<name>Themerather than whatever the source exports. A named import that missed becameextends: undefined, anddefineThemetreated an absent base as no base at all. The loader now resolves source extensions first, which is also the resolution the author's TypeScript sees, so the CSS a build emits matches the theme that type-checked.Three things behind it are fixed too, so the failure cannot come back by another route.
defineThemethrows whenextendsis present but is not a theme, naming the likely cause, instead of inheriting nothing — the one behavior change here, and it turns a silent stylesheet into a build error. A theme'sonDark/onLightsurfaces and its__inputTokensare now inherited like its tokens and components were, so a child no longer reverts its base's inverted-surface customizations to the defaults or loses its[light, dark]tuples. And a built theme module now carries the resolvedcomponentsand surfaces alongside its tokens, so extending one — the./builtsubpath every shipped theme exposes — is no longer lossy.theme buildalso stopped hand-picking fields when it re-resolves a plain object theme file, which droppedextends,colorandsyntaxon the way in.An extended theme is flat: everything it inherits is resolved into its own output, and its stylesheet stands alone. Measured on a 14-theme family (one base, 13 palettes extending it): each palette went from 25 custom properties and no component rules to the base's full 175 and 70, with its own colours still winning.
@astryxdesign/theme-butter
Fixes
- The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
@astryxdesign/theme-chocolate
Fixes
- The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
@astryxdesign/theme-gothic
Fixes
- The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
@astryxdesign/theme-matcha
Fixes
- The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
@astryxdesign/theme-neutral
Fixes
-
Banner: a dismissed banner no longer drops focus, a custom status no longer loses its ARIA role, and the info banner paints again under the neutral theme.
Dismissing unmounted the focused dismiss button, so focus landed on<body>and a keyboard user lost their place in the page. Banner now records where focus entered from and returns it there, the same handoffToastViewportmakes for a dismissed toast. Measured in Chromium:document.activeElementwasBODY, and is now the control the user tabbed in from.BannerStatusMapis documented as augmentable, but all four status lookups were closedRecord<BannerStatus, ...>maps. Adding the augmentation the docs show produced four TypeScript errors insideBanner.tsxitself, which a consumer cannot fix, and at runtime an unknown status resolved toundefinedfor its icon, its background and its ARIA role, so the banner stopped being a live region at all. The lookups are partial now: an unrecognized status renders with no status fill, no default glyph androle="status".A theme could not reach the banner's radius.
--_banner-radiuswas declared in the doc file and inderivedVarRegistry.ts, but no rule read it, so a theme'sborderRadiuson thebannertarget expanded into a variable nothing consumed. The four card-silhouette radii read it now, falling back to--radius-container.Under
@astryxdesign/theme-neutralthe info banner had no background at all, light or dark: the override setbackground-colordirectly and forced--color-accent-mutedtotransparent, and a plain CSS property written by a theme lands in@layer astryx-theme, which StyleX's@layer priority4outranks. Info now goes through--color-accent-mutedlike the other three statuses and like the stone theme already did.Also in this change:
children={false}(the ordinary{cond && <ul/>}idiom) no longer produces an expand toggle that opens an empty box, anddescription=""no longer leaves an empty 20px row, both viaisRenderable; a long unbroken word in the title or description no longer forces the page into horizontal scrolling at a 320px viewport, measured atdocument.scrollWidth529px before; and the content area's bottom border uses logicalborder-block-endalongside its inline siblings. -
The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
@astryxdesign/theme-stone
Fixes
- The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
@astryxdesign/theme-y2k
Fixes
- The
/builtentry now loads under Node ESM and externalized SSR (Vite--ssr, Remix / React Router v7): it imports./icons.mjsinstead of the extensionless./iconsNode cannot resolve.
Contributors
Thanks to everyone who contributed to this release:
@AKnassa @cixzhang @ernestt @Sunil56224972
Full Changelog: v0.4.2...v0.4.3
详细ChangeLogv0.4.2
2026年08月16日
Astryx 0.4.2 — all @astryxdesign/* packages ship at this version.
Note on this tag. The published tarballs were built from
b2057d1rather than the
version-bump commit, so 0.4.2 also contains #5084
(useContainerRevealhover-intent and forced reveal state), which is not described
below — its changelog entry will appear under 0.4.3. The tag points at the commit that
was actually published, sogit checkout v0.4.2matches what is on npm.
npx astryx upgrade --apply@astryxdesign/core
New Features
-
AvatarGroup: expose
sizeon theavatar-group-overflowtheming target so themes can style the "+N" overflow chip per size (matchingavatar-fallback); the default chip font is unchanged (#5046). -
Chat: ChatMessageBubble accepts a
widthprop (numbers are pixels, strings pass through, e.g.width="100%"), following the sizing convention on Card and other containers. When set it replaces the bubble's defaultmax(80%, 280px)width cap; when unset nothing changes. Combined withvariant="ghost", custom in-message content (an artifact card, attachment chips, a standalone ChatMessageMetadata) can now align with the bubble's text column at the full message-column width — previously the only workaround was hardcoding the bubble's private padding token at every call site. (#2574) -
astryx theme templatewrites an annotated theme template into your project (#5048).
New sibling oftheme add: whereaddstarts you from a theme we ship,templatestarts you from a blank annotated one.astryx init --features themecalls the same leaf, so project setup writes it too — it previously printed a one-line hint and wrote nothing, which is the weakest form of the help a theme author needs, since the first problem is not knowing the command but not knowing what the theme surface contains. The file istheme.template.ts: everydefineThemefield with a note on when to reach for it, the token families, the component override syntax, and the consumption steps (providing the theme, loading the fonts you name, building for SSR), each section naming the CLI command that prints its authoritative reference. An existing file is never clobbered.This came out of a vibe test (#5047): agents given an annotated template reached twice as far into the theme surface as agents given only the docs (17 component targets vs 8, and the only arm to use interaction states, custom variants and
onDark), and shipped a third of the contrast defects.A template that lies is worse than no template, so its claims are machine-checked against live sources rather than trusted:
scripts/check-theme-template.test.mjsfails when adefineThemefield is added and left undocumented, when a token family is missing from the inventory, when a CSS variable or component key it names does not exist, when it cites a docs topic that does not, or when a theme source drops its SYNC reference.theme buildcompiles it warning-free in CI, and the CLI typecheck now covers it.
Fixes
-
Avatar: put the avatar box on the element that carries the
astryx-avatartheme target, so a theme rule on the documentedsizeaxis resizes the whole avatar instead of growing the wrapper around a fixed-size circle; treat a whitespace-onlynameoraltas absent, so it falls through to the default icon rather than rendering an empty plate behind a blank accessible name; warn through the shareduseDevWarninghook rather than a bareconsole.warnin the render body; and replace the phantom<OnlineIndicator />in the JSDoc example with the realAvatarStatusDot(#5030) -
CommandPaletteFooter: wire default keyboard-hint strings through useTranslator so they resolve from the locale catalog instead of being hardcoded English (#4506)
-
context-menucomponent overrides now drive the menu's internal radius and padding vars.ContextMenu.doc.mjshas always documentedderivedentries mappingborderRadius→--_dropdown-menu-radiusandpadding→--_dropdown-menu-padding, butderivedVarRegistryhad nocontext-menukey, so the mapping was dead:components: {'context-menu': {base: {borderRadius: '12px'}}}emittedborder-radiusalone and the menu kept reading its ownvar(--_dropdown-menu-radius). The registry entry now matches the doc, as it already does fordropdown-menu(#4783). -
useFocusTrap: a modal surface with no tabbable controls keeps its programmatic focus target instead of letting Tab escape into the page behind it. A dialog that places initial focus on atabIndex={-1}heading or panel had nowhere to advance to, so Tab walked straight out of the trap.@astryxdesign/core/hooksalso exportshasActiveFocusTrapEscapeandisImeKeyEvent, which coordinate nested traps and skip IME composition keys (#5023). -
Heading's
typeis a documented theming target, and the docs stop teaching a CSS variable that does not exist (#5016).
Headingreflectstypeas a theme selector —typography.scalegeneratesheading: {'type:display-1' …}rules for it — buttheming.targetslisted onlylevelandcolor, soastryx theme buildwarnedUnknown prop "type" on component "heading"on every theme that sets a type scale, including the shippedneutralTheme. The drift guard missed it twice over: it read a conditional spread ({level, color, ...(type && {type})}) as an unknown bag, and it only checked a component against a doc file in its own directory, soHeading/— documented fromText/Text.doc.mjs— was never checked at all. Both are fixed, which brings three more previously unchecked directories under the guard.Separately, the theme docs' component-override example set
--button-press-scale, which no component defines: copying it produces CSS that silently never applies. It now sets a real public var, and the example no longer declares the samebuttonkey twice. -
DateTimeInput: the focused-and-empty time placeholder hints ("e.g., 2:30 PM" / "e.g., 14:30") now route through the i18n translator so they localize with the rest of the component. Adds
@astryx.dateTimeInput.timeHint12hand@astryx.dateTimeInput.timeHint24hto theencatalog. The live-region "Invalid date" / "Invalid time" announcements this PR also covered landed first in #4363 and now reuse that PR's@astryx.dateInput.invalidDateand@astryx.timeInput.invalidTimekeys. (#4546) -
Floating layers now declare their own body type instead of inheriting it. The layer container already set
font-family; it now setsfont-sizeandline-heightfrom--text-body-size/--text-body-leadingalongside it. A layer is hosted wherever its trigger sits, so any content that did not set its own size took the ambient one — the same Tooltip, Popover or HoverCard rendered at 13px from a caption and at 20px from a lede. Content that goes throughText, or sets a size itself (Tooltip's label, DropdownMenu items, NavMenu headings), is unaffected: those already declared their own and still win. Anything that was relying on inheriting a non-body size now renders at the body size and should set one explicitly (#5064). -
Added a
@astryx.listInput.*catalog namespace topackages/core/locales/en.jsonso the labListInputcomponent's action labels, empty state, reorder instructions, and live announcements can be translated.ListInputpreviously hardcoded every visible and assistive-technology-facing string (#4967). -
Layer: use an inert
<template>marker to find each context layer's actual JSX position. Safe positions stay inline; positions inside a paragraph, link, button, inline formatting, or a structurally restricted container portal to the nearest safe ancestor. Corrective portals keep CSS custom properties inheriting from that nearby host while preserving direction and writing mode, andshow()passes the trigger as the popover's invokersource. The newlazyMountoption waits until opening to resolve and mount content; HoverCard uses it so rich content never enters an invalid paragraph during initial render and unmounts again when hidden. Other context layers keep their existing closed-content behavior (#5039). -
Two guards left failing on
mainby their own landings, so every PR since has been red through no fault of its own. #4963 gave Thumbnail's remove button a coarse-pointer hit-area var and did not document it, which the derived-var guard reads as an undocumented private var; the var is aninseton a::afteroverlay, so it is documented as private and listed alongside the other vars no standard CSS property maps onto. #5026 movedborderDefaultsintoCoreTokenName— the landing the theme-template guard was explicitly waiting for (its comment says "when #5017 lands, this guard starts requiring the template to cover it") — so the template's token inventory now names--border-width. -
Menus that open on hover no longer close when you click them. A hover-opened menu is already open under the cursor by the time the pointer arrives, so the click that naturally follows was dismissing it — fixed for TopNavMegaMenu in #4555, and now shared: the hover→click guard lives in
useMenuHover, so TopNavMenu, TopNavHeading, SideNavHeading and DropdownMenuSubMenu get it too, and TopNavMegaMenu runs on the shared machine instead of its own copy. Also from the consolidation: opening a menu moves focus into it synchronously rather than a frame later, closing one returns focus to its trigger instead of dropping it to the document, and keyboard activation always opens rather than toggling an open menu shut (#3121) -
SideNav: a hardening pass over the family, driven by the component audit. Accessibility, theming, passthrough and code-health defects across
SideNavItem,SideNavHeading,SideNavSection,SideNavCollapseButtonand thenavItemStylesmodule the TopNav drawer modes share — the motion guards, the untranslated flyout name, the hand-rolled visually-hidden block, the dropped...rest, the missing theming state, the uncleaned timers, and the hand-rolled hover intent, which is now the shareduseMenuHover. Nav rows also adopt the shared focus outline from #4654, so a keyboard-focused row is ringed with the system's2px --color-accentat3pxoffset in every theme instead of falling through to the browser's own ring; in a split-action row the link and the chevron toggle are ringed individually, since they are separate tab stops.
Three visual fixes came out of review. The collapsed submenu flyout was painting a second, square-cornered surface inside the popover's rounded one, and insetting its own content by 4px instead of standing off the rail — both gone, with the gap moved to the positioned layer whereDropdownMenukeeps it. The selected row now survivesforced-colors: active: it marked the current page with a 6% background tint, which forced colors flatten away entirely, and it now paintsHighlight/HighlightTextlikeToggleButtonandSegmentedControlItem. And the footer icon row comes out one size, with the collapse chevron centred rather than seated 2.42px high on a stray text baseline.Four changes are visible to a consumer. Hover on a collapsed item's flyout is now gated on
(hover: hover)and only closes onmouseleaveif hover opened it, and a click-to-dismiss no longer springs back open under a stationary pointer. The footer icon rows cascade asmsize throughSizeContext, so an unsizedButtonpassed tofooterIconsnow matches the built-in collapse button instead of rendering a size larger — pass an explicitsizeto opt out.SideNavCollapseButtontakes asize, for placements outside the nav that have no row to inherit from. AndSideNavCollapseButtontakes the controlledcollapsibleconfig — the same{isCollapsed, onCollapsedChange}object handed to SideNav — which is how a button rendered outside the sidenav now stays in step with it.handleRefon both components is deprecated in its favour: the state the consumer already owns reaches the button through props, with no imperative handle in between. -
Slider: the thumb no longer overhangs the component's own box at
minandmax. It was centred on the container edge at either extreme, leaving half of it (10px) outside the control, where a tight container clipped it or it overlapped the next element. Thumb travel is now inset by half a thumb at each end — the geometry a nativeinput[type=range]uses — and the fill, the marks and the pointer-to-value mapping share that inset, so the thumb also stays under the pointer that grabbed it instead of jumping by up to half its width. Vertical sliders and both thumbs of a range slider are fixed the same way (#5051). -
Interactive controls meet the WCAG 2.5.8 AA 24px minimum on touch. The Slider track (20px tall, and clickable along its whole length) floors its block size to 24px, Thumbnail's remove button grows its tappable area through a
::afteroverlay, andsmCheckboxInput, RadioListItem and Switch floor to a 24px target centred on the control. All of it is gated on@media (pointer: coarse), and only the invisible tappable area changes — rails, thumbs and glyphs stay exactly where they were, and fine-pointer rendering is untouched (#4963, #4964).
Documentation
- Ten private (
--_*) component theming vars are now documented in their owning component'stheming.vars[]:--_avatar-group-overlap,--_card-elevation,--_card-ring,--_codeblock-gutter-width,--_item-label-color,--_item-description-color,--_tab-indicator-bottom,--_tree-indent(plus--_dropdown-menu-radius/--_dropdown-menu-padding, which Breadcrumbs sets on a child menu). They were declared in source and described nowhere, because the drift guard skipped the--_prefix outright (#4783).
@astryxdesign/cli
New Features
-
astryx theme buildwarns when a theme names fonts it does not load. The resolved--font-family-*tokens and component-overridefontFamilyvalues are checked against CSS generics and known system families; anything else gets one warning per family in the receipt and, after the install instructions, the<link>/@font-facesnippet to add.astryx docs typographygains a Loading Custom Fonts section (Google Fonts and self-hosted recipes,font-display: swap, real fallback stacks), and the theme docs' production-build section points at it (#5015). -
astryx theme templatewrites an annotated theme template into your project (#5048).
New sibling oftheme add: whereaddstarts you from a theme we ship,templatestarts you from a blank annotated one.astryx init --features themecalls the same leaf, so project setup writes it too — it previously printed a one-line hint and wrote nothing, which is the weakest form of the help a theme author needs, since the first problem is not knowing the command but not knowing what the theme surface contains. The file istheme.template.ts: everydefineThemefield with a note on when to reach for it, the token families, the component override syntax, and the consumption steps (providing the theme, loading the fonts you name, building for SSR), each section naming the CLI command that prints its authoritative reference. An existing file is never clobbered.This came out of a vibe test (#5047): agents given an annotated template reached twice as far into the theme surface as agents given only the docs (17 component targets vs 8, and the only arm to use interaction states, custom variants and
onDark), and shipped a third of the contrast defects.A template that lies is worse than no template, so its claims are machine-checked against live sources rather than trusted:
scripts/check-theme-template.test.mjsfails when adefineThemefield is added and left undocumented, when a token family is missing from the inventory, when a CSS variable or component key it names does not exist, when it cites a docs topic that does not, or when a theme source drops its SYNC reference.theme buildcompiles it warning-free in CI, and the CLI typecheck now covers it.
Fixes
-
Heading's
typeis a documented theming target, and the docs stop teaching a CSS variable that does not exist (#5016).
Headingreflectstypeas a theme selector —typography.scalegeneratesheading: {'type:display-1' …}rules for it — buttheming.targetslisted onlylevelandcolor, soastryx theme buildwarnedUnknown prop "type" on component "heading"on every theme that sets a type scale, including the shippedneutralTheme. The drift guard missed it twice over: it read a conditional spread ({level, color, ...(type && {type})}) as an unknown bag, and it only checked a component against a doc file in its own directory, soHeading/— documented fromText/Text.doc.mjs— was never checked at all. Both are fixed, which brings three more previously unchecked directories under the guard.Separately, the theme docs' component-override example set
--button-press-scale, which no component defines: copying it produces CSS that silently never applies. It now sets a real public var, and the example no longer declares the samebuttonkey twice. -
Two guards left failing on
mainby their own landings, so every PR since has been red through no fault of its own. #4963 gave Thumbnail's remove button a coarse-pointer hit-area var and did not document it, which the derived-var guard reads as an undocumented private var; the var is aninseton a::afteroverlay, so it is documented as private and listed alongside the other vars no standard CSS property maps onto. #5026 movedborderDefaultsintoCoreTokenName— the landing the theme-template guard was explicitly waiting for (its comment says "when #5017 lands, this guard starts requiring the template to cover it") — so the template's token inventory now names--border-width.
Documentation
- MobileNavToggle preview simulates a mobile AppShell instead of an empty stage: new playground.appShellMobile for components that render nothing without AppShell mobile context (#4983)
@astryxdesign/theme-butter
Fixes
--radius-noneno longer overrides to0.125rem.--radius-noneand--radius-fullare documented as always fixed (never scaled by a theme), matching@astryxdesign/core's own defaults — each of these themes' radius group bumps swept--radius-nonealong with it by mistake, the same bug fixed fortheme-neutralin #4856. Anything opting out of rounding via--radius-noneunder these themes now renders with a true0pxradius again, instead of a silent 2px.
@astryxdesign/theme-chocolate
Fixes
--radius-noneno longer overrides to0.125rem.--radius-noneand--radius-fullare documented as always fixed (never scaled by a theme), matching@astryxdesign/core's own defaults — each of these themes' radius group bumps swept--radius-nonealong with it by mistake, the same bug fixed fortheme-neutralin #4856. Anything opting out of rounding via--radius-noneunder these themes now renders with a true0pxradius again, instead of a silent 2px.
@astryxdesign/theme-gothic
Fixes
--radius-noneno longer overrides to0.125rem.--radius-noneand--radius-fullare documented as always fixed (never scaled by a theme), matching@astryxdesign/core's own defaults — each of these themes' radius group bumps swept--radius-nonealong with it by mistake, the same bug fixed fortheme-neutralin #4856. Anything opting out of rounding via--radius-noneunder these themes now renders with a true0pxradius again, instead of a silent 2px.
@astryxdesign/theme-stone
Fixes
--radius-noneno longer overrides to0.125rem.--radius-noneand--radius-fullare documented as always fixed (never scaled by a theme), matching@astryxdesign/core's own defaults — each of these themes' radius group bumps swept--radius-nonealong with it by mistake, the same bug fixed fortheme-neutralin #4856. Anything opting out of rounding via--radius-noneunder these themes now renders with a true0pxradius again, instead of a silent 2px.
Contributors
Thanks to everyone who contributed to this release:
@AKnassa @cixzhang @freddymeta @HelloOjasMutreja @imdreamrunner @is-jain @jiunshinn @rubyycheung
Full Changelog: v0.4.1...v0.4.2
详细ChangeLogv0.4.1
2026年08月15日
Astryx 0.4.1 — all @astryxdesign/* packages ship at this version.
npx astryx upgrade --apply@astryxdesign/core
New Features
- The keyboard focus ring is now a theme token.
--focus-outline-width,--focus-outline-style,--focus-outline-colorand--focus-outline-offsetdrive every ring in core and lab, so one override in a theme'stokensrestyles focus system-wide; the color tracks--color-accentunless a theme sets it. The:focus-visiblecondition is not themeable, so a themed ring still cannot appear for pointer users (#4973).
Every ring is now drawn from the shared focus-outline utility rather than written out per component, and a lint rule keeps it that way. Two corrections come with that: the rings that had drifted to a 2px offset (Slider, Switch, Lightbox, ProgressBar, and lab's InfoTip, Step and LogStream) now sit at the documented 3px, and the buttons inside a field — the Date, DateRange and DateTime calendar toggles, the DateRange presets, and the Selector and MultiSelector status buttons — draw the standard 2px ring instead of a 1px one. - AspectRatio, Badge, Blockquote, Card, Center, Code, Grid, Section, Skeleton and VisuallyHidden no longer carry
'use client'(#823). Each was verified against its transitive import graph to use no React client API, no client-only dependency and no module-level mutable state, so they can now render in a React Server Component without forcing a client boundary. A newserverSafeComponents.test.tsderives the server-safe set from the import graph and fails if one of these components later gains a client dependency without restoring the directive — including the transitive casescripts/check-use-client.mjscannot see.
Not a breaking change: no prop, type or export changed, and'use client'is inert outside an RSC bundler. Client consumers keep working identically, though bundlers may lay these modules out in different chunks now that they are no longer client entry points. - Selector and MultiSelector:
indicatorPositionplaces the selection indicator on either edge of the option row —startorend, logical, so it follows RTL. Defaults keep today's rendering (endfor Selector's check,startfor MultiSelector's checkbox); a start-positioned check reserves its column on every row so labels stay aligned (#4993).
Fixes
-
TimeInput: announce arrow-key time stepping via the polite live region (also in DateTimeInput), localize the "Invalid date"/"Invalid time" live-region messages through the i18n catalog, and use long timezone names in Timestamp's AT-facing aria-label while keeping the short form visible (#4363)
-
Banner: the 'banner-icon' theme target now rides on the default status Icon itself instead of its layout wrapper, so theme component overrides ('banner-icon' + 'status:X') that set color actually reach the glyph. The Icon keeps its existing color variant (info still renders accent) and same-element rules in @layer astryx-theme win over it, so default rendering is unchanged. Contract note: '.astryx-banner-icon' now matches the icon element rather than the wrapper when the default icon renders; a theme that used the target for wrapper layout (margin, alignment) now styles the glyph instead. With a custom
iconnode the target stays on the layout-only wrapper, since core never injects props into consumer elements (#4166) -
CommandPalette: discard in-flight search responses when the palette closes (#3896)
Closing the palette while a search was still in flight let the late response re-commit the abandoned query and results into the closed palette, which showed up as a ghost query on reopen. Closing now invalidates any pending request. -
FileInput: validation messages, default placeholder, drag hint, and file-selected announcements now go through the i18n translator instead of hardcoded English. DropdownMenuRadioGroup: consumer
xstyleprop is composed into styles instead of being dropped (#4589). -
The popup theme targets added in #4991 sat on the wrong element.
astryx-complex-selector-popupandastryx-multi-selector-popupwere rendered on each component's own content box — the one with the padding and the scroll — while the element that paints the popup's background, radius and elevation is the surfaceusePopovercreates one level above it. A theme reaching for those classes to restyle a popup got a rule that could not paint it. Both now land on the surface, so they do what they were documented to do.
Selectorgains the matchingastryx-selector-popup, which its siblingMultiSelectorhad and it did not.New: every popup surface carries the shared
astryx-popover-surfaceclass, so a theme can style all of them at once, andusePopoveraccepts asurfaceTargetnaming the surface for a component that wants its own target there. A component cannot do this for itself — the surface belongs tousePopover, so any class it renders itself lands inside. -
Selector's menu now clears the trigger by the standard
--spacing-1gap whenever it is not overlaying it — every explicitplacement, and search mode. It was the only anchored menu in the system sitting flush against its anchor; DropdownMenu, MultiSelector, ComplexSelector, Popover, and Tooltip all use this clearance. The default selected-item overlay is unchanged: it owns its block geometry and is meant to sit on the trigger (#5003). -
Selector, MultiSelector: the dropdown panel's search field is now part of the panel instead of a bordered input dropped into it. The panel is already a bordered, elevated surface, so the nested
TextInputdrew a box inside a box; the row now renders a leading magnifier, a borderless input, and the shared clear (✕) button, with a full-bleed divider between it and the options — the same shape the command palette already uses. Focus is shown as an inset ring on the row, rounded to the panel's own corners. Section titles move from labeled dividers to plain secondary headings, matching DropdownMenu and CommandPaletteGroup, and MultiSelector no longer draws a rule under select-all. Behavior, keyboard handling, and accessible names are unchanged; MultiSelector's search row additionally stays put while the options scroll under it. New theme targets:astryx-selector-search,astryx-selector-section-heading,astryx-multi-selector-search,astryx-multi-selector-section-heading; anything that styled the dropdown search throughastryx-text-inputneeds to move to those. -
TableRow: honor
classNameandstyleon the<tr>.TableRowPropsextendsBaseProps, but both were spread beforemergeProps()and then overwritten by the component's own StyleX classes, so a consumer's values silently had no effect. They are now merged throughmergeProps()alongside the row's StyleX styles, the same wayTableCellandTableHeaderCellalready handle them, in both the in-Tableand standalone rendering paths. The Astryx theme classes and striped/hover styling are unchanged (#4391).
@astryxdesign/cli
Fixes
astryx theme buildno longer warnsUnknown propfor documented state override keys. Component docs declare state-driven selectors undertheming.targets[].states(radio→checked/disabled,calendar-day→today/selected, …), but override validation only loadedvisualProps, so the state syntax the Theming Infrastructure wiki documents —components: {radio: {checked: {...}}}— warned on every build. The CSS was always generated correctly; only the warning was wrong. 30 targets across core were affected (#4778).
Contributors
Thanks to everyone who contributed to this release:
@AKnassa @arham766 @bhamodi @cixzhang @Eloitor @jiunshinn
Full Changelog: v0.4.0...v0.4.1
详细ChangeLogv0.4.0
2026年08月14日
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
详细ChangeLogv0.3.0
2026年08月06日
@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
详细ChangeLogv0.2.0
2026年07月31日
@astryxdesign/core
Breaking Changes
- TabList: remove orientation prop (misleading no-op). The prop did not render vertical tabs; it only toggled the keyboard-hint badge arrows. Arrow navigation has always accepted both axes (horizontal and vertical) via orientation: both in useListFocus. Run astryx upgrade to auto-strip the prop from your code.
New Features
Avatargains optional interactivity viahref/onClick(withas/target/rel), following Button's element-swap trichotomy:hrefrenders a link throughuseLinkComponent,onClick(no href) renders a<button type="button">, and with neither the avatar stays the static, non-focusable element it is today (non-breaking default). Interactive avatars get the focus-visible accent ring and a required accessible name (fromalt/name). InsideAvatarGroup, interactive avatars — and an interactiveAvatarGroupOverflow— now share a single Tab stop with roving ArrowLeft/ArrowRight focus, and the group exposes a screen-reader keyboard hint viaaria-describedby. A purely static facepile is unchanged. (#4170)- Citation:
CitationSource.iconnow accepts aReactNode(e.g. an Astryx<Icon>, an SVG, or a custom element) in addition to an image URL string, and a newCitationSource.srcfield holds a favicon/logo image URL (mirroringAvatar/Thumbnail). Additive and non-breaking: a stringiconstill renders as the favicon<img>, so existing callers are unaffected. When both a nodeiconandsrcare set, the node wins. The icon stays decorative — the accessible name still comes solely from the citation'saria-label. - CodeBlock: move the collapse chevron to the left of the title/language label, following the leading-disclosure convention (points right
>when collapsed, downvwhen expanded). It grows into place (width + inline margin) so it slides the title over smoothly instead of popping in and shifting the header. Respectsprefers-reduced-motion(#4513) - Collapsible: expose the trigger button as a distinct theming target (
astryx-collapsible-trigger) so themes can style the trigger independently from the content — e.g. a heading font on the trigger while the content keeps the body font. - DateInput: add
astryx-date-input-clear-iconandastryx-date-input-toggle-icontheme targets on the clear and calendar-toggle glyphs, so consumers can recolor, resize, and hover-style each icon — and style the toggle's open/closed state — viadefineThemeinstead of a fragile descendant selector or raw CSS. The toggle reflects its open/closed state as adata-stateattribute.Iconnow fully handles its styling props (className,style,xstyle) so they compose with its base styles instead of being dropped. Default rendering is unchanged. - DateRangeInput: add
astryx-date-range-input-clear-iconandastryx-date-range-input-toggle-icontheme targets on the clear and calendar-toggle glyphs, so consumers can recolor, hover-morph, and resize them viadefineThemeinstead of a fragile descendant selector or raw CSS. The toggle icon reflects its open/closed state as adata-stateattribute.Iconnow fully handles its styling props (className,style,xstyle) so they compose with its base styles instead of being dropped. Default rendering is unchanged. - DateTimeInput: add
astryx-date-time-input-date-segmentandastryx-date-time-input-time-segmenttheme targets on the two segment wrappers, so a theme can restyle their geometry (padding/height/font) viadefineThemeinstead of being unable to reach them at all. Both reflectsizeandstatusas data attributes, mirroring the root target. Default rendering is unchanged. - DropdownMenu: add submenus via a single
DropdownMenuSubMenucomponent (or a nesteditemsarray in data mode). The row adopts DropdownMenuItem semantics (label / icon / description / isDisabled) and its children — or anitemsarray — become the flyout content. Flyouts open inline-end with auto-flip, hover-intent, and full keyboard support (Right/Enter/Space opens and focuses the first item; Left/Escape closes and returns focus to the trigger). - Bordered inputs gain a
statusVariant="tooltip"option that hides the status message box and surfaces the status as an info-tip on the on-field status icon. The icon is a real focusable button so the status is reachable by everyone: keyboard users tab to it (with a visible focus ring) and see the message on focus, pointer users see it on hover, and touch users tap to toggle it. The message is piped into both the input's and the button'saria-describedby, and the tooltip is dismissible with Escape. Added to TextInput, TextArea, NumberInput, DateInput, DateRangeInput, TimeInput, and FileInput. - The bordered input family now accepts a
statusVariantprop ('attached' | 'detached', default'attached') that forwards to the underlyingField, letting you float the status message below the input with spacing instead of overlapping it. Added to TextInput, TextArea, NumberInput, DateInput, DateRangeInput, TimeInput, Selector, MultiSelector, Typeahead, Tokenizer, FileInput, and PowerSearch. Non-breaking: the default matches today's behavior. (#4187) - Add RTL direction API:
useDirection()hook,getLocaleDirection(locale)server-safe helper, and an optionaldirprop onInternationalizationProvider. - RTL: mirror directional disclosure/navigation chevrons under RTL via a shared
rtlStyles.mirrorCSS transform, applied to the icon wrapper in Lightbox and the Table tree / grouped-rows / row-expansion plugins. The mirror composes correctly with the Table chevrons' state-rotation (expanded chevrons still point down under RTL). Semantic aria-labels are unchanged. - Selector & MultiSelector: add
astryx-selector-clear-icon,astryx-selector-indicator-icon,astryx-multi-selector-clear-icon, andastryx-multi-selector-indicator-icontheme targets on the clear and chevron glyphs, so consumers can recolor, resize, and hover-style each icon — and style the chevron's open/closed state — viadefineThemeinstead of a fragile descendant selector or raw CSS. Each chevron reflects its open/closed state as adata-stateattribute.Iconnow fully handles its styling props (className,style,xstyle) so they compose with its base styles instead of being dropped. Default rendering is unchanged. - add size prop (sm / md) to Switch to match CheckboxInput and RadioList boolean control scales (#4230)
- Table:
rowIndexStartandrowCountprops expose row numbering as a table-level ARIA concern, soaria-rowindex/aria-rowcountreflect a row's position in the full dataset: correct across pagination and even when no visible index column is rendered. Opt-in; tables that set neither prop are unchanged. Closes #3939. - Timestamp: new
tooltipEntriesprop renders the hover tooltip across several time zones and/or formats at once — one line per entry, each with an optionaltimezoneID(IANA id; omit it or pass'local'for the viewer's zone),format(every non-relativeTimestampFormatplus'full'), andlabel. The default is unchanged: with no entries the tooltip stays the single full absolute line in the viewer's zone. Configuring entries also attaches the tooltip to absolute formats, which previously had none — note that this gives those timestamps a tab stop and focus ring, as relative timestamps already have, so a column of them gains one tab stop per row.hasTooltip={false}still suppresses the tooltip, and an empty array counts as no configuration. Also correctsisTimezoneShown's documentation, which claimed it applied to thesystem_date_timeandsystem_timeformats; it never has, and those formats stay machine-readable. (#4188) - Token: make the
colorprop extensible via module augmentation.TokenColoris now derived from aTokenColorMapinterface, so theme packages can add custom colors (andastryx theme buildgenerates the type augmentation), matching Badge and Button. - TreeList: the per-level indentation step is now the themeable
--tree-list-indentvariable (defaultvar(--spacing-4)), so a theme can retune the indent metric viadefineThemeon thetree-listtarget instead of the previously hardcoded, unreachable step (#4308). - TreeList: add a
variantprop ('lineGuides' | 'noGuides', default'lineGuides') to select the base hierarchy guide-line look.noGuideshides the connector lines while keeping indentation intact. Orthogonal todensity(spacing); the guides stay themeable via theastryx-tree-list-guidetarget. Non-breaking — omitting the prop renders exactly as before.
Fixes
- Avatar: compose the status dot's label into the avatar's accessible name so assistive tech can reach the status the
role="img"root previously pruned (WCAG 4.1.2) - Calendar: expose selected state in day-button accessible names, announce range-selection progress and completion via the polite live region, and set aria-multiselectable on the grid in range mode
- CommandPalette: announce result counts, empty, and loading states to screen readers via the shared polite live region (WCAG 4.1.3)
- Divider: expose the label as the separator's accessible name via aria-labelledby; Spinner: name the status element from the visible label instead of duplicating it as aria-label
- FileInput: announce the required state via a visually hidden description on the trigger (aria-required is unsupported on role="button") and announce validation errors exactly once
- hooks: resolve focus-trap Escape by DOM depth instead of push order, exclude aria-hidden subtrees from trap tab cycles, and auto-clear live regions after announcing so stale status text does not linger
- List: keep list semantics for all listStyle variants by always emitting an explicit role="list", since the base style strips list-style-type for every variant and Safari/VoiceOver drops implicit list roles for such lists (WCAG 1.3.1)
- NumberInput: announce the units text through the input's accessible description, and stop TextInput/NumberInput from referencing the non-rendered status message id in aria-describedby inside InputGroup
- reset: stop suppressing
:focus-visibleoutlines on coarse-pointer devices so keyboard users keep the WCAG 2.4.7 focus indicator - hooks: auto-detect RTL direction for arrow-key navigation in useListFocus and useGridFocus (WCAG 1.3.2)
- Slider: constrain range thumb aria-valuemin/aria-valuemax by the sibling thumb (including minStepsBetweenThumbs) and render the label as a group label wired via aria-labelledby instead of an inert
- TopNav: disabled TopNavItems now render href-less anchors so they no longer navigate or fire clicks, and Outline's active item uses aria-current="location"
- Avatar: avoid remounting the avatar subtree when the name tooltip toggles — render the tooltip as a conditional sibling instead of forking the return so the avatar keeps its position in the React tree (and its image-load state) across tooltip changes.
- Card: rest the bordered variant on the subtle
--color-borderinstead of--color-border-emphasized, so a Card's outer frame matches its own LayoutHeader/LayoutFooter dividers and neighboring ClickableCards instead of rendering a heavier edge. - CheckboxInput, RadioList, Switch: align control sizes. The visible checkbox and radio controls now fill their size exactly (20px at
sm, 24px atmd) instead of being inset 2px. ThesmSwitch track is now 32px wide with a 2px inset. - Export the
BasePropstype through the@astryxdesign/core/BasePropssubpath. Previously it was only reachable through the package barrel, so theimport type {BaseProps} from '@astryxdesign/core/BaseProps'specifier thatastryx swizzlegenerates failed to resolve (#4091). - Guard DropdownMenu item hover styles with
@media (hover: hover)to prevent sticky highlights on touch devices. Forward BaseProps pass-throughs to the menu element. - FieldStatus renders a leading status icon on the
detachedmessage so status is not conveyed by color/position alone (WCAG 1.4.1). The icon is decorative for assistive tech; the message text and live-region announcement carry the status. Theattachedvariant is unchanged. - FileInput no longer nests interactive controls (the clear and status buttons) inside a role="button" trigger. The trigger is now a visually hidden button alongside them in a non-interactive container, resolving the nested-interactive a11y violation (WCAG 4.1.2) while keeping click, keyboard, and drag-and-drop behavior. (#4522)
- Inputs no longer show the hover ring while disabled. The shared input wrapper's disabled state now suppresses both the base and status hover shadows, so TextInput, TextArea, NumberInput, DateInput, TimeInput, Selector, MultiSelector, Typeahead, and Tokenizer stay visually inert on hover when disabled.
- HoverCard: move themeProps className to the layer container (where bg/radius/shadow live) so themes can target the visual surface. Forward consumer xstyle/className/style to that same layer container so surface customization lands next to the theme class, instead of silently dropping them.
- With
statusVariant="detached", bordered inputs no longer render a status icon inside the control (this also covers DateTimeInput, which is fixed to the detached presentation) — the detached message box already carries a leading icon, so the on-field glyph was a duplicate. Also centers the detached message's icon on the first line of text. - DropdownMenu/ContextMenu: mouse hover now moves the highlight instead of adding a second one, so keyboard focus and pointer hover share a single highlighted item (#4493)
- utils: clamp plainDateAddMonths to the target month's last day
Adding a month to Jan 31 landed on Mar 3 (Date#setMonth overflow) instead of Feb 28, so month arithmetic from end-of-month dates skipped February entirely. The helper now uses pure month arithmetic and clamps the day, matching Temporal.PlainDate.add and date-fns. - Calendar RTL: month-navigation chevrons now mirror correctly under RTL (via the shared
rtlStyles.mirrortransform on the nav-icon wrapper), and the range-selection / hover-preview fill pills use logical CSS (insetInline*,border*Start/EndRadius) so their rounded start/end caps follow the reading direction instead of the physical left/right. LTR rendering is unchanged. - Carousel now supports RTL: the directional scroll-button chevrons mirror under RTL, the scroll buttons respect RTL scroll semantics (previously the button was a no-op under RTL), and the button pills sit on the correct edges.
- RTL: migrate physical
left/rightCSS properties to their logical equivalents so components mirror correctly underdir="rtl". This is the Phase 2 mechanical, one-to-one follow-up to the RTL direction API — a no-op in the default LTR direction with no visual change. - RTL Phase 4 (behavioral): mirror directional behavior that logical-CSS and icon name-swaps alone couldn't fix. SideNavCollapseButton and TreeListItem now compose
rtlStyles.mirroron the icon wrapper outside the state rotation, so the chevrons point toward the correct edge in every collapsed/expanded × LTR/RTL combination. TreeList connector/guide lines position via logicalinset-inline-start/inset-inline-endso they mirror to the inline-start (right) edge under RTL alongside the chevron and row indent. Slider positions its thumb, fill, and marks via logicalinset-inline-startand flips the physical centering transform under RTL, and its pointer/click math measures the value fraction from the inline-start (right) edge under RTL — so a click at 25% of the track maps to 75 instead of 25. - RTL Phase 4b behavioral fixes: ChatMessageBubble grouped-bubble tail corners now use logical border radii so the tail follows reading direction (mirrors under RTL, text unaffected); Table sticky-column shadows make their
translateXand gradient direction-aware so the shadow fades from the pinned edge toward scrolled content in both LTR and RTL instead of rendering inside-out, and gate shadow visibility onMath.abs(scrollLeft)so the start/end shadows still appear under RTL (where spec-compliant browsers report a negativescrollLeft); ResizeHandle's hit-area bias is now direction-aware (mirrors about center under RTL) and the pointer-drag delta reads the handle's computed direction so dragging resizes intuitively in RTL. - Markdown honors the
components.imageoverride for standalone (block) images, matching the inline image path. A standaloneline parses as a block image, whose render path previously hardcoded a bare<img>and ignored a suppliedcomponents.image; it now uses the override just like an inline image does. - Selector & MultiSelector: keep group headers visible while searching; hide groups with no matching items. Previously, typing a query flattened grouped options into a single ungrouped list; now each group header stays above its matching items and a group is hidden only when none of its items match.
- SideNav: remove the top border above the footer region so the
footerslot no longer renders a divider line. - Slider: round snapped values to the min/step decimal precision
With fractional steps,min + steps * stepaccumulated binary floating-point error, so a keyboard nudge on astep={0.1}slider emitted0.30000000000000004throughonChange/onChangeEnd(and intoaria-valuenow/the value tooltip once the consumer echoed it back). Snapped values are now rounded to the combined decimal precision ofminandstep, which removes only the error — exact steps are unaffected. - Tooltip: dismiss the tooltip when its trigger is pressed. Previously the tooltip stayed open through a click (e.g. a "Copy link" button's tooltip lingered after activation); now pressing the trigger hides its own tooltip. Applies to uncontrolled tooltips only.
- Typeahead: omit aria-activedescendant when search results are empty or index is out of bounds (#4059)
Documentation
- document
widthprop across 17 input component doc files (#4163)
Other Changes
- Add
@astryx/no-physical-propertiesESLint rule that flags physical left/right CSS properties insidestylex.create()and suggests the CSS logical equivalent for RTL support. - KEY-BASED:
marginLeft/marginRight,paddingLeft/paddingRight,borderLeft/borderRight(+ theirWidth/Style/Colorlonghands),left/right→insetInlineStart/insetInlineEnd, and the four physical corner radii → their diagonal-aware logical names (borderTopLeftRadius→borderStartStartRadius, etc.). - VALUE-BASED:
textAlign: 'left'|'right',float: 'left'|'right', andclear: 'left'|'right'(the key stays, only the physical value is flagged). - Scoped strictly to
stylex.create()— physical identifiers used elsewhere are ignored. useDirection()returns'ltr' | 'rtl'for the current provider context (falls back to'ltr'when called outside a provider).getLocaleDirection(locale)computes direction from a BCP 47 locale viaIntl.Locale.getTextInfo()— safe to call from React Server Components and Next.js layouts to set<html dir>.<InternationalizationProvider locale="ar">auto-derivesdir="rtl". Pass an explicitdirprop to override (useful for RTL testing under an English catalog, or to force LTR).- Pagination is the first component to consume the hook: prev/next chevron icons flip under RTL while the aria-labels stay semantic.
- Storybook gains a global
Directiontoolbar for toggling every story between LTR and RTL. - Component-level CSS migrations (borders, chevrons, sliders, calendar range pills, etc.) land in follow-up PRs.
textAlign: 'left' | 'right'→'start' | 'end'(Selector, Typeahead, Chat trigger menu, DropdownMenu, CommandPalette, NavMenu items).borderLeft*/borderRight*→borderInlineStart*/borderInlineEnd*, kept as separate start/end declarations (Banner, Table cell/header dividers, DateRangeInput preset sidebar).- Static
left/rightpositioning →insetInlineStart/insetInlineEndfor full-bleed overlays and single-side offsets (Button spinner overlay, Chat dock/blur/placeholder, Field sr-only label, Lightbox close/nav/counter buttons, TabList indicators, Thumbnail remove slot, CodeBlock copy button). - Inline
marginLeftindentation →marginInlineStart(TreeList rows).
@astryxdesign/cli
Breaking Changes
- cli/json: remove the central
CLIAnyResponse,CLIResponseType, andCLIResponseDataMaptypes.jsonOutis now a structural serializer andparseResponse/assertResponsereturn the structuralCLIResponse({type, data, meta?}) instead of the discriminated union, soresult.dataisunknownuntil you narrow it yourself.
Runtime output is unchanged (every--jsonenvelope is byte-identical). This only affects consumers importing those types or relying onparseResponse/assertResponseto auto-narrow.data. - component/hook
--jsonlist responses collapsed.--detail compact/fullpreviously emitted distinctcomponent.brief/component.full(andhook.*) envelopes; they now all emitcomponent.list(resp.hook.list) with adata.detail: 'names' | 'compact' | 'full'field. Migrate: switch ondata.detail, not the.brief/.fulldiscriminator. Removed types: ComponentBriefResponse, ComponentFullResponse, HookBriefResponse, HookFullResponse.
New Features
- CLI:
blogis now a normal, agent-facing command — it appears in--helpand the capability manifest and supports--json(emittingblog.list/blog.detailenvelopes), instead of being hidden. Human output is unchanged; the reader still consumes the public RSS feed. Also scriptable through the./apibarrel asblog(slug?). - CLI:
initis now fully scriptable through the./apibarrel — the non-interactive installer (agent-docs cheat sheet, starter template,--remove-agents) lives inapi/initand returns a typed receipt (init.run|init.remove), with the CLI reduced to a thin parse → API call → render wrapper. Human output is emitted through an injectable logger, so a scriptedinit()stays silent while the CLI output is byte-identical for existing usage. - CLI:
theme buildis now fully scriptable through the./apibarrel — the ~1,000-line theme compiler (defineTheme extraction, CSS generation via@astryxdesign/core/theme, variant/type-declaration + icon-module generation, override validation) lives inapi/theme/buildand returns a typedtheme.buildreceipt, with the CLI reduced to a thin parse → API call → render wrapper. Human progress is emitted through an injectable logger, so a scriptedthemeBuild()stays silent while the generated CSS/JS/.d.ts, the--jsonenvelope, and human output stay byte-identical for existing usage. Watch mode remains a thin CLI loop. - CLI:
upgradeis now fully scriptable through the./apibarrel — the version-to-version pipeline (codemods + agent-docs refresh) lives inapi/upgradeand returns a typed receipt (upgrade.list|upgrade.status|upgrade.run), with the CLI reduced to a thin parse → API call → render wrapper. Human progress is emitted through an injectable logger, so a scriptedupgrade()stays silent while the CLI output and--jsonenvelopes are unchanged for existing usage. - Timestamp: new
tooltipEntriesprop renders the hover tooltip across several time zones and/or formats at once — one line per entry, each with an optionaltimezoneID(IANA id; omit it or pass'local'for the viewer's zone),format(every non-relativeTimestampFormatplus'full'), andlabel. The default is unchanged: with no entries the tooltip stays the single full absolute line in the viewer's zone. Configuring entries also attaches the tooltip to absolute formats, which previously had none — note that this gives those timestamps a tab stop and focus ring, as relative timestamps already have, so a column of them gains one tab stop per row.hasTooltip={false}still suppresses the tooltip, and an empty array counts as no configuration. Also correctsisTimezoneShown's documentation, which claimed it applied to thesystem_date_timeandsystem_timeformats; it never has, and those formats stay machine-readable. (#4188)
Fixes
astryx theme build: component-override keys for multi-word components (TextInput, DateInput, NumberInput, DropdownMenu, SideNav, TopNav, etc.) now match the hyphenated class the component actually renders. The known-component registry used de-hyphenated keys, so overrides authored against them emitted dead selectors (.astryx-textinputinstead of.astryx-text-input) that silently never applied (#4109).
Other Changes
- CLI: blog reorganized into api/blog leaf shape — list/detail leaves projecting a shared RSS adapter (
_adapter.mjsowns all network fetch + feed parsing), withblog.mjskept as a dispatcher+barrel so the sameblogexport, the CLI wrapper, api/index.mjs, and the --json/human output stay byte-identical. - CLI:
buildreorganized into theapi/buildleaf shape —build.mjsis now a dispatcher + barrel that routes no-query →build.help(api/build/help/help.mjs) and a query →build.kit(api/build/kit/kit.mjs), with each leaf projecting its single{type, data}envelope. Pure reorganization: thebuildexport, the./apibarrel, and the CLI consumer are unchanged, and the--jsonand human output stay byte-identical for existing usage. - CLI:
componentreorganized into theapi/componentleaf shape over a shared_adapterresolver —component.mjsis now a dispatcher + barrel that routes to per-type leaves (list,detail,detail/props,detail/source,detail/showcase,detail/blocks), each a thin projection of a subject the adapter resolves once (core/external/scoped/integration ownership, ambiguity handling, and fuzzy search, deduped). Pure reorg: every--jsonenvelope and human output stays byte-identical across all modes. - CLI: discover reorganized into api/discover leaf shape (list, detail, detail/doc, search) behind a shared _adapter that owns external-package discovery and doc loading; discover.mjs is now a dispatcher+barrel keeping the same exports. Pure reorg —
--jsonand human output are byte-identical and api/index.mjs + the CLI consumer are untouched. Adds colocated leaf tests. - CLI: docs reorganized into api/docs leaf shape —
docs()inapi/docs/docs.mjsis now a dispatcher + barrel that routes by argument shape into three leaves (api/docs/list,api/docs/detail,api/docs/detail/section), each projecting into a single{ type, data }envelope. The discovery, overlay loading, and topic resolution shared by ≥2 leaves live inapi/docs/_adapter.mjs. Pure reorganization: thedocsexport,api/index.mjs, the CLI consumer, and all--jsonand human output are unchanged (byte-identical). - CLI: hook reorganized into api/hook leaf shape —
hook.mjsis now a dispatcher+barrel routing to colocated leaves (list/list.mjs→ hook.list,detail/detail.mjs→ hook.detail,detail/params/params.mjs→ hook.detail.params) over a shared_adapter.mjsresolver. Pure reorg:--jsonand human output are byte-identical across all modes, and thehookexport surface (api/index.mjs + CLI) is unchanged. - CLI: init reorganized into api/init leaf shape —
init.mjsis now a dispatcher + barrel that routes toapi/init/run/run.mjs(the default /--features/--allinstall path) andapi/init/remove/remove.mjs(the--remove-agentspath), with the shared plain-logger contract inapi/init/_adapter.mjs. Pure reorg:getNextSteps,noopInitLogger, and theInitOptions/InitLoggertypes stay re-exported from the barrel, so api/index.mjs, the CLI command, and the programmatic API are unchanged. Human and--jsonoutput are byte-identical. - CLI: layout reorganized into the api/layout leaf shape — a shared
_adapter.mjs(analyze/loadBlocks/formatIssueoverlib/xle) with thinexpand/,check/, andgrammar/leaves, plus alayout.mjsbarrel.api/index.mjsand the CLI are unchanged (they import via the barrel). Pure reorg:layout expand/check/grammar--jsonenvelopes and human output are byte-identical. - CLI: swizzle reorganized into api/swizzle leaf shape — the flat command splits into
api/swizzle/list(swizzle.list) andapi/swizzle/copy(swizzle.copyreceipt, incl.rewriteImports), with shared @astryxdesign/core discovery + component listing deduped inapi/swizzle/_adapter.mjs, andswizzle.mjsreduced to a dispatcher + barrel that keeps its existing exports (swizzle,rewriteImports). Pure reorganization with no behavior change: human output and every--jsonenvelope stay byte-identical, and the CLI command, the./apibarrel, and the centraltypes/swizzledeclarations are untouched. - CLI: template reorganized into api/template leaf shape (shared helpers preserved on the barrel). Pure reorg —
--jsonand human output stay byte-identical: shared discovery/IO moved toapi/template/_adapter.mjs, the command modes split intolist/show/skeleton/copyleaves, andtemplate.mjsbecomes a dispatcher + barrel that re-exports every previously-exported symbol (template, discoverTemplates, discoverAll, discoverAllWithErrors, discoverIntegrationTemplatesForOne, findShowcase, findRelatedBlocks, stripTemplateAssetRefs, listTemplates, extractComponents, and the DiscoveredTemplate/TemplateDiscoveryError types) so component/layout/search/init/discover/validate-integration and lib/project keep resolvingapi/template/template.mjsunchanged. - CLI:
theme add/listare reorganized into the fractalapi/theme/leaf shape — a shared_adapter.mjs(bundled-theme manifest reader + slug resolver) with thinadd/(copy →theme.addreceipt) andlist/(theme.list) leaves over it, plus atheme.mjsbarrel, mirroring thetheme buildextraction (#4462).themeList()is now exported from@astryxdesign/cli/apialongsidethemeAdd. Pure reorg:theme list/add--jsonenvelopes and human output are byte-identical, with new direct-API tests for both leaves. - CLI: upgrade reorganized into api/upgrade leaf shape — the flat pipeline is split into a dispatcher+barrel (
upgrade.mjs), a shared_adapter.mjs(version detection + agent-docs refresh + codemod selection/execution machinery), andlist/status/runleaves (upgrade.list|upgrade.status|upgrade.run). Pure reorg: the./apibarrel + CLI consumer are unchanged, and both the human output and--jsonenvelopes are byte-identical.
@astryxdesign/theme-neutral
Fixes
- Neutral theme: express the light
--color-borderas#00000014(translucent black) instead of the opaque#ebebeb. Same rendered color over a white surface, but it now blends over any background — matching the translucent dark-mode value.
Contributors
Thanks to everyone who contributed to this release:
- @AKnassa
- @arham766
- @bhamodi
- @cixzhang
- @ernestt
- @freddymeta
- @HelloOjasMutreja
- @humbertovirtudes
- @josephfarina
- @kentonquatman
- @lexs
- @nynexman4464
Full Changelog: v0.1.9...v0.2.0
详细ChangeLogv0.1.9
2026年07月28日
v0.1.9 is an all-patch release — no breaking changes, so no codemods required.
New Features
- Avatar: add a
tooltip?: string | booleanprop for a name-on-hover tooltip. Omitting it (ortrue) shows the avatar'snameon hover and keyboard focus; a string shows that text instead (no need to wrap inTooltip);falsedisables it. Avatar owns the tooltip via the existing Tooltip hook, so there's no extra wrapper DOM. Because this adds a default tooltip to every existing named Avatar, settooltip={false}when you supply your ownTooltip/HoverCardoverlay. The rootaria-label(alt || name) is unchanged; the default name tooltip is visual-only (noaria-describedbydouble-announce), while a custom string tooltip is exposed as a description. Decorative avatars (noname/alt) get no tooltip. (#4164) - BreadcrumbItem gains a
menuprop that turns a crumb into a menu trigger for switching between sibling destinations. It accepts the same item API as DropdownMenu/MoreMenu/ContextMenu (aDropdownMenuOption[]array or composed item children), so existing menu-item definitions drop into a breadcrumb with no rewrite. The item components are also re-exported underBreadcrumb*aliases. - Calendar: make the today/selected day-cell ring precisely themeable. The day cell now reflects a compound
markerstate (today-only/today-in-range) that maps 1:1 to the treatment actually drawn, sodefineTheme({components: {'calendar-day': {'marker:today-only': {...}}}})targets exactly those states without over-matching or needing a:not()exclusion. Default rendering is unchanged. - Calendar: add a dedicated
astryx-calendar-navtheme target for the prev/next month-nav buttons, so consumers can theme the nav controls (color, radius, per-direction, disabled edge) without reaching every Button via the globalastryx-buttonhandle. Reflectsnav(prev/next) and thedisabledstate as data attributes. - ChatComposer: make custom inputs first-class.
useChatComposerContext()and its types are now public, so any input in theinputslot can readvalue/onChange/onSubmit/canSend/placeholder/isDisabledand drive the shell's send button. Inputs can register a focus control oninputControlRefso click-to-focus works for any input shape (not justcontenteditable/textarea); the shell keeps a DOM-query fallback for uninstrumented inputs. - ChatComposerInput: add an
onKeyDownseam so consumers can host platform- or app-specific key handling — e.g.preventDefault()Enter to insert a newline on a touch keyboard, or submit on Cmd/Ctrl+Enter. Enter also no longer submits mid-IME-composition. - CommandPalette: add a dedicated
astryx-command-palette-group-headingtheme target on the group heading, so consumers can theme just the heading (e.g. its padding or typography) viadefineThemeinstead of a fragile structural selector. The group root keeps its ownastryx-command-palette-grouptarget. - DateInput gains a
formatprop for the committed date value, reusing Timestamp'sformatvocabulary so the same literal renders the same date shape in both components. Named values aredate_long(the default, "March 21, 2026"),date("Mar 21, 2026"),date_weekday("Wed, Mar 21, 2026"), andsystem_date("2026-03-21"); a(value) => stringfunction is also accepted for custom output. Thedate_longdefault is byte-identical to DateInput's previous long-month rendering, so existing usage is unchanged. This also extends Timestamp with two new shared members,date_longanddate_weekday, giving the two components full value parity on the date-only formats. Formatting applies only to the committed value, never to text being typed. - Add an
elevationprop to configurable surfaces — Card, ClickableCard, SelectableCard, Button, IconButton, ButtonGroup, and Banner take the full'none' | 'low' | 'med' | 'high'scale; ChatComposer takes'none' | 'low'. Defaults preserve today's appearance (noneeverywhere except ChatComposer'slow), so nothing changes unless you opt in. (#4146) - OverflowList: add
maxVisibleItemsto cap the number of visible items (the ceiling partner tominVisibleItems) andmaxRowsfor bounded multi-row wrapping — items wrap onto up to N rows, then collapse into the overflow indicator. Both props are optional and default to off, so single-line behavior is unchanged. See #4176. - Add
useTableRowStatus, a plugin that prepends a narrow column
signaling per-row status. - Thumbnail: add
showRemoveOnprop —'hover'(default) reveals the remove button on hover or keyboard focus and keeps it visible on touch;'always'shows it at rest. - Table tree: add an optional expand-all/collapse-all header control (#4142)
useTableTreeStatenow returns an aggregateisAllExpandedstate (true/false/'indeterminate') and threadsexpandAll/collapseAllintotreeConfig.useTableTreeDatagains ahasExpandAllControlprop: when set, it renders an expand-all/collapse-all toggle in the tree column header, wired to that state, so consumers no longer need to hand-roll external buttons. Flat data stays a full no-op. This is the first affordance folded in fromuseTableRowExpansionas part of converging the two tree plugins. - TreeList: add a dedicated
astryx-tree-list-chevrontheme target for the expand/collapse toggle, so consumers can theme the chevron (color, per open/closed state) viadefineThemeinstead of reaching it through the functional[data-tree-toggle]attribute. Reflects the open/closed state as adata-stateattribute (expanded/collapsed); the functionaldata-tree-togglehook is unchanged. - TreeList: add a stable
astryx-tree-list-guidetheme target on the hierarchy guide (connector) line elements, so consumers can recolor or hide the guides throughdefineTheme(e.g.backgroundColor, ordisplay: 'none'to hide them) instead of hiding the built-in connectors and reimplementing them with unlayered CSS. - TreeList: add a dedicated
astryx-tree-list-item-labeltheme target on the item's label text, so consumers can theme just the label (e.g. bold the selected item's label) viadefineThemeinstead of a fragilebutton:not([data-tree-toggle]) > spanstructural selector. Reflects the row'sselectedstate as adata-selectedattribute on the label. - CLI: full API coverage for the
build,swizzle,layout, andvalidatecommands — each is now scriptable through the./apibarrel with the CLI as a thin parse → API call → render wrapper.buildgains--jsonoutput. Behavior is unchanged for existing command usage. (#4302)
Fixes
- AvatarStatusDot: pair each variant with a distinct built-in shape — success stays a filled dot, neutral renders as a ring, error gets a minus bar — so status no longer relies on colour alone (WCAG 2.1 SC 1.4.1, #4143). A rendered
iconreplaces the shape glyph at sizes where icons fit; themes can target the new stableastryx-avatar-status-dot-glyphclass and itsdata-shapeattribute — a stroked inline<svg>painted from the dot'scurrentColor. - Button: link-rendered buttons (
href) now exposearia-busywhile loading, matching the<button>branch. Previously an interruptible loading link showed the spinner and announced "Loading" but carried no machine-readable busy state. - Calendar only marks in-month date cells as today, preventing duplicate today indicators in multi-month views.
- Carousel: slides now expose APG slide semantics (role=group, aria-roledescription="slide", "Slide N of M" labels) instead of anonymous divs.
- Honor
prefers-reduced-motionin ChatToolCalls (chevron rotation, expand/collapse), ChatLayoutScrollButton (pill show/hide), and ChatDictationButton (equalizer bars). - Chat/useChatStreamScroll: the scroll-follow spring now respects
prefers-reduced-motion— locked following,scrollToBottom(), andlock()fall back to the existing instant jump, so the transcript still tracks the bottom without animated travel. Follow-up promised in #3800. - Chat: the composer drawer toggle now references its disclosed content via aria-controls, so assistive tech can navigate from the toggle to the drawer.
- ChatSendButton now forwards
className,style, and pass-through attributes (data-*,aria-*, and other rest props) to the rendered button. Previously these were silently dropped. (#4190) - Chat: tool-call error details are now exposed to screen readers and keyboard users instead of living only in a hover-only title attribute.
- CheckboxInput: the indeterminate mark now uses the
--radius-fulltoken instead of a hardcoded radius, for token consistency. No visual change. - CheckboxList: items with rich (non-string) labels can now provide an accessibleLabel so their checkbox no longer announces as the literal "Checkbox".
- CodeBlock: collapsed code regions are now inert, so keyboard focus can no longer land on the invisible scroll container while collapsed.
- CommandPalette: forward BaseProps pass-through attributes (className, style, xstyle, data-, aria-) to the underlying Dialog. Previously these were silently dropped.
- CommandPalette: the search input (role=combobox) now has an accessible name by default, from the new label prop or the visible placeholder. Previously screen readers announced a nameless combobox.
- DateTimeInput: ArrowDown (and Alt+ArrowDown) in the date field now opens the calendar popover from the keyboard, matching DateInput and the advertised combobox pattern.
- Dialog: modals are now automatically labelled by their DialogHeader title via aria-labelledby, matching AlertDialog. Unnamed open dialogs warn in development.
- Dialog: the entry animation is disabled under prefers-reduced-motion, matching the Layer animation guards.
- Fix Divider rest-prop spread order so consumer-passed HTML attributes cannot overwrite the component's
role="separator"oraria-orientation. - FieldLabel now forwards className, style, xstyle, and pass-through attributes (data-, aria-, event handlers) to the rendered element. Previously these were accepted by the type but silently dropped.
- Field/FieldStatus: status and error messages are now announced through persistent live regions, so they are reliably read by screen readers regardless of when they appear.
- FileInput: the trigger's accessible name now includes the selected filenames, so screen-reader users can review what is attached when refocusing the control.
- Prevent PowerSearch edit popover from closing when selecting multi-select options via Enter (#4245)
- Selector/MultiSelector: PageUp/PageDown now jump the active option to the first/last match while searching, complementing Home/End which stay on text-caret movement.
- HoverCard: popups now expose a named dialog when the new label prop is set, and honest group semantics otherwise. Previously every hover card announced as an unnamed dialog.
- Item: aria-selected is now emitted only when the item's role permits it (option, tab, treeitem, grid cells), removing invalid ARIA from plain list items.
- MobileNav: the toggle button now exposes
aria-expandedand references the nav drawer viaaria-controls, so screen-reader users can tell whether the drawer is open and what the button controls. (#3721) - MultiSelector: searching within the options popover now announces the number of matching results ("3 results" / "No results found") to screen readers, mirroring Selector and Typeahead. Previously filtering happened silently.
- SideNav/TopNav: collapsed heading popovers no longer wrap their menus in a modal dialog, and the heading button is no longer an invalid child of the menu.
- Slider now clamps controlled values to its minimum and maximum before positioning the thumb and exposing ARIA values.
- Slider: required sliders now convey the required state to screen readers through the thumb's accessible description (aria-required is invalid on the slider role).
- Make the CLI's
.mjssources fully strict-typecheckable (checkJs + JSDoc)
Annotated the entire CLI package sotsconfig.strict.json(fullstrictcheckJsoversrc,bin,scripts,docs, and the emittedtemplates) reports zero errors — down from 1717. Fixes are JSDoc-only: no runtime logic changed,.mjsstays.mjs. Strict checking also surfaced and corrected several type-contract drifts: theupgrade.runresponse type (declared adepsUpdatedfield the command never emits, and omitted the realintegrations/filesChanged/transformsApplied/errors), registered the emittedtheme.list/theme.add/layout.*response types in the--jsonenvelope union, and addedcategory?toReferenceSectionin core's docs types (reference docs already emit it). - Table: the selection plugin accepts getRowLabel so row checkboxes announce which row they select, instead of an undifferentiated "Select row".
- Text: an explicit
sizenow overrides the font-size of a themedtype. Thesizeclass lived in a lower cascade layer (astryx-base) than a theme's per-type font-size rule (astryx-theme), so<Text type="supporting" size="xsm">silently kept the type's size. Themes now re-emit the size classes in the theme layer sosizewins as documented. - Thumbnail: replace the hover box-shadow on interactive tiles with the same
::afteroverlay treatment ClickableCard and SelectableCard use. All three now tint on hover with--color-overlay-hover(and--color-overlay-pressedon press), so interactive feedback is consistent across the card family. - Thumbnail/TopNavMegaMenuFeaturedCard: images without alt text are now explicitly decorative instead of silently empty-alt, matching Avatar's handling.
- Thumbnail: the overlaid remove button now uses a fixed
--color-overlayscrim with an--color-on-darkicon instead of adapting to the image's luminance, so it has reliable contrast on any image. - Tokenizer: adding and removing tokens (including Backspace on an empty input) is now announced to screen readers. Previously tokens appeared and disappeared silently.
- TopNav: TopNavMegaMenu triggers now expose aria-controls, and the panel is a labeled group instead of an invalid modal-dialog-wrapped menu.
- TopNav: TopNavMenu popups now expose proper menu semantics (no modal dialog wrapper) with the full APG keyboard pattern (roving tabindex, arrow keys, typeahead).
- Align two
--jsoncontract shapes with what the CLI actually emits - Register all emitted response types in the
--jsonenvelope union
Three response types were defined, exported, and emitted by commands but never added toCLIAnyResponse— the union thatjsonOut()type-checks payloads against:component.full,component.detail.blocks, andupgrade.status. Because their discriminators were missing from the map,jsonOut('upgrade.status', …)(and the two component variants) were rejected by the type-checker, and their payload shapes weren't actually being validated.build.helphad no response type at all. Added aBuildHelpResponsetype and wired all four into the union so every--jsonenvelope the CLI can emit is now type-checked against a declared shape. - Type
detectPackageManagerhonestly soastryx doctor's "no lockfile" branch is reachable
detectPackageManagerreturns'npx'as the sentinel for "nothing detected", but its return type only listed'yarn' | 'pnpm' | 'bun' | 'npm'. Type-checkers therefore treateddoctor'spm !== 'npx'guard as a dead comparison — the "No lockfile detected — defaulting to npm/npx" message looked unreachable and was at risk of being "cleaned up". The return type is nowPackageManager | 'npx'and detection narrows via a shared type predicate, so the guard is honest and the branch is preserved. - Drop the dead
cwdparameter fromgetLatestVersion
checkForUpdatecalledgetLatestVersion(cwd)and the JSDoc advertised acwdparameter, but the function takes no arguments — it only reads the$ASTRYX_LATEST_VERSIONenv var, so the passedcwdwas silently ignored. Removed the phantom parameter and its doc so the signature matches the behavior. No functional change to the update-nudge output. - Scope the
sourceresolve condition to @astryxdesign packages in withAstryx
withAstryxset webpack'sconditionNamesto['source', …]globally, which resolved any dependency shipping asourceexport to its raw TypeScript — not just Astryx packages. Third-party deps that ship asourceexport (e.g.lexical, pulled in by the new RichTextEditor lab component) were then fed untranspiled.tsthrough Next's babel and failed on syntax likedeclareclass fields.
Documentation
- docs(Tokenizer, PowerSearch): document and test the existing startIcon prop
Both components have shipped a workingstartIcon?: ReactNode | IconTypeprop for a while (PowerSearchforwards it verbatim to the internalTokenizer, and it's already exercised in Storybook), but neither's.doc.mjsdocumented it and neither had test coverage — so it was invisible toastryx component <Name> --dense, the docsite props table, and anyone (human or AI) relying on those as the source of truth. No behavior change; adds the missing props-table entries (en/zh/dense) and colocated tests confirming the icon renders and forwards correctly.
Other Changes
- Compact per-row status signal (error, warning, unread, etc.) without a
dedicated status column: a colored status dot by default, or an icon when provided. getStatus(item)maps a row to{color, icon?, label?}, ornullfor no
indicator.coloraccepts a semantic status name (success/error/warning/accent/red/green/etc.) mapped to a theme token, or a raw CSS color as an escape hatch.iconrenders the status as a shape signifier instead of the dot, which is
more accessible than color alone when multiple statuses coexist.labelsupplies the accessible name.- Memoize
getStatuswithuseCallbackfor a stable plugin identity. swizzle.copypayloads always includepackageandusesStyleX(both covered by tests), butSwizzleCopyResponse.datadidn't declare them — the call site cast the payload toRecord<string, unknown>to sidestep the mismatch. Added both fields to the type and dropped the loose cast so the payload is type-checked.- The error
suggestionsshape was declared as{name, reason}(reason required) in the JSON envelope / API error contract, but some call sites emit bare{name}(e.g. candidate component names on swizzle). Introduced a single canonicalSuggestiontype (reason?optional) and referenced it everywhere so the contract matches the emitted data.
Contributors
@AKnassa, @bhamodi, @cixzhang, @ejc3, @freddymeta, @HelloOjasMutreja, @humbertovirtudes, @josephfarina, @kentonquatman, @Kevinjohn, @potatowagon, @saadpocalypse, @yyq1025
Full Changelog: v0.1.8...v0.1.9
详细ChangeLogv0.1.8
2026年07月23日
@astryxdesign/core
Breaking Changes
- Avatar and AvatarGroup adopt Icon's abbreviated size scale —
sizenow takesxsm/sm/md/lg/xlinstead oftiny/xsmall/small/medium/large. Pixel values are unchanged (20/24/36/48/128px) and the default is nowmd(still 36px, formerlysmall). Avatar's tiers stay larger than Icon's because avatars align with media rather than glyphs. Runastryx upgradeto migrate call sites. (#2672)
New Features
- Collapsible: the content area now anchors body typography (font family, size, weight, line-height) instead of inheriting from its surroundings, and exposes a stable
astryx-collapsible-contenttheme target so revealed text can be themed externally. - Collapsible: add an
isDisabledprop to disable a single item. A disabled item's trigger can't be toggled and is dimmed; following the system-wide disabled convention it usesaria-disabled(not the nativedisabledattribute) and drops out of the tab order, staying perceivable to assistive tech. Disabling doesn't collapse an already-open item. Works standalone and inside CollapsibleGroup. @astryxdesign/core'sdocs.mjsnow redirects to the CLI so agents converge on a single documentation entry point (astryx docs/astryx init) instead of a large standalone script. (#4207)- DropdownMenu selectable items: add
DropdownMenuCheckboxItem(independent toggle,role="menuitemcheckbox") andDropdownMenuRadioGroup+DropdownMenuRadioItem(single-select,role="menuitemradio"). The control size derives from the menu's item size and swaps to the row's inline-end on touch. They also work insideContextMenu(re-exported asContextMenuCheckboxItem/ContextMenuRadioGroup/ContextMenuRadioItem). Both menus' keyboard/typeahead/activation now recognize the selectable roles, and the menu context (DropdownMenuContext,useDropdownMenuContext,DropdownMenuSize) is exported for building custom menu items. See #3829. - Icon: add an optional
labelprop for the accessible name. Setting it makes a standalone icon meaningful (role="img"+aria-label, noaria-hidden), collapsing the old three-attribute dance into one prop; omitting it (or passing'') keeps the decorative default (aria-hidden). - "Foolproof init": both
@astryxdesign/coreand@astryxdesign/clinow print a postinstall nudge pointing you tonpx @astryxdesign/cli init,astryxcommands nudge you to finish setup until init has run, andastryx initruns non-interactively (no TTY required) so it works in CI and agent environments. (#4147, #4153, #4154, #4155) - Outline: keyboard navigation, navigate callbacks, and scroll-scoping props (#2527)
Layers the public API deferred out of #2746 onto the scroll-spy engine and click-lock that already shipped. No visual change. - Table: add a tree-data plugin —
useTableTreeStateanduseTableTreeData— for rendering and managing hierarchical (parent/child) rows, expansion state, and flattening. Exported from@astryxdesign/core/Table. (#3789)
Fixes
AvatarGroupOverflownow grows into a pill for long+Ncounts so the number never clips.
The indicator was a fixed-size circle, so wide counts (e.g.+4912) overflowed and crowded the edges. It now uses a minimum width equal to the avatar size plus horizontal padding: short counts (+5) stay a perfect circle, while longer counts grow horizontally into a stadium/pill and remain legible. No new public props.- Carousel now scrolls horizontally when you hold Shift and scroll the mouse wheel. Trackpad users already got horizontal scroll for free; this brings standard mouse (vertical-only wheel) users to parity using the established Shift + wheel convention. Native trackpad horizontal scrolling and the prev/next buttons are unchanged.
- Citation: a non-interactive citation (no
source.url) now keeps the default cursor instead of showing a pointer, so only linked citations look clickable. The pointer cursor is applied alongside the existing hover treatment for both thelabelandnumbervariants (#4134). - Stop suggesting bare
npx astryxbefore the CLI is installed — it resolves to an unrelated package on the npm registry.
The CLI now emits an install-aware invocation everywhere it prints a command: - Icon: size variants (
xsm/sm/md/lg) now usereminstead of hardcodedpx, so icons scale in step with text when the document/rootfont-sizechanges — matching the rest of the design system's rem-based type scale. Fixes #4092. - Guard useTableRowExpansionState tree walks against cyclic data (#3971)
ThedepthMap, flatteneddata, andallExpandableKeyswalks now track the ancestor keys on the current path and skip edges that point back at an ancestor, so self-referential or cyclic row data terminates instead of overflowing the stack. - Route the Table sortable plugin's header-button aria-labels ("Sort by …", "… sorted …", "… priority … of …") through
useTranslator()with new@astryx.table.sort.sortBy/sortedBy/sortedByWithPriority/direction.*catalog keys, so they localize like the sort menu labels already do (#3618, tracker #3636). The direction word resolves through its own key rather than interpolating the raw enum value. English output is unchanged. - Table: the tree row expander's
aria-labelis now localized throughuseTranslator()instead of a hardcoded English string, so expand/collapse controls announce in the app's language. (#4149) - TabList
hasDividerreserves a gap so the hover pill and adjacent buttons no longer touch the underline
A divided TabList now reserves 4px between the tabs and the divider rail. The hover highlight sits clear of the underline, and a same-size Button placed alongside the tabs aligns to the tab baseline instead of butting the rail — so amdtab strip pairs with amdbutton. The selected indicator still rests on the rail. Non-divided tab lists are unchanged. TabList inside aToolbarwithdividers={['bottom']}gets the same alignment via the toolbar's own spacing. - TreeList: focusing a parent row no longer leaks the focus-visible outline onto its descendant rows — each row's ring now resolves from its own nearest treeitem instead of matching any focused ancestor (#4130)
Documentation
- DropdownMenu and DropdownMenuItem: seed playground defaults so the docsite properties-tab preview renders real content instead of an empty trigger.
Other Changes
- Installed / global / dev runs suggest
<pm> astryx <cmd>(e.g.pnpm exec astryx …), unchanged. - One-off runs (launched via
npx/pnpm dlx/yarn dlx/bunx) suggest the scoped package<dlx> @astryxdesign/cli <cmd>, which always resolves to us. - Improved translator context in the shipped English catalog (
packages/core/locales/en.json) descriptions. Sharpened ~172 entries — added screen-reader-only clarifications, ICU-composition examples, polysemy warnings, and set-pairing notes — so translators working in Crowdin get better context. No changes todefaultMessagevalues, keys, or runtime API. - Keyboard navigation — the outline is now a single tab stop (roving tabindex via
useListFocus), seated on the active heading per WAI-ARIA, so tabbing into a table of contents while reading section 7 lands on section 7 rather than sending the reader back to section 1. Arrow keys move between headings, Home/End jump to the ends, and Enter/Space activate. A 40-heading table of contents costs one Tab press instead of 40, and Tab still leaves the outline in one press. onNavigateStart(id)/onNavigateEnd(id)— fire around the smooth scroll started by a click or keyboard activation, so an app can drive an arrival effect.onNavigateEndresolves onscrollendwhere supported and on a settle timeout where it is not (Safari), so it also fires correctly when reduced motion collapses the scroll into an instant jump. It fires exactly once for everyonNavigateStart— including when the user interrupts the scroll — so a "navigating" state can never leak.offset— the height of a fixed header overlaying the top of the scroll root. It shifts both the activation line and the scroll landing by the same amount, so a heading activates exactly where navigating to it puts it: below the header, not hidden underneath it. It composes with each heading's ownscroll-margin-top(the header, then the breathing room below it) rather than replacing it — leaveoffsetat 0 when nothing overlays the content and letscroll-margin-topdo the work, since the browser already honors it.scrollContainerRef— scope scroll tracking to a specific container instead of auto-detecting the nearest scrollable ancestor. Fixes the table of contents whose highlight never moves inside a split pane, modal, or dashboard panel. The default (viewport) path is unchanged.hasScrollOnClick(defaulttrue) — set tofalseto own the scrolling yourself; the outline still updates the active item, the hash, and the navigate callbacks.
@astryxdesign/cli
Breaking Changes
- Avatar and AvatarGroup adopt Icon's abbreviated size scale —
sizenow takesxsm/sm/md/lg/xlinstead oftiny/xsmall/small/medium/large. Pixel values are unchanged (20/24/36/48/128px) and the default is nowmd(still 36px, formerlysmall). Avatar's tiers stay larger than Icon's because avatars align with media rather than glyphs. Runastryx upgradeto migrate call sites. (#2672)
New Features
astryx init --features agentsnow defaults to creating rootAGENTS.md— the tool-agnostic standard that Codex/Copilot, Cursor, and most agents read — instead of the Claude-specific.claude/CLAUDE.md. Claude output is now opt-in via--agent claude(→.claude/CLAUDE.md), and--agent allstill writes both. Projects with existing agent-doc files are unaffected: init still discovers and updates every file already present, so this only changes the from-scratch default. (#4216)- "Foolproof init": both
@astryxdesign/coreand@astryxdesign/clinow print a postinstall nudge pointing you tonpx @astryxdesign/cli init,astryxcommands nudge you to finish setup until init has run, andastryx initruns non-interactively (no TTY required) so it works in CI and agent environments. (#4147, #4153, #4154, #4155)
Fixes
- Stop suggesting bare
npx astryxbefore the CLI is installed — it resolves to an unrelated package on the npm registry.
The CLI now emits an install-aware invocation everywhere it prints a command: - Extend the v0.1.0 upgrade codemods to cover test files that mock
@xds/coremodules, which were previously left half-migrated and broke after upgrade: astryx upgradenow keeps the managed agent-docs block (<!-- ASTRYX:START --> … <!-- ASTRYX:END -->) in sync with the installed version on every path — including the up-to-date and no-codemods short-circuits that previously returned before any refresh, leaving AI agents reading a stale component index and superseded rules. The block documents the installed library, so it's now refreshed up front (independent of codemods) and reported in the--jsonreceipt asagentDocs. One detection pass covers three cases: a stale block is rewritten (--apply) or reported as a pending change (dry-run, which no longer writes); a project with core installed but no managed block is nudged to runastryx init --features agents; an already-current block stays silent. (#4168, #4169)
Documentation
- Add a
cli-integrationsCLI docs topic (astryx docs cli-integrations) so the integration-authoring guide (originally written by @ejhammond) is discoverable through the CLI and docsite instead of an unreferenced markdown file. Rewrite the CLI README's Configuration section to match the current strict config schema (integrations,issuesUrl,hooks.postCodemod,experimental.xle) and reframe the Integrations section around the two-file API.
Other Changes
- Installed / global / dev runs suggest
<pm> astryx <cmd>(e.g.pnpm exec astryx …), unchanged. - One-off runs (launched via
npx/pnpm dlx/yarn dlx/bunx) suggest the scoped package<dlx> @astryxdesign/cli <cmd>, which always resolves to us. - migrate-xds-module-specifiers: rewrite the mocked-module path in
vi.mock/vi.doMock/jest.mock/jest.doMock(and baremock) calls, plusimport(...)specifiers used in TS type positions (typeof import('@xds/core/Text')), so the mock still intercepts the renamed@astryxdesign/*import. - drop-xds-prefix-imports: un-prefix partial-mock override keys inside an
@xds/coremock factory (e.g.useXDSTruncation→useTruncation) so the override matches the renamed export instead of silently overriding nothing. Scoped to recognized@xds/coremock factories only; unrelated object keys are untouched.
Contributors
Thanks to everyone who contributed to this release:
Full Changelog: v0.1.7...v0.1.8
详细ChangeLog