v0.4.4
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