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