Skip to content

v0.3.0

Latest

Choose a tag to compare

@cixzhang cixzhang released this 05 Aug 18:50
· 46 commits to main since this release
82d4dab

@astryxdesign/core

Breaking Changes

  • DropdownMenuRadioGroup now takes a required label prop that names the group for assistive tech (applied as aria-label), replacing the previous optional aria-label/aria-labelledby passthrough -- rename aria-label="..." to label="..." (pass aria-labelledby via 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/createDoc and 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/authoring and will be removed next release. Author docs/configs/integrations as plain objects and import types from @astryxdesign/cli/authoring; astryx upgrade repoints existing imports automatically.
  • Remove long-deprecated compatibility APIs from core and CLI. Run astryx upgrade first to migrate the supported replacements for authoring imports, Dialog logical positions, Switch label spacing, and Table root props.

New Features

  • Carousel: add hasLoop for 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 a handleRef imperative handle (CarouselHandle) exposing scrollNext, scrollPrev, scrollTo(index), canScrollNext(), and canScrollPrev() for programmatic control.
  • Center: add padding, paddingInline, paddingBlock (spacing-scale inner padding) props. These match the existing padding props on Stack, Card, LayoutContent, and LayoutPanel, so centered page content no longer needs inline style={{}} or xstyle wrappers for basic padding.
  • ComplexSelector: add a rich custom selector shell with accessible button/popover behavior, async change actions, and optional grid keyboard navigation.
  • defineTheme: make color.accent optional (#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-muted and --color-on-accent ungenerated, so they fall through to the token defaults — the same fall-through expandColorScale already applies to status, categorical and on-dark tokens. Configs that pass an accent are unchanged, token for token.
  • Dialog: add logical start/end offsets to the position prop and deprecate the physical left/right. start/end map to inset-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 physical left/right still work unchanged and never mirror (non-breaking); they are now @deprecated and 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) rewrites position={{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 alignment prop 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-icon and astryx-field-status-icon theme targets on the field status glyph, so consumers can recolor, resize, and restyle it — per status — via defineTheme instead of a fragile descendant selector or raw CSS. astryx-input-status-icon sits on the on-field icon shared by all bordered inputs across the attached and tooltip status variants and reflects data-size/data-status; astryx-field-status-icon sits on the detached message box's leading icon and reflects data-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) via defineTheme instead of overriding global spacing tokens or reaching for fragile [role="paragraph"]-style descendant selectors. Each target reflects data-density (so spacing can differ per default/compact), and the heading target additionally reflects data-level (1–6) for per-level spacing. Targets apply only to the default render path — a custom components.heading/code/blockquote/hr/image continues to own its own styling. Purely additive — default rendering is unchanged.
  • Pagination: add an input variant — an editable page-number box (a NumberInput, so it clamps to [1, totalPages] with integer-only semantics) flanked by first/last («/») buttons, rendering Page [ n ] / N. Navigation is page-based via the existing onChange. The leading noun is set with an open pageLabel prop (defaults to the localized "Page"; pass pageLabel="Row" to relabel it). Also adds a step prop 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. Adds chevronsLeft/chevronsRight icons. 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 marks prop that draws fixed target lines on the track at values in the same 0..max scale as value (e.g. a goal or threshold). Marks stay visible whether progress is below or past them; each mark requires a label (its accessible name, revealed via a tooltip on hover/focus), and marks are ignored in indeterminate mode. The mark tick is directly themeable via the progressbar-mark target — a theme sets backgroundColor, width, and height on 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. Named marks (with a ProgressBarMark type) to match the marks prop on Slider.
  • Icon registry: registerIcons() now accepts arbitrary extension keys (not just built-in IconNames), so libraries can augment the icon map with their own keys. Add getExtendedIcon(name, fallback) — resolves an extension key, preferring a theme-registered icon over a caller-supplied default. This lets library-shipped icons (e.g. the lab RichTextEditorToolbar's richtext:* 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 existing hasSearch dropdowns 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-cell and astryx-table-header-cell now reflect the active row density as data-density (compact/balanced/spacious), so a theme can override cell padding per density via defineTheme. Previously the density split lived entirely in internal StyleX classes with no density:* hook on the cell target, so a components: { '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']}), enabling components: { 'table-cell': { 'density:balanced': { paddingBlock: '12px' } } }. Purely additive — default padding is unchanged.
  • Table: useTableTreeData gains an opt-in hasRowClickExpansion prop. 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: color is now theme-extensible. TextColor is derived from a new TextColorMap interface (same technique as ButtonVariantMap etc.), so a theme can add custom text colors — astryx theme build generates the module augmentation when it sees new color:* values on Text/Heading overrides, and consumers can augment TextColorMap manually for type safety. A custom color renders as a stable class (astryx-text.<color> / astryx-heading.<color>) that theme CSS paints, falling back to the primary StyleX 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.
    Each tooltipEntries row opts into a copy button via isCopyable (default false) — 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 the system_date_time value). 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 the supporting text role (the secondary, quieter register that is Timestamp's own default) and values the body role.
  • Timestamp: add a relative_short format — the compact sibling of relative. 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, and in 5m for future times. Months render as mo (not m) so they never collide with minutes; the short form is always numeric (no yesterday idiom). Like relative, 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_unix format to unix_seconds. The value is absolute Unix time in whole seconds since the epoch — not a wall-clock system_* rendering — so it does not belong to the system_* family; the explicit unit name also leaves room for a future unix_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_unix format renders the value as Unix time in whole seconds since the epoch (e.g. 1771520400) — an absolute, zone-independent machine value, useful as a copyable tooltipEntries row alongside human-readable zones. It joins the system_* machine-readable family and, being absolute, ignores any tooltip time zone. (2) The copyable hover card's copy button now shows a visible Copy tooltip on hover/focus (flipping to Copied after a copy, in step with the icon), so the affordance is discoverable for sighted users; the full Copy <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 spread getContainerProps() on the container and getContentRevealProps() on each child; no StyleX authoring required. Thumbnail's showRemoveOn="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 interactiveRef prop on Item/ListItem (the useClickableContainer pattern), replacing the internal invisible row button. interactiveRef is mutually exclusive with onClick/href.
  • Resizable, TabMenu: two collection ARIA minors (WCAG 4.1.2) — Resizable's collapsed handle clamps aria-valuenow to aria-valuemin and announces a localized "Collapsed" via aria-valuetext, and TabMenu overflow options are menuitemradio with aria-checked (APG menu-button single-select) instead of menuitem + 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-emphasized tone-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 href and onRemove are provided. The token surface now delegates to the link via useClickableContainer, 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/Button subpath.
  • 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 isRtl option on useListFocus and useGridFocus. 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 -> absolute discretely, which previously snapped it out of layout flow at full opacity before the fade could run. The flip now participates in the transition with transition-behavior: allow-discrete and 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 .js extension in the published ESM dist — babel-plugin-add-extensions only rewrote static import/export declarations, so the lazy Tooltip specifier in Text, Heading and Timestamp shipped extensionless and strict-ESM consumers (Rspack, webpack fullySpecified, 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 columns value is honored with stacked labels. columns={3} previously fell back to the responsive repeat(auto-fill, minmax(280px, 1fr)) grid whenever labels were stacked (the default for multi-column lists), so the documented fixed column count only worked with label={{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 inline style object.
  • 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-within ring, matching Selector and 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 spinbutton role 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's dir, 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 physical left: 50% + translateX(-50%) — both reference the same physical edge, so the pair is direction-symmetric and centers identically in LTR and RTL. A logical insetInlineStart: 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 physical left is correct, so the single sanctioned no-physical-properties suppression lives in the helper rather than at each call site.
    The @astryx/no-physical-properties rule now recognises this left: '50%' + centering translate idiom 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-properties lint rule from warn to error in 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 in rtlStyles.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 physical left/right, so they mirror correctly under RTL. The Avatar status dot's outward-push transform is 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 shared rtlStyles.centerInline helper — fixing an RTL regression where a logical insetInlineStart: 50% anchor combined with a physical centering translate shifted 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. The maxLength counter 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/core StyleX peer dependency — add @stylexjs/stylex to the Getting Started / Quick Start install commands in both READMEs, and add an astryx init next-steps reminder to ensure the @stylexjs/stylex peer dependency is met, with a pointer to astryx 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:


@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 upgrade first 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-ASCII key: value records/sections that mirror --json and are greppable by field. Every command was migrated onto it (a lint rule keeps output funneled through the single emit sink), and astryx --help documents the output contract. --json output is unchanged. (#4686)
  • defineTheme: make color.accent optional (#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-muted and --color-on-accent ungenerated, so they fall through to the token defaults — the same fall-through expandColorScale already 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/Button subpath.
  • Remove the @xds/theme-default@astryxdesign/theme-neutral collapse from the v0.1.0 upgrade codemods (module-specifiers, css-surfaces, and declare-module). theme-default was 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.css CSS imports) to a @astryxdesign/theme-neutral package the app never declared. The @xds/theme-dailytheme-neutral collapse (and its defaultThemeneutralTheme export 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/build verbose flag to --verbose, resync the bundled themes, and fix unwrap-authoring-factories edge 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 with name.split('@')[0], which for a scoped peer like @stylexjs/stylex returned an empty string, printing a bare npm install with 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.1 against a ^0.19.0 peer) 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 the KNOWN_COMPONENTS prop lists match what each component renders (#4109)
    loadKnownValues mapped 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 its TextInput/DropdownMenu/AppShell dir and the built-in prop values were silently dropped. It now strips non-letters from both sides before comparing, so hyphenated keys resolve. The KNOWN_COMPONENTS visual-prop lists are also synced to each component's theming.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/next codemods into the resolved release version folder.
  • Document the @astryxdesign/core StyleX peer dependency — add @stylexjs/stylex to the Getting Started / Quick Start install commands in both READMEs, and add an astryx init next-steps reminder to ensure the @stylexjs/stylex peer dependency is met, with a pointer to astryx 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 its type directly ({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 types ComponentDoc, HookDoc, ReferenceDoc, TemplateDoc, and the project-file types AstryxConfig, 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*, and ContentBlock/TokenPreviewType → Reference*. The authorable entry types (ComponentDoc/HookDoc/ReferenceDoc/TemplateDoc) are unchanged.
  • astryx upgrade migrates you automatically. Three codemods ship in this release: unwrap-authoring-factories rewrites every create* call to the plain stamped object, migrate-authoring-imports repoints the import specifiers to @astryxdesign/cli/authoring, and rename-authoring-doctypes applies the doc field-type renames (imports, type references, and JSDoc @type refs).
  • CLI — the public @astryxdesign/cli/api type surface is now generated from the runtime JSDoc, and the injectable logger is consolidated into one Logger.
    Consumer-visible changes to @astryxdesign/cli/api (types only — runtime imports are unchanged):
  • Precise return types. component, docs, blog, discover, build, swizzle, upgrade, init, and themeBuild previously resolved to Promise<any>; they now return their precise { type, data } response unions. Code that leaned on any may surface new (correct) type errors.
  • Response types are now exported by name — e.g. ComponentDetailResponse, SearchResponse, UpgradeRunResponse — alongside themeAdd/themeList/listThemes and a new shared logger value + Logger type.
  • Breaking: the per-command return-union aliases ComponentResult, DiscoverResult, DocsResult, HookResult, and TemplateResult are no longer exported. Use Awaited<ReturnType<typeof component>> (still works), or import the member response types directly.
  • theme build --out/<file>, the validate-integration manifest roots (components/templates/codemods), and layout --file are now confined with assertWithin. An escaping integration root reports a validation issue instead of importing and executing files outside the package; layout --file is also size-capped (5 MB) and rejects non-files, so a stream like /dev/zero can'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__/constructor as 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).
  • assertWithin now 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-integer limit, empty query, unknown --typeERR_INVALID_ARGUMENT (previously limit: 0 returned the full unclamped set).
  • swizzle(): the component name is sanitized so ../separators can't escape the --output base.
  • swizzle() import rewriting: dynamic import('../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.stylex keeps its full subpath (the StyleX compiler needs the dedicated ./theme/tokens.stylex export — collapsing it to <pkg>/theme broke StyleX resolution). Component-local .stylex files that aren't subpath exports keep the working barrel collapse.
  • template() copy: refuses to clobber without overwrite: true (ERR_FILE_EXISTS); adds an overwrite option.
  • upgrade(): the --path scan dir is confined to cwd (--apply rewrites files in place).
  • init(): template scaffold refuses to clobber an existing page.tsx (ERR_FILE_EXISTS); an unknown --agent now throws ERR_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 outline repeat block) 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 surfaces ERR_MISSING_ARGUMENT and a missing --file surfaces ERR_FILE_NOT_FOUND (was a generic ERR_UNKNOWN / a raw ENOENT errno, with a stack leak in human mode).
  • layout parser: a pathologically deep compact expression (V > … nested past 512 levels) is rejected with a located ERR_LAYOUT_PARSE instead of blowing the call stack and surfacing a raw RangeError (→ ERR_UNKNOWN).
  • layout check --form … printers: a string containing a quote (e.g. a Button label="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-string astryx.theme in package.json (number/array/object/boolean) degrades to null instead of crashing astryx component with a raw TypeError (parity with the empty-string / unknown-slug paths).
  • jsonOut: serializes the envelope BEFORE marking the emission handled, so if a command returns unserializable data (circular ref / BigInt — an author bug) the bin error boundary still emits a JSON error envelope instead of leaving a --json consumer with empty stdout.
  • package scanner: a dependency's astryx.docs that is a non-string (number/array) is skipped instead of crashing the whole scan with a raw TypeError, and a docs path that escapes its own package dir is skipped rather than surfacing foreign docs; a non-string package name is coerced to a string.
  • component --package <pkg> --showcase/--blocks: route to the right leaf instead of falling back to component.detail.
  • discover/docs leaves: empty query/section errors instead of matching everything via .includes('').
  • docs()/discover(): a non-string topic/section/query now throws a stable coded error (ERR_UNKNOWN_TOPIC / ERR_UNKNOWN_SECTION / ERR_INVALID_ARGUMENT) instead of a raw TypeError the CLI downgraded to ERR_UNKNOWN (parity with the component/hook non-string guards).
  • blog() detail: a non-string slug throws ERR_INVALID_ARGUMENT (was a raw TypeError the CLI downgraded to ERR_UNKNOWN), and fails fast before any network fetch.
  • hook()/component() dispatchers: a non-string name or category throws a coded error (ERR_UNKNOWN_HOOK / ERR_UNKNOWN_COMPONENT / ERR_UNKNOWN_CATEGORY) instead of a raw TypeError with no .code from the leaf's .toLowerCase()/.replace(...).
  • theme add: a write failure where an ancestor of the target dir is a file now surfaces ERR_WRITE_FAILED (the mkdir moved 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 an invalid_package_spec diagnostic instead of crashing with a raw stack (human) / generic ERR_UNKNOWN (--json).
  • doctor: no longer crashes (raw stack in human mode / ERR_UNKNOWN in --json) when multiple astryx.config.* files coexist — it reports a config FAIL. Version-alignment skips (info) instead of a spurious drift WARN with a NaN.undefined.x fix 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 error code into the --json envelope (bogus --type / non-positive / non-integer --limitERR_INVALID_ARGUMENT instead of a generic ERR_UNKNOWN), and delegates --limit validation to the API (parity with search).
  • layout check: exits 1 in BOTH --json and 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.
  • upgrade config codemods: a findConfigPath throw (multiple astryx.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 on code.
  • toErrorEnvelope/AstryxError: attach suggestions only when it's a real array.
  • injectXdsBlock/removeXdsBlock no 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.
  • resolvePackageDir rejects 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 via issues(), 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-label no longer emits a duplicate label prop when the badge already has one (<XDSBadge label="x">Active</XDSBadge> produced an invalid label="x" label="Active"); it now skips a badge that already declares label.
  • readDocMeta no longer reads a group:/hidden: field nested inside a propDescriptions block (a docsZh/docsDense translation export) as the component's group — that leaked a translated prop description as a group key in the default English component --list (e.g. a Chinese string appeared as a group). The field regexes now match top-level fields only (<=2 spaces).
  • astryx search/build verbose output was unreachable: the boolean --detail flag collided with the root program's value-taking --detail <level>, so search button --detail errored argument missing. The boolean is now --verbose (the global --detail <level> is unchanged).
  • The themes bundled for astryx theme add had drifted from source — the neutral bundle was missing a WCAG AA light-mode text-secondary contrast fix and a StatusDot color block, so astryx theme add neutral scaffolded a theme below AA. All bundles are regenerated to match source, guarded by a new drift test.
  • The unwrap-authoring-factories upgrade codemod produced broken output for a shorthand type property (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:


@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-secondary from 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:

Full Changelog: v0.2.0...v0.3.0