Warning
Stepper context compatibility: v0.5.3 changed the package-exported StepperContextValue / useStepperContext shape. Ordinary <Stepper> and <Step> usage is unaffected, but consumers that call the context hook directly or construct StepperContextValue should remain on v0.5.2 while a source-compatible repair is evaluated. See #5659.
Astryx 0.5.3 — all @astryxdesign/* packages ship at this version.
npx astryx upgrade --apply@astryxdesign/core
New Components
- Add built-in
popover,bottom-sheet, and compact-touchadaptivepresentation policies to DropdownMenu, MoreMenu, and ContextMenu. (#5395) - Add popover, bottom-sheet, and adaptive presentation options to Selector and MultiSelector, with docsite examples for both bottom-sheet variants. (#5395)
- Add
isReadOnlyto Selector and MultiSelector so selected values remain focusable and form-submittable without exposing selection menus or editing affordances. (#5805) - Add the opt-in theme-local token contract for maintained theme families. (#5844)
- Add
elevationprop to ToggleButton for floating (FAB-style) toggles, mirroring Button; retained inside a ToggleButtonGroup. (#6012)
New Features
-
Add structured accessibility requirements and theme coverage support to component documentation. (#5713)
-
Banner: the header's supporting line now carries a stable theme target,
astryx-banner-description. (#5483) Only the header, the status icon and the content panel were themeable before, so a theme restyling the description — its colour, its type, or the space between it and the title — had to reach in with a structural selector like.astryx-banner > div:nth-child(2) > div:nth-child(2). Purely additive: no existing class, data attribute, or style changes.
Nothing else in the header becomes a target. The end area is a layout row — flex, wrap, and the edge compensation that lets its buttons overhang the header padding — not a painted surface, and a theme that wants the header to grow around its buttons instead of letting them overhang setspadding-blockon the existingbannertarget, which reaches the same height without exposing a private margin. The title, the two controls and the text column are likewise left alone: the column paints nothing (display: flex; flex-direction: column; gap: 0) and the space it owns is expressible onbanner-description, while the title and the controls already render the way the consuming theme wants them. -
Add
nativePickerto DateTimeInput for browser and OS date/time pickers, with Astryx time fallbacks for seconds, custom increments, and preset options. Native fields follow DateInput's compact minimum sizing in fit-content layouts. (#5620) -
Let themes add typed Heading visual roles with a safe semantic-level
fallback when the owning theme styles are unavailable. (#6026) -
RadioListItem and CheckboxListItem accept rich
labelanddescription(#5257)
RadioListItemtypedlabelanddescriptionasstringwhile its siblingCheckboxListItemalready typedlabelasReactNode— so the same slot had two contracts, and an app whose option descriptions carry links could not type them on either component. Both now takeReactNode; the runtime already rendered it.RadioListItemgains thearia-labelescape hatchCheckboxListItemestablished, with the same meaning: a plain-text accessible name for the control. The radio differs in one way worth knowing — it points at its visible label for its accessible name, so a rich label still names it from its own text, andaria-labelis there to narrow a name that reads badly rather than to supply a missing one.aria-labelnow lands on the radio instead of the row<div>, where ARIA ignored it. -
Stepper:
--step-connector-gap, so a theme can stop the on-track connector short of the indicator
The on-track layouts draw the connector as one segment either side of the node. A theme that wants the track to leave a hole around the indicator had to reach the two segments separately, and they are only distinguishable by sibling position — which changes withindicator="none".One public var does it instead, declared on the Stepper root because component vars are root-owned: a theme writes
stepper: {base: {'--step-connector-gap': '4px'}}and every connector inherits it. Astryx spends it on whichever side each segment faces the node from, so the pair leaves a symmetric hole and the caller never names the pieces.0pxby default: the shipped track still reads as one unbroken line.Measured in Chromium against a built theme override, reading painted pixels down a 12px segment:
| value | clipped away | stepper height | | ------- | ------------- | -------------- | |
6px| 6px | unchanged | |-4px| 0 | unchanged | |1rem| capped to 8px | unchanged | |999px| capped to 8px | unchanged | |10%| 1px (of 12px) | unchanged | |50%| capped to 6px | unchanged |Four things that had to be true and are:
A theme override reaches it. The default is declared once on the root, not on each connector. Declared per-connector, every connector re-declared
0pxon itself, and a value declared on an element beats an inherited one — so a generatedstepperoverride compiled cleanly and changed nothing.The value is bounded, and both halves earn it — neither for padding's reasons.
max(0px, …)becauseinset()accepts a negative length: Chromium computesinset(0 0 -4px 0)as written rather than clamping it the way it clamps negative padding, so the floor has to be declared.min(…, --spacing-2)— the flexible segment's ownmin-height— so an oversized gap leaves a short track rather than an unbounded one. Neither can grow the Stepper; a clip cannot change layout. (An earlier padding-based revision grew a three-step Stepper 108px → 144px at1rem.)The horizontal clip mirrors under
dir="rtl".clip-path: inset()is physical — top/right/bottom/left, no logical form — while the row itself reverses. Left unflipped, the leading segment sits to the right of the node in RTL and still clipped its right edge, so the hole opened at the join between steps instead of at the indicator. Measured before the fix:con0 x=622, indicator x=606, clips RIGHT edge. After:clips LEFT edge, with LTR unchanged. The block axis needs no handling —dirdoes not reverse it.One declaration covers both layers. The gap has to reach the track (the segment's own background) and the accent fill (an absolutely placed
::before). Spending it on each separately meant two declarations on two boxes, so a percentage resolved against a different containing block for each and stopped them ~1.2px apart. A singleclip-path: inset(…)on the segment clips the element and its pseudo-element together against one reference box, so every accepted value behaves identically on both — which is what #5824 requires of a public input across its full value domain. Clipping also cannot change layout, so the node the segment positions cannot move.No indicator, no gap.
indicator="none"renders no node, so a gap there is a hole in a track that is meant to be continuous.Any CSS length or percentage is accepted and behaves the same way on both layers. A percentage resolves against each segment's own box, so a fixed and a flexible segment clip by slightly different amounts from one declared value — cosmetic, bounded by the cap, and recorded as accepted rather than fixed.
Why a custom property and not a guaranteed CSS property. A theme target reaches the element, never its
::before. Measured against a built theme override onstep-connector:paddingBlock: 6pxproduces no hole at all — the background paints to its border box and the fill is out of reach — its only effect being the Stepper growing 108px → 120px;paddingBlockEnd: 6pxproduces no hole either, and addresses only one of the two edges. Only the component can clip both layers together, mirror per axis and direction, and clamp first.Adds
Stepper.spec.md, the canonical owning record for this public property, carrying that admission argument, the value contract, and the anatomy-to-target map.Stepper.doc.mjsgains the anatomy entries its existingstepper,step, andstep-connectortargets never had, so every current target is anchored to a described part.Supersedes the
segmentvariant this PR previously proposed. That exposedlead/rail/contentas public theming vocabulary, which does not hold up: the words never appeared in the generated docs, they emit barelead/contentclasses where a consumer's own stylesheet can collide with them, andleadmeans different geometry per orientation. The pieces are how this layout happens to be drawn today, not a contract. -
Stepper's
horizontalOptions.collapsedVariantlets a flow choosewithLabelAndControls,withLabel, orhiddenLabelfor its compact presentation. UsewithLabelwhen the surrounding flow owns Back/Continue, orhiddenLabelwhen surrounding UI owns both the current-step heading and navigation and only a bare progress track is needed. The default preserves both label and controls, its controls requireonStepClick, and every step keeps its name in the accessible sequence at any width. (#5659) -
Stepper's
horizontalOptions.minimumStepWidthconfigures the per-step width at which a horizontal Stepper collapses. Numbers are interpreted as pixels and strings accept CSS lengths such as7rem,calc(6rem + 8px), and custom properties. The browser resolves string units through an invisible measurement element, and changes to the resolved value update the compact layout. Omitting the option preserves the existing 112px threshold. (#5659) -
Stepper: add
astryx-step-labelandastryx-step-descriptiontheme targets. (#5728)
Both text parts declare their own typography and color, so themes cannot reach them through thesteptarget by inheritance. The new targets apply in both indicator positions and reflectprogressandstatus.step-labelalso reflectsdisabled, because the label owns Stepper's disabled text paint.step-descriptiondoes not. The new targets change no default style. -
Stepper collapses itself in narrow containers instead of leaving each consumer to hand-roll a fallback: a horizontal stepper measures its own width and, once a step has under
horizontalOptions.minimumStepWidth(112px by default), drops the labels to a bare track and uses the configuredcollapsedVariantbeneath it. The breakpoint follows the step count rather than the viewport. Bothseparatedandon-trackleave their compact track presentational; navigation moves to named prev/next controls when configured and whenonStepClickis set. On-track indicators stay on the rail without repeating the active indicator beside the compact label. The full sequence stays intact for screen readers throughout. (#5659)
[fix] Step labels hold to a single line and ellipsize rather than wrapping and breaking mid-word, so a row of horizontal steps keeps one height and the track under it stays straight. The full label is still carried in the step's accessible name.[fix] The gap between connector segments is now
--spacing-1, matching the connector's own thickness, so the track reads as one dashed line at any theme scale. -
Add semantic Table row statuses while restoring custom-marker compatibility. Named custom icons keep their released Icon color mapping; raw CSS custom icons now use the caller's paint as required by the current contract. Canary users relying on implicit glyphs should switch from
colortostatus. (#5832) -
TabList: add an
isFullBleedprop so a tab bar can bleed out to its container's inline content edges instead of requiring hand-written negative-margin CSS (#2622). Like Divider'sisFullBleed, it cancels the nearest padded Layout container's--container-padding-inline-*custom properties with negative margins; the inner strip pads back by the amount the bleed exceeds a tab stop's own padding so edge labels remain aligned to the content inset. It is inline-only: TabList owns the inline full bleed, and the container owns the block-end dock. For that, LayoutHeader gains apaddingBlockEndper-edge override in Section's existing spelling —paddingBlockEnd={0}docks the header's last child on its bottom edge so a tab strip's underline meetshasDividerat any header padding. Thedetail-pagetemplate now uses both props, aligns its ghost panel toggle with the container inset, and no longer carries any hand-written tab-row CSS. -
Add
nativePickerto TimeInput so coarse pointers use the browser/OS time picker by default, withalwaysandneveroverrides. Seconds and custom increments retain Astryx's typed field. (#5811)
Fixes
-
Keep AppShell's section top bar solid in auto-height mode while content scrolls beneath it. (#5873)
-
Extend attached field-status backgrounds behind the lower half of their
controls so rounded and pill-shaped inputs connect without visible gaps while the control remains visually above and receives pointer input across the overlap. (#5769) -
BottomSheet now keeps the iOS Safari browser-bar edge consistent with the sheet surface for both modal and non-modal presentations. (#5373)
-
Keep loading-button spinners at full contrast while interaction is
blocked, and suppress pressed feedback for disabled and loading buttons. (#5627) -
Carousel: mirror the single-edge fade gradients under RTL so the mask fades the physical edge that actually hides content (overflowStart/overflowEnd are logical edges, the gradients were always physical left/right) (#5586)
-
ChatComposerInput no longer discards a pending draft when you click the
composer's padding and press ArrowUp. Focusing a contentEditable collapses the caret to the start of the draft, which is the one position where ArrowUp means "recall history", so the first ArrowUp after that click replaced what you had typed. The composer now places the caret after the draft when it focuses itself — clicking the space after the text means "put me there" — so ArrowUp moves the caret with a draft present and still recalls history when the composer is empty. Multi-line caret navigation is unchanged. (#6051) -
ChatComposer: add a keyboard-only focus ring around the composer body when its editor receives focus (#5648)
The ring uses the shared theme focus tokens and does not appear for pointer focus or when an internal action button owns focus. -
Chat/useChatStreamScroll: an upward scroll releases auto-follow in both motion modes, and only the reader can release it. While following, the hook owns the container's position: it disables CSS scroll anchoring on the scroll element, so the only move the browser makes on its own is the resize clamp onto the bottom, and any other upward move is read as the reader. A wheel or drag a nested scroller consumes, or a block collapsing above the viewport, no longer touches the lock either way; unlocked, anchoring is restored. The wheel and touch listeners are gone.
jumpToBottomalso cancels the spring's pending frame, so animation loops cannot stack. (#5662, #5663) -
ChatToolCalls now announces pending, running, complete, and failed statuses to assistive technology, including expandable rows and collapsed tool-call groups. (#5666)
-
ChatComposerInput: ArrowUp/Down only recall message history at the text boundaries, so the caret can move between lines of a multi-line draft (#4284)
-
CheckIndicator: start the docsite properties preview in the checked state (#5972)
Seeds acheckedplayground default so the properties-tab preview shows a visible indicator on first load instead of an empty stage. -
Collapsible's trigger label now fills the row instead of hugging its own content, so a composed trigger can put something at the far edge next to the chevron. The trigger is aspace-betweenflex row, but its label span had noflex-grow— so the free space collected between the label and the chevron, and a trigger built as<HStack>with a right-hand element (a date, a count, a status) had that element parked against the label with a gap after it, unable to reach the edge thatspace-betweenimplies.flexGrow: 1on the label is the whole change. For a plain text trigger nothing moves: the label was already flush to the start edge and the chevron to the end, and the box that grew is one the text does not fill. The flex floor is deliberately left atauto, so no label can now be squeezed narrower than its own content and start overlapping the chevron. (#5933) -
DropdownMenuRadioItem: add playground wrapper and wire wrapper selection state for docsite preview (#5917)
Wraps DropdownMenuRadioItem in DropdownMenuRadioGroup wrapper and keeps the wrapper's selection independent from the item's value knob, so aria-checked stays false until the item is activated and updates on click. -
Keep DropdownMenu and submenu flyouts inside the viewport with safe inline gutters and viewport-aware height limits. Only overflowing menus become internal scroll containers, while
menuWidthkeeps its existing minimum-width behavior up to the available space. (#5395)
[feat] Add an opt-inpresentationprop for data-driven DropdownMenu instances so products can render the same actions as an anchored popover or a modal bottom sheet according to their own responsive input policy. -
FieldLabel: a field's description now sits flush under its label without breaking existing label layout overrides. (#5673)
A label and its description are one block of text, but nothing inFieldLabelsaid so. It returned a fragment, leaving the<label>and the description<span>as bare siblings of whatever column happened to hold them — so the space between them was set by that parent'sgap, the same declaration that separates the label group from the control below it. No caller could close the pair without also pulling the control up against the description, and each had picked its own value.Measured in Chromium as
description.top - label.bottom:| | label → description | description → control | | ------------------------- | ------------------- | ----------------------------------- | |
Field,TextInput| 4px → 0px | 4px → 4px | |CheckboxInput,Switch| 2px → 0px | n/a — control sits beside the label |The label and description now share a wrapper of their own, so the space between them is theirs to set rather than a side effect of the caller's column. Only the pair closes up: the description → control gap is unchanged, so fields keep their existing rhythm.
CheckboxInputandSwitcheach carried a 2px label wrapper to do this job locally, which the shared wrapper makes redundant, so all three callers now agree instead of each choosing a value.A hidden label group takes
display: contents, so the wrapper box leaves the caller's layout entirely and the sr-only label and description stay out of flow exactly as they were — a hidden label still costs no space and draws no gap.This is one change in
FieldLabelrather than a change across the ~20 input components, because every input reaches its label throughField. -
useFocusTrap only restores focus when focus actually entered the trap while it was active. (#5651)
useFocusTrapcaptureddocument.activeElementon activation and restored focus to it on deactivation whenever focus would otherwise be lost to<body>. For popups that deliberately keep DOM focus on their trigger — a Typeahead or PowerSearch listbox opened withrole: "none"andhasAutoFocus: false— the trap never receives focus, so the restore fired on outside-click dismissal and re-focused the anchor input. Because the input was then already focused, clicking it again fired nofocusevent andhasEntriesOnFocuscould not reopen the menu — the control was stuck until a second outside click.The restore effect now tracks whether focus entered the trap container at any point while it was active (via a
focusinlistener). If focus never entered, the restore is skipped entirely. Popups that do take focus — Dialog, DropdownMenu, a Typeahead option click — are unaffected. -
Core: track keyboard and pointer modality once per document instead of initializing global listeners from every consuming component. (#5881)
-
LayoutHeader: add playground wrapper and default children for docsite preview (#5918)
Prevents the properties-tab preview on the docsite from rendering an empty stage by wrapping LayoutHeader inside a Layout scaffold with representative header text. -
LayoutPanel: add playground wrapper and default children for docsite preview (#5919)
Prevents the properties-tab preview on the docsite from rendering an empty stage by wrapping LayoutPanel inside a Layout scaffold with representative panel content in start slot. -
Streamed Markdown no longer goes blank when the line still arriving
contains an escaped pipe. A\|is literal text, not a table-cell delimiter, so a line carrying only escaped pipes is ordinary prose and renders as it streams instead of being held back as an unfinished table header. Genuine partial table syntax is still suppressed, and incremental parsing stays bounded to the stream tail. (#6051) -
Popover layers now cap explicit widths and match-trigger sizing to the available viewport with alignment-aware token safe-area gutters, preserving trigger alignment while keeping the painted surface at least one spacing token from both viewport edges. Long content scrolls inside the layer instead of forcing page overflow on narrow viewports. Repeated resize and content-change signals coalesce overflow measurement to once per animation frame. Pointer-activated dialog popovers focus the labeled dialog container so the first action does not appear preselected, while keyboard activation still focuses the first content control. Read-only content uses the same container target without revealing the fallback close button, while preserving Tab access to that fallback escape control. (#5373)
-
Reuse
DateRangeInputfor PowerSearch date-range values so endpoint selection always emits an ordered range. (#6004) -
useResizable: percentage configuration with an explicit basis (AST-010)
Implements the accepted AST-010 contract. Percentages configure a pixel size; they never create a second, responsive sizing mode. -
ResizeHandle: a drag survives the cursor crossing an embedded frame. (#5297) The handle listened for
pointermove/pointeruponwindowwithout taking pointer capture, so the browser hit-tested every later event — and the moment the cursor entered an<iframe>inside the resizable region the events went to the guest document instead. Measured in Chromium, the host received 0 of 25 pointermoves once the cursor was over the frame, the panel stopped tracking, and thepointerupwas never heard: the handle stayed armed withdata-resizingset and the body cursor/user-selectoverrides stuck. The drag now takes pointer capture on the grab zone onpointerdown, so the whole gesture is delivered there whatever is underneath, and the move/up/cancel handlers sit on that element rather than onwindow(the same shape as Slider and BottomSheet). -
Breadcrumbs mirrors its built-in slash separator in right-to-left layouts (#5365)
Fixes #5364. -
sharedResizeObserver: independent subscriptions per element (#5817)
The module held one callback per element —callbacks.set(element, callback)overwrote. A second hook observing the same node silently replaced the first, and either one callingunobserveResize(element)blinded the other.Two hooks on one element is ordinary rather than exotic: a
TabListroot, auseOverflowcontainer and auseTruncationtarget are all nodes another hook may reasonably watch.observeResizenow returns an unsubscribe that removes only its own registration, and every caller in the package uses it.unobserveResize(element, callback)does the same by hand; the callback-lessunobserveResize(element)still drops every callback on the element and stays for a caller that owns its element outright.Dispatch snapshots the callback keys, so a callback may unsubscribe while the batch is running without skipping its neighbour.
Prerequisite for AST-010 §Implementation-requirements 9, kept separate so it is reviewable on its own. No component behaviour changes: 8534 core tests pass, and the five observer regressions fail against the old module.
-
Spinner: a narrow flex host no longer compresses the box and clips the ring (#5484)
The spinner's box carriedoverflow: hiddenfrom the canvas ring it no longer draws. It clipped nothing — the painted circle is inscribed in the box, so hiding or showing the overflow renders the same pixels at every size and shade — but a flex item whose overflow is notvisiblehas an automatic minimum size of zero. That left the box with no floor: a flex host narrower than the spinner compressed it while the ring kept drawing at the size its own attributes ask for, and the clip then cut the ring off at the box edge, silently, because a sliced ring still spins.Ordinary layouts reached it. A
mdspinner beside a label in a 140px row rendered a 16px box around a 20px ring; anlgspinner next to aflex: 1 0 100pxsibling lost half of its ring. The clip is gone and the box isflex-shrink: 0, so the box and the ring stay one measurement and a spinner that does not fit overflows its host visibly instead. Nothing moves for a spinner whose host already fitted it. -
Spinner: the drawn frame follows a themed
--spinner-diameter, so a
themed ring is no longer clipped or off-centre. (#5214)--spinner-diameterand--spinner-stroke-widthset the ring in CSS, and the box the ring sits in is composed from those same two vars — but the<svg>was sized and given itsviewBoxin JS, from the size's own constants. Theming the diameter therefore left the frame behind: the svg stayed at its default while the box shrank around it, and an overflowing grid item aligns to start rather than centre. Measured in Chromium across the four sizes, a themed ring rendered 1.5-3.3px off-centre with its far edge cropped.The svg is now sized in CSS from
--_spinner-box-size— the same composed var the span is sized from — with noviewBox, so one user unit is one pixel and the frame moves with the box. (Not a percentage: the span is a grid whose area is not always definite in both axes, and an unresolved percentage height on an SVG falls back to the replaced-element default of 150px.) The pxwidth/heightattributes remain as the no-stylesheet fallback, asrandstroke-widthalready were. Both circles centre oncx/cy="50%", and the arc's twelve-o'clock offset is a CSS rotation about the shape's own box rather than an SVG transform about a centre in user units.No change to the default render at any size — same box, ring, stroke and sweep, verified against a build of
main. What changes is that the documented claim "the rendered box … follows automatically" is now true. -
Table's sortable header button now follows its column's
align, so analign: 'end'oralign: 'center'column no longer gets a start-hugging header label sitting above right-aligned figures. Sorting wraps the header in a full-width flex button, which thetextAlignthatalignsets on the cell cannot position; the alignment is now carried onto the button's main axis with a flow-relativejustify-content, so it keeps mirroring under RTL. (#5928) -
Route table-row outcomes and completed or failed tool calls through the theme's semantic icon registry. (#5671)
-
themingTargets.test.tsnow discovers component sources at any depth undersrc, not only in a top-level directory. (#5784) Sources nested a level down —Table/plugins/<name>/— were silently exempt from the guard, which is the same drift #3741 was filed to prevent. Nothing was failing (no nested source rendered athemeProps()class before this release), so this closes the hole rather than fixing a live break: the guard goes from 294 to 302 assertions. -
Timestamp relative and compact-relative labels now follow the active provider locale, including locale-specific plural rules and word order. (#5859)
-
Toast: the card's shadow is no longer clipped away. (#5547)
Each toast's grid row usedoverflow: hiddenthroughout its lifetime. The clip is load-bearing while the row opens and closes — it makes the toast read as folding into the stack — but at rest it hugs the card's border box with zero slack on every side and cuts off every shadow the card casts. Astryx's own--shadow-medwas declared and invisible: against a white page, every sampled pixel below a stock toast was pure white.The row now keeps its cross-engine
overflow: hiddenboundary during entry and exit, and releases it tooverflow: visibleonly after the opening transition settles. Dismissal restores the clip synchronously. The wrapper keeps its ordinary pointer boundary, so a second click while the toast is still visible is absorbed by the toast rather than falling through to an obscured control underneath.This avoids
overflow-clip-margin, which WebKit 26.5 does not support, while preserving the exact paint boundary the exit shipped with before this fix.Settled state is held on the mounted row rather than in a set of toast ids on the viewport, so it cannot outlive the row it describes. A row leaves the DOM by more paths than dismissal —
maxVisibleevicts the oldest when a newer toast arrives, and auniqueIDoverwrite swaps a new entry into a replaced toast's place — and on neither path does anything on the dismissal path run. An evicted toast that resurfaces once the stack drains therefore mounts clipped and runs its own entry transition, instead of releasing the clip over a row that is still opening.One further lifecycle guard: only the row's own transition is read, since
grid-template-rowsis not private to the wrapper andtransitionendbubbles from any descendant animating its own grid. Reading a descendant's event as the row's own releases the clip before the row has finished opening, and during exit it unmounts the toast mid-collapse.Below a settled stock toast on a white page, sampling straight down from the card's bottom border box:
255,255,255at every offset before; after, the shadow paints223at +0px and fades237 → 243 → 247 → 250 → 253 → 254, reaching white again at +12px. During exit the row clips again, so anything outside the shrinking row is neither painted nor hit-testable — while the wrapper itself keeps the ordinary pointer boundary it has always had, and still absorbs a click aimed at a toast that is still on screen. -
Reset Toast swipe state when a second touch begins so native pinch and two-finger gestures remain available, and clear transient drag styles before a successful swipe dismissal. (#5676)
-
ToastViewportresets the UA popoverwidth, so an end-positioned toast lands on the end edge again. (#5822) The viewport reaches the top layer throughpopover="manual", and the UA stylesheet gives every popoverwidth: fit-content. Since the placement rework the viewport is positioned by spanning the inline axis and aligning within itself, and a shrink-wrapped box cannot span — both inset edges cannot be honoured, so the box resolves against the start edge andalign-items: flex-endaligns the toast to the right of a box sitting on the left. Measured in Chromium at 1200px: a 438px viewport at x=0, with the defaultbottomEndtoast at x=19 instead of x=781. The reset block already neutralisedinset,margin,borderandbackground;widthbelongs with them. -
Tokenizer: render and interact in the docsite properties preview (#5982)
Seeds playground defaults for the requiredvaluearray so the properties-tab preview shows a labeled field with tokens on first load instead of the missing-required-props placeholder, and wires the preview'sonChangebridge back to the controlledvalueso removing a token updates the field. -
TreeList: respect consumer
onKeyDownpreventDefaultcancellation for APG tree keyboard navigation (#5606)
TreeListpreviously processed built-in APG keyboard navigation on the inner<ul role="tree">before consumeronKeyDownran on the root<div>, preventing consumerevent.preventDefault()from suppressing built-in arrow navigation.Root
onKeyDownnow invokes consumeronKeyDownon the root container first and checksevent.defaultPreventedbefore handling internal tree navigation for keydown events originating inside the<ul role="tree">. Callingevent.preventDefault()inonKeyDownnow successfully cancels built-in navigation and leaves focus and roving tabindex unchanged while preserving root handler target contracts. -
Typeahead: the field keeps its width when a value is selected, and the value stays out of the end controls (#5560)
Two halves of one promise from the input-field family contract (docs/families/input-fields.md): FR1, a field's available width does not change because its value did; and FR2, a visible end affordance does not have field content painted under it.FR1 — the input keeps its place. Every other field in the family gets a stable width for free: the
<input>stays in flow, and the field is as wide as the input's own intrinsic width. Typeahead took the input out of flow and zeroed its width while a token showed, so the field was left measuring the token. In any shrink-to-fit parent it snapped to the value's length. Block-level parents hid it, because they fill their container whatever their content is, which is why no story caught it. The input now keeps its place in the row and its own width — it is only made invisible and inert — and the token is painted over that space rather than beside it. In flow the token would add its own width instead, which is the same value-dependent sizing from the other direction: a long value would grow the field.FR2 — the value is bounded by a content lane. The input and the token share a content lane: an ordinary flex item,
flex: 1withmin-width: 0, that ends exactly where the end lane begins. That is TextInput's own arrangement — the lane takes the free space so the end controls sit in the corner, and yields all of it when the field is narrow, so a narrow field cannot overflow. The token is anchored at both of the lane's inline edges, so a long value ellipsizes at the lane's edge instead of reaching the controls. Positioned against the whole field instead, as the first revision of this change did, it had no idea where those controls start.Measured in Chromium. Widths are the field's border box, field in a
max-contentparent,Field.widthotherwise unset:| | empty | short value | long value | | --------------------------------- | ----- | ----------- | ------------ | | TextInput (family baseline) | 199px | 227px | 227px | | Typeahead before | 199px | 54.7px | 224.09px | | Typeahead after | 199px | 223px | 223px | | Typeahead in
InputGroup, before | 397px | 252.7px | 422.09px | | Typeahead inInputGroup, after | 397px | 421px | 421px |The 24px between the empty and valued columns is the clear button entering the row — ordinary for any field whose clear is conditional, it does not vary with the value, and TextInput's is 28px.
Overlap is the value's trailing edge past the clear button's leading edge; escape is how far the value reaches past the field's border. The middle column is this change's own first revision, which fixed the width and made the overlap worse:
| field, long value | overlap on main | first revision | now | | ----------------- | --------------- | -------------- | --------------- | | shrink-to-fit | 12px | 28.09px | none, 7px clear | | in
InputGroup| 12px | 33px | none, 7px clear | | 220px | 12px | 31.09px | none, 7px clear | | 180px | 12px | 33px | none, 7px clear | | 140px | 12px | 33px | none, 7px clear | | escape, 140–220px | none | up to 4px | none |No new API and no constants. An earlier revision floored the field with a
--typeahead-min-widthpublic var defaulting to 200px, which review rightly rejected: it was a second sizing contract beside the documentedField.widthprop, it was hand-derived (the empty field measures 199, so the floor overshot by 1),InputGroupcancelled it, and it could not helpTokenizer. Nothing here states a width; the lane'smin-width: 0is the opposite of a floor.Tokenizeris not fixed here. It shares the family promise and breaks it — 199px empty to 114.7px with one token, in the same probe — but by a different mechanism: its tokens are in flow and wrap, and its input deliberately becomes a 40px continuation lane after them, so what a wrapping multi-value field's width should be is a design question rather than this bug. Its numbers are identical before and after this change. -
Typeahead, Tokenizer: the busy indicator is a Spinner in the field's end lane, and the input keeps its text out from under it (#5555)
Three defects in one block. The indicator a search painted was<Icon icon="clock">— a static glyph, in a family where every other input paints busy with aSpinner, and whereclockotherwise means time. It was an in-flow item at the row's inline end, which is where each field independently parks its clear button, so the two landed on each other: 17×20px of overlap in Typeahead and 19×20px in Tokenizer. The overlap is visual, not functional — the clear button is positioned, so it paints above the in-flow indicator and stays clickable across the whole covered band. And the combobox never carriedaria-busy, unlike every sibling input.The base engine now reports the busy state to the field, which paints it in the one inline-end lane it already owns beside its clear button and end content, and sets
aria-busyon the input. A caller usingBaseTypeaheaddirectly is unaffected: it still renders its own visible, named "Loading" status, now a Spinner rather than the clock.Typeahead puts both controls in flow, as ordinary flex siblings of the input, exactly as TextInput does with its own spinner and clear button — an in-flow box takes up room, so the input cannot run under it and there is nothing to measure. Getting there meant dropping
flex-wrap: wrapfrom its wrapper, which the shared field base does not set and TextInput does not use: this field holds at most one token, so there is no second row to wrap to, and wrapping is what made an in-flow lane impossible, since flex moves an item to a new line rather than shrinking it. Measured in Chromium: withflex-wraprestored and a token too wide to share the row, the end controls drop to a second row and a 280px field grows from 32px to 46px tall. Unwrapped, a long value ellipsizes in the token instead.Tokenizer's own pre-existing case of the overlap closes with it: at 280px with a token and no search running, its clear button covered 20px of the input's content box, and covers none now.
Tokenizer keeps a measured lane, because it cannot use the in-flow shape: its lane stays pinned to the field's first row while tokens wrap below it, so it has to be out of flow, and an out-of-flow box reserves nothing. Its width is measured with
offsetWidthrather thangetBoundingClientRect(). The rect is in viewport space — it carries every CSS transform above the element — while the padding it feeds is in local space, so mixing them broke under any transform: measured in Chromium,scale(.5)reserved half of what was needed and put the query back under the controls by 22.83px, andscale(2)left the caret in a 202.69px gap.offsetWidthis the untransformed border-box width and reports the same number at every scale.The measurement reaches CSS as a custom property written to the field wrapper, never as React state, so a lane that grows or shrinks repaints without re-rendering the field. Held in state it cost a second commit every time the lane changed size — once as the spinner arrived and once as it left — which doubled the field's commits across a search for a value no JavaScript reads. The observation is shared too, through the same
observeResizesingletonuseTruncationuses, so a page of fields costs one callback per frame rather than one observer each. The property is--_tokenizer-end-lane-width: private and component-named, like every other runtime layout var in the package, and never something a theme writes.The busy indicator now appears in each field's documented anatomy, delegating its theming to
component:Spinnerrather than gaining a target of its own — the dispositionTextArea,CheckboxListandCommandPalettealready use for the same part.
Performance
- Keep Tooltip refs stable across rerenders (#5951)
- Markdown streaming bounds four incremental-parse operations by the mutable tail: splitting, fence/boundary detection, link-definition collection, and block re-parsing no longer grow with the already-settled document. The parser contract is unchanged: each call returns a fresh, never-mutated snapshot, and replacing already-settled text still re-parses the document. Two costs intentionally remain proportional to the whole input on each call because that contract requires them — the settled-prefix comparison that detects a replaced document, and the pointer-per-block copy behind each returned snapshot. (#5515)
- Typeahead: skip the loading cycle for synchronous bootstrap sources (#5955)
BaseTypeaheadnow applies an array returned bySearchSource.bootstrap()immediately instead of entering and leaving the asynchronous loading state. An empty synchronous bootstrap becomes a render no-op, while synchronous entries still open normally. Switching from an in-flight search to a synchronous bootstrap also clears the superseded search's loading state. Promise-backed bootstrap sources keep the existing loading behavior.
Documentation
- Grid, Stack, HStack, VStack, GridSpan, and StackItem: seed example content via playground defaults (and, for the two sub-components, a real parent wrapper) so the docsite properties-tab preview renders a working component instead of an empty stage. (#5892, #5893, #5894, #5898, #5899, #5900)
- The namespaced-icon rationale and the add-a-semantic-icon intro in the icons guide, and the
SideNavItemactionsprop description, now use a comma and a colon in place of prose em dashes. Meaning unchanged. (#5647) - The Popover presentation best practice and the Banner collapsible best practice now use a straight apostrophe instead of a curly one, so the strings match the rest of the doc copy. Meaning unchanged. (#5772)
- The Spinner CSS-variable and size descriptions, and the
useTableGroupedRowsdescription, now use colons and semicolons in place of prose em dashes. Meaning unchanged. (#5597) - The Stepper progress bar anatomy description now sets its nested aside in parentheses instead of paired em dashes, so the sentence about multi-segment spans reads plainly in the CLI and doc site. Meaning unchanged. (#5691)
Other Changes
-
Core's postinstall no longer hand-mirrors the setup contract.
packages/core/scripts/agent-doc-state.mjsis now GENERATED byte-for-byte from the CLI's dependency-free leafpackages/cli/foundation/agent-docs/agent-doc-state.mjs, andpnpm check:setup-contract— wired intocheck:repo— fails the build when the two differ. (#4162)
The previous guard compared two hand-edited constant lists. That caught a new agent-doc path or a new marker, and nothing else: the predicate itself, and theshouldNudgedecision matrix duplicated in both postinstall scripts, could still drift and leave layer 1 and layer 2 disagreeing about "is this project set up?" with the test green.shouldNudgeand the nudge string move into the contract as well, so all four things — paths, markers, predicate, decision — now have one definition and one place to edit.Behavior is unchanged, and verified rather than assumed: the nudge text is byte-identical, legacy
<!-- XDS:START -->blocks still count as set up, all six agent-doc locations are still detected, and both scripts still exit 0 on every path including failure. Core loads its copy with a dynamic import, so a packaging mistake degrades to "no nudge" instead of throwing out of module evaluation and failing a consumer's install.check:setup-contractalso fails if core stops listing the generated file infiles, so it cannot go missing in the first place. -
minSize/maxSizejoindefaultSizein one vocabulary: a non-negative finite number, an exactNpx, an exactN%from 0–100, Table's existingpixel(value), orpercent(value, {min: pixel(value)})/percent(value, {max: pixel(value)})for a percentage with exactly one pixel floor or ceiling.percent()requires its options;'40%'remains the only unbounded percentage spelling.minSizePx/maxSizePxremain deprecated aliases, each an exact mutually-exclusive TypeScript union with its replacement; if untyped code supplies both, the unified prop wins and development names the ignored alias. -
containerRef(caller-owned) changes only what a percentage is a share of: that element's content-box size on the active axis,directionselecting inline or block. Omitted, percentages keep the released one-timewindow.innerWidthresolution with its 1200px server fallback. -
A percentage default resolves once into a pixel selection, applying its optional structured floor or ceiling exactly once. Percentage bounds re-resolve with their basis, apply that one pixel bound, and clamp the selection — they never rescale it. A basis change is not a user interaction: it fires no
onSizeChangeand persists only resolved pixels. -
Everything else stays pixels, exactly as released: pointer, keyboard, snaps, collapse/expand, persistence, callbacks, and
resize(number).resize('50%')remains a type error, andresize(NaN),resize(Infinity)or a negative now warn and keep the last legal size instead of poisoning state. -
Invalid configuration repairs deterministically — 250px for a default, 50px for a minimum, unbounded for a maximum — identically in development and production, warning only in development. Explicit
maxSize: InfinityandmaxSizePx: Infinitykeep the released unbounded behavior. The deprecated aliases retain their released exact atomic-string behavior for untyped callers. An inverted pair warns and the maximum wins, preserving the released clamp order.The structured API follows Table's existing shape rather than parsing CSS expressions:
Resizable/utilsis a server-safe subpath that re-exports the exact samepixel()binding andPixelWidthtype asTable/utils, alongside Resizable'spercent()and types.pixel(value)is the canonical structured static size; raw numbers and exactNpxremain compatible.proportional()remains Table-only because it describes sibling weight, not a literal percentage of one measured basis. CSSmin()/max()strings are deliberately unsupported.The defect this closes: a percentage ceiling could previously only be written in CSS, and CSS stops the paint but not the state.
ResizeHandlepublishes the hook's size asaria-valuenow, so the separator announced a width the panel did not have — measured at 899.5 against a 434px panel. Bounds now clamp the state, so paint, persistence and ARIA describe one geometry.ResizeHandlealso warns in development when itsdirectiondisagrees with its region's, which previously failed silently. Existing vertical panels must passdirection: 'vertical'touseResizableas well asdirection="vertical"to the handle.The container basis follows the ref, not the element it first pointed at: replacing the element behind the same
containerRefre-resolves against the replacement, and the element left behind is unobserved. A container that is not laid out yet — unmounted,display:none, detached — measures 0, which is not a measurement: percentages hold the documented temporary 1200px basis until it is real, and nothing is written toautoSaveIdstorage from it. Once the first real basis resolves, the default is committed as a pixel selection with its initial clamp included; a 321px default clamped to 200px therefore stays 200px when the container later grows instead of reviving the raw default.A gesture that is cancelled rather than completed —
pointercancel, a lost pointer capture, a handle unmounted mid-drag — releases the basis it froze through a new optional_onResizeCancelonResizableProps. It is not a resize end (a cancelled drag deliberately signals none, per #5297), but it is the end of the gesture._onResizeCanceland_directionare both optional:ResizablePropsis exported, so an object literal that satisfied the released type still compiles.Not in scope, per the spec: SideNav's simplified
defaultWidth/minWidth/maxWidthstays pixel-only.A pixel-only configuration keeps its single render pass even when a
containerRefis supplied. With no percentage anywhere there is no basis to observe or ref identity to follow, so the pixel selection is made at mount; only a basis-dependent configuration with a supplied container defers until that measurement exists.
@astryxdesign/cli
New Components
- Add popover, bottom-sheet, and adaptive presentation options to Selector and MultiSelector, with docsite examples for both bottom-sheet variants. (#5395)
- Reuse Neutral-owned local tokens for semantic status fills across badges, status dots, step indicators, and progress bars. (#5854)
- Add Neutral's reproducible, theme-owned OKLCH palette without changing
its runtime token mappings. The request, receipt, generated result, and CLI template artifacts are committed together for review. (#5987) - Add the opt-in theme-local token contract for maintained theme families. (#5844)
New Features
-
Add structured accessibility requirements and theme coverage support to component documentation. (#5713)
-
Add the checkout wizard page template. (#5660)
-
CLI: record every command run and hand it to a function you supply. (#4812)
// astryx.config.mjs export default { debug: event => appendFileSync('runs.ndjson', JSON.stringify(event) + '\n'), };
That is the whole feature. Setting
debugopts in; the function receives oneDebugEventper invocation and decides what happens to it. The CLI stores nothing.Each event carries the command, its arguments and flags (with their Commander source, so you can tell a typed flag from a default), the outcome, exit code, duration, error code, a coarse environment snapshot including which coding agent invoked the CLI, and — under
output— everything the command printed to stdout and stderr. That last part is the answer the user actually got, which is what makes a record useful for improving the output rather than just counting invocations. Streams are captured separately with their true byte counts, and truncated past 32KB per stream so a command that prints a whole file does not dominate the record. Coverage is the point: handled errors, parse errors,--help, rejected invocations, uncaught throws, and Ctrl-C all report. The event is delivered from aprocess.on('exit')listener because the CLI's error path exits synchronously — anything hooked to normal completion would report successes and almost no failures — and the handler is loaded before parsing, because parse errors and--helpshort-circuit before any hook runs.eventis a published contract:DebugEventis exported from@astryxdesign/cli/debugwith a sealed zod validator,parseDebugEvent, drift-locked to the type so the recorder cannot add a field without publishing it.schemaVersionis a literal, so widening it turns every consumer's branch into a compile error rather than a silent misread.The handler runs synchronously at exit — a returned promise is never awaited, so network delivery from inside it will not work; write a file or spawn a detached child. It receives a copy, so a handler that throws, or mutates what it was given, can neither fail the command nor affect anything else. Follow-up hardening keeps a handler from replacing the command's exit code and routes handler writes away from stdout so a
--jsonenvelope stays valid. (#5929)Nothing changes for a project that has not set
debug. Startup is unmoved: the environment probe is deferred to delivery rather than run inbegin, because its firstIntlcall initialises ICU and that alone was ~9% of the CLI's startup for everyone. Nor does the config run:Project.loadevaluates the config module and loads its integrations, which most commands never did, so the file is read as text first and only loaded when the worddebugappears in it. Measured across eight commands, no command evaluates a config that did not already.Values are scrubbed before delivery: home paths, absolute paths inside stack frames, email addresses, URL credentials, credential-shaped strings, and the value half of a sensitive assignment wherever it appears — including where an error message, a stack frame and the captured stderr all quote the flag that was rejected. Sensitive names are matched with
-and_stripped, so--api-key,--api_keyand--apiKeyare one rule;key,patandpware matched whole so they do not take--keyboardand--pathwith them.argvis scrubbed pairwise, so--token hunter2loses its value the way--token=hunter2does. Oversized values are clamped.Hardened against three adversarial chaos runs and an independent review, each finding mutation-tested before its fix landed: a
__proto__key silently reparenting the record that carried it, one oversized value discarding the whole event, an exit that bypassedcliErrorbeing indistinguishable from a classified failure, a signal-terminated run leaving no record at all, a sensitive--flag=valuescrubbed inargvbut written back out in full through the error message and captured stderr that quote it, absolute paths surviving inside stack frames — where nothing puts whitespace in front of them — and taking the machine's username with them, a graceful Ctrl-C recorded as a failure with an exit code the process never returned,--api-keyand--token valuereaching a handler intact, and the two startup costs above.One change reaches beyond this feature:
installJsonShimnow shims commands as they join the command tree rather than in a single walk at startup, so a command registered later can no longer silently fall out of the--jsoncontract. -
CLI:
astryx init --jsonnow works. It emits the install receipt as a standard envelope —init.runwith the mode, the features that ran, the agent-doc files written, any softdocsError, and the template outcome, orinit.removefor--remove-agents. Human output is suppressed so stdout carries only the envelope, and the exit code is unchanged from human mode. (#4812)
initwas the last side-effecting command still refused by the--jsongate. That gate existed to stop a command writing half a project and only then reporting that--jsonwas unsupported; sinceinit()already returned a typed receipt, the fix was to emit it rather than to keep refusing.themeandlayoutremain off the allowlist, but both are command groups with no output of their own. -
Add result and agent-session context to DebugEvent (#5971)
-
Add the dialog wizard page template. (#5798)
-
Let themes add typed Heading visual roles with a safe semantic-level
fallback when the owning theme styles are unavailable. (#6026) -
Add the form wizard page template. (#5664)
-
Add the inline wizard page template. (#5797)
-
An integration can now supply a
debughandler, so installing it turns on its debug logs with no change to the app. Exportdebugfromastryx.integration.*; the app's owndebugstill runs, both get every event, and a handler that throws cannot affect the command. Opt out with{"astryx": {"inheritDebug": false}}. (#5998) -
Mute the low-tone edge of Neutral's dark chromatic palette while preserving its light and neutral ramps. (#6069)
-
Rebuild the
table-pagetemplate as a searchable, filterable, sortable table pattern with filter-aware totals, row detail, a scrolling document masthead, and guidance organized around narrowing, sorting, and communicating filtered state. (#5865) -
TabList: add an
isFullBleedprop so a tab bar can bleed out to its container's inline content edges instead of requiring hand-written negative-margin CSS (#2622). Like Divider'sisFullBleed, it cancels the nearest padded Layout container's--container-padding-inline-*custom properties with negative margins; the inner strip pads back by the amount the bleed exceeds a tab stop's own padding so edge labels remain aligned to the content inset. It is inline-only: TabList owns the inline full bleed, and the container owns the block-end dock. For that, LayoutHeader gains apaddingBlockEndper-edge override in Section's existing spelling —paddingBlockEnd={0}docks the header's last child on its bottom edge so a tab strip's underline meetshasDividerat any header padding. Thedetail-pagetemplate now uses both props, aligns its ghost panel toggle with the container inset, and no longer carries any hand-written tab-row CSS. -
Add the vertical wizard page template. (#5672)
-
Add the
work-item-detailpage template for task, ticket, issue, story, bug, card, and request detail surfaces. It includes responsive main-content and details-rail layouts, editable metadata, subtasks, attachments, comments, activity, and a narrow-viewport details dialog. (#5926)
Fixes
-
build: a page that matched one word of the query is no longer offered as a direct match. A page template's keywords include every component its source renders, so
build "actionable warning banner"returnedlogin,contact-formanddocumentation-designat 95 apiece — an exact keyword hit on "banner" alone, plus the coverage garnish, landing exactly on the direct-match threshold. Three pages that are not warnings, presented as the page to start from. Coverage now gates the pages group rather than garnishing its score.
Score alone could not carry the gate, soscoreQuerynow reports the coverage it already computed:matchedTerms/queryTermson every search result, with a whole-phrase hit reporting full coverage. A single strong hit and a broad weak one land on the same score, so a caller cannot tell "one of three concepts" from "three of three" without it.Rebased onto current
main, which landed integration search (#5259) and the scorer-level false-direct-match fix (#5614) while this was open. Both aremain's implementations, untouched here — this branch no longer rewritesgatherComponents, so the two regressions that rewrite caused are gone with it: an integration result reports its own package again, and a broken config no longer turns a Corebuttonsearch into an empty success.The other two pieces this branch used to carry now ship separately, as asked: the guidance-tier indexing in #5937 and the thin-kit hint in #5938.
-
A thin
buildkit now says what to try instead of looking empty.
build "quantum flux capacitor telemetry"returned one incidental component and the always-on frame list, and said nothing else. An agent reading that does not conclude its wording was wrong — it concludes the package has nothing and falls back on its own memory of what Astryx contains, which is the failurebuildexists to prevent.Below three offerable results the kit carries a
hintnaming the two commands that browse rather than search, and saying plainly that this is keyword matching, not semantic. The threshold counts what SURVIVED the score floors, not what search returned:hasResultsis already true for a query that matched things and then filtered them all out, and that is the case most likely to be misread.hintis structured —{reason, commands}with bare subcommands — not a sentence with commands baked into it. The API cannot know how a project invokes the CLI, and a hardcodedastryx component --listdoes not resolve in a pnpm workspace, where every other command in this output renders aspnpm exec astryx. The renderer formats them throughformatCliCommand, so they are runnable as printed, and a JSON caller gets the parts rather than prose to re-parse.hintis present only when it applies, so a healthy kit is byte-identical to before. The CLI renders it last, as aFEW MATCHESsection listed in the legend's section order, so it is the line the reader leaves with.Split out of #5320 at review request. Public response doc (
build.doc.mjs) and theBuildKitResponsetype both updated. -
The shared CLI blog adapter (
blog.list,blog.detail) cleared its 15-second abort timer as soon asfetchreturned response headers, leaving the later body read unbounded in time, and buffered the entire response before checking the 5 MB size limit, so the limit didn't actually cap how much was read into memory. (#5286)
The abort timer now stays active through body consumption. The body is read as a stream where available, checking decoded size after each chunk and aborting the read as soon as it exceeds the limit, instead of buffering the full response first. -
CLI: recorded runs no longer carry a raw agent session id, and the environment snapshot is scrubbed like every other value. (#6051)
ADebugEventclaimedredacted: truewhileenvhad never been through the scrubbing pass, and it stored the rawagentSessionIdbeside its hash. A session id follows one person across every run they make, and a handler may forward these records anywhere — so the record shipped a stable identifier, and an agent name pasted in from the environment went out verbatim, under a flag that said neither had.The contract is now explicit on
DebugEventEnv: no identity, attribution only from positive evidence, and free text scrubbed.env.agentSessionIdis always null — join runs onenv.agentSessionIdHash, which is what the raw value was for. Everything the CLI derives itself (platform, CI provider, locale, the hash) is still recorded verbatim, because a scrubbed snapshot is not worth keeping.redactedis set only on the sealed copy, after every pass has actually run.DebugSchemaVersionwidens to1 | 2and the CLI emits2, so code that switches on it is forced to handle both rather than silently reading a field that no longer means what it did.parseDebugEventis version-aware to match: a v1 record may carry the raw id, a v2 record may not and is rejected if it does.Not a breaking change:
debugand the wholeDebugEventsurface are unreleased — they land in this same release — so no published consumer ever saw the raw identifier. -
A manifest key this CLI does not know no longer discards the whole integration.
astryx.integration.*was parsed with a strict schema, so one unrecognized field failed the parse — and an integration whose manifest fails to parse contributes nothing, taking its components, templates and codemods down with it. Since an integration is published once and installed against many CLI versions, a field added by a newer CLI reached every older consumer as total, silent loss of that package (#5119). Unknown fields are now ignored with anunknown_manifest_keywarning naming them, and the rest of the manifest still applies; a known field of the wrong type is still an error. (#5311 follow-up) -
CLI: a stray lockfile no longer overrides the
packageManageryour project declares. (#6051)
Oneyarn installinside a pnpm project leaves ayarn.lockbehind forever. A single lockfile used to outrank thepackageManagerfield, so the CLI answered "yarn" for a project that says pnpm — and printedyarn astryx …in every command it suggested, including the invocation line written into agent docs, where agents copy it.astryx doctorcalled that setup healthy.The declared
packageManagerfield now decides, whatever lockfiles sit beside it. The documented fallbacks are unchanged: with nothing declared, a single lockfile still answers, a committedpnpm-workspace.yaml/.yarnrc.yml/bunfig.tomlstill breaks a multi-lockfile tie, an unbroken tie still resolves to the neutralnpxform with a doctor FAIL, and the runner is still consulted only when the whole walk found nothing.astryx doctornow WARNs when a lockfile contradicts the declaration, names the file, and says what to delete — instead of reporting the project as fine. -
astryx search(andastryx build, which shares its ranking) never surfaced a component whose exact multi-word keyword phrase was searched, if enough other unrelated candidates happened to each contain one of the query's individual words. Searching"table of contents"returned no results forOutline, even thoughOutline.doc.mjsdeclares'table of contents'verbatim as a keyword, becauseTable-related templates each matchedtableandcontentsseparately and their combined per-word score outranked Outline's single exact match.
A query that exactly matches a candidate's declared keyword (or name) verbatim is now promoted to a top-tier score, so it always outranks a candidate that only coincidentally contains several of the query's individual words. Single-word queries and queries that don't exactly match a keyword are unaffected.Follow-ups #6001 and #5994 preserve the coverage counts needed by build ranking while keeping them out of the public search result shape.
-
CLI:
searchandbuildnow report how many results MATCHED, not how many were returned. (#6051)
matchCounton abuild.kitenvelope, andoutput.resultCounton a recorded run, were both the length of the list after--limithad cut it. A query matching two hundred things and one matching exactly twenty filed the same number, so nothing downstream could tell a capped answer from a complete one — and a thin kit read as "the package has nothing" when it was really "the cap hid the rest".search --jsonnow carriesmatchCountalongsideresults, and the text view saysResults for "x" (2 of 57)when the list was cut short. The payloads themselves are unchanged:resultsis still bounded by--limit, and the kit still surfaces at most 3 pages, 5 blocks, and 6 components. -
Rename five dashboard page template catalog slugs to reusable pattern names, per the naming convention in Contributing Templates:
dashboard-data→dashboard-comparison,dashboard-executive-summary→dashboard-scorecard,dashboard-portfolio→dashboard-composition,dashboard-project-status→dashboard-progress, anddashboard-service-monitoring→dashboard-alert-rail. Each old slug named the data or task rather than the reusable pattern, so it under-served neighbouring requests: the composition-over-time shape is not specific to portfolios, and the alert-rail shape is not specific to service monitoring. The old slugs no longer resolve because catalog lookup is exact-match; use the new current-catalog values above. Thetemplatecommand and machine-readable schema are unchanged. (#5927)
Two categories move with their slugs:dashboard-comparisontakesDashboard - Comparison(it previously sharedDashboard - Analyticsverbatim with thedashboardtemplate, so neither owned the keyword) anddashboard-scorecardtakesDashboard - Scorecard. Both values are added to theTemplateCategoryunion; the superseded values stay reserved. Domain vocabulary — portfolio, holdings, monitoring, uptime, executive summary — is untouched in eachdescription, which is where retrieval actually reads it from.Also fixes a typo in the scorecard template's
namefield ("Executive Summary Dashoard"). -
Deduplicate parent-owned theming targets in CLI discovery while preserving each child component's direct documentation. (#5767)
-
Preserve anatomy when loading localized component docs directly (#5761)
The validated component-doc loader now applies the same full-overlay fallback as the CLI loader, so omitted localized anatomy inherits the canonical structure while explicit localized anatomy still wins. -
Package-manager detection: don't let a stray lockfile decide (#5301)
detectPackageManagerchecked lockfiles in a fixed order and returned the first hit, so a directory holding more than one lockfile was resolved by array position.yarn.lockis first in that array, which means a singleyarn installinside a pnpm project silently switches the CLI's answer to yarn — permanently, because the stray lockfile stays on disk.Every command the CLI prints is then wrong, including the invocation line written into the agent-docs block, which agents copy verbatim into their own runs.
An explicit
packageManagerdeclaration is authoritative even when the project also contains one or several lockfiles. Without a declaration, a single lockfile remains decisive. When several lockfiles sit in one directory, the tie is broken only from other evidence the project owns: a committed package-manager config file (pnpm-workspace.yaml,.yarnrc.yml,bunfig.toml). A strayinstalldrops a lockfile; it writes none of those.The runner (
npm_config_user_agent) deliberately does NOT break that tie. An agent handed the wrongyarn astryxline runs the CLI through yarn, so the runner agrees with the mistake and regenerating agent docs writes the wrong line again — the failure reproduces itself. The same holds for an installed binary invoked from that shell. Both now have regressions that start from the wrong line.When nothing project-owned decides it, the CLI does not guess.
detectPackageManagerreturns the neutralnpx, which is correct under every package manager, and the newexplainPackageManagerreportsambiguouswith the tied candidates.astryx doctorturns that into a FAIL naming the directory and the fix — add apackageManagerfield, or delete the lockfile that does not belong. It is the refusalfindConfigPathalready makes for coexisting config files. -
LayoutPanel: add playground wrapper and default children for docsite preview (#5919)
Prevents the properties-tab preview on the docsite from rendering an empty stage by wrapping LayoutPanel inside a Layout scaffold with representative panel content in start slot. -
Preserve canonical anatomy in localized component docs (#5753)
Localized component docs now inherit canonical anatomy when they omit it, while explicit localized anatomy still takes precedence. -
Make the table-filter page template usable on narrow and touch surfaces: View options, the detail panel, the saved-view dialogs and the filter overflow adapt to bottom sheets, the filter and saved-view rows hold one line, and column freezing is dropped where there is too little width to scroll the rest. (#5829)
-
Correct Neutral Banner interaction tints so light mode uses translucent light overlays and dark mode uses translucent dark overlays. (#5936)
-
Use palette-backed red interaction overlays for Neutral destructive
buttons, solid dark-palette tone-25 backgrounds (tone 20 for gray), and calmer dark-mode text colors. Use a palette-backed muted blue tint for dark info banners while preserving the existing light-mode non-semantic color mappings. (#6049) -
Give Neutral segmented controls a roomier inset while preserving their outside height. (#5851)
-
Rename built-in syntax theme identifiers. (#5847)
-
Remap Neutral's semantic, syntax, and categorical color tokens to the
reviewed theme-owned palette through named stop references. Keep the maintained CLI template synchronized. (#6034) -
Keep query-coverage metadata internal to build ranking while preserving it for promoted exact-phrase matches. (#5994)
-
searchindexes a component's usage guidance, one tier below its description. (#5937)
A component's best practices are where the reader's vocabulary lives.Bannerdescribes itself as "a persistent message"; only its guidance says "caution", "problems", "form errors". None of those words found it, because guidance was never read — 97 core components ship guidance, and all of it was invisible to search.Measured on the real registry, before and after:
caution,problems,sourcesandattentioneach now return the component whose guidance defines them, and each returned nothing relevant before.Guidance scores 45, below description's 50, so a component that IS the answer still outranks one whose advice merely mentions the term — the ordering that put
ToastbehindCard,DialogandItemon "notification".It sits deliberately BELOW
MIN_TOKEN_SCORE, so it never counts as a matched concept in a multi-word query. That is not a detail: letting it count was measured movingnested menufrom SideNav to List, andexplain why a field is requiredfrom Field to TextInput — a component whose guidance happens to mention the other word displacing the one that is the answer. Breadth is not relevance, the same reasonweakKeywordsare capped. With the floor left at 50, a 28-query sweep shows zero top-result changes and zero regressions, while the single-word gains above are kept. -
Table inbox replies now preserve an unsent body only for the same conversation, preventing text from following changed recipients. (#5934)
Documentation
- The namespaced-icon rationale and the add-a-semantic-icon intro in the icons guide, and the
SideNavItemactionsprop description, now use a comma and a colon in place of prose em dashes. Meaning unchanged. (#5647) - The description prose of 12 page templates now uses parentheses, commas, and colons in place of em dashes. These strings feed the CLI template list and the doc site, so plain punctuation reads better there. Meaning unchanged. (#5679)
Other Changes
-
Core's postinstall no longer hand-mirrors the setup contract.
packages/core/scripts/agent-doc-state.mjsis now GENERATED byte-for-byte from the CLI's dependency-free leafpackages/cli/foundation/agent-docs/agent-doc-state.mjs, andpnpm check:setup-contract— wired intocheck:repo— fails the build when the two differ. (#4162)
The previous guard compared two hand-edited constant lists. That caught a new agent-doc path or a new marker, and nothing else: the predicate itself, and theshouldNudgedecision matrix duplicated in both postinstall scripts, could still drift and leave layer 1 and layer 2 disagreeing about "is this project set up?" with the test green.shouldNudgeand the nudge string move into the contract as well, so all four things — paths, markers, predicate, decision — now have one definition and one place to edit.Behavior is unchanged, and verified rather than assumed: the nudge text is byte-identical, legacy
<!-- XDS:START -->blocks still count as set up, all six agent-doc locations are still detected, and both scripts still exit 0 on every path including failure. Core loads its copy with a dynamic import, so a packaging mistake degrades to "no nudge" instead of throwing out of module evaluation and failing a consumer's install.check:setup-contractalso fails if core stops listing the generated file infiles, so it cannot go missing in the first place.
@astryxdesign/build
Fixes
withAstryx()now resolves an app's own@astryxdesign/*imports to the packages'sourceentries. The scoped webpack rule only governs requests issued from insidenode_modules, so app code resolved the library throughdefaulttodistwhile PostCSS compiled it from source — the two emit disjoint class names and the app rendered unstyled without erroring. (#5932)
@astryxdesign/theme-butter
Fixes
- Rename built-in syntax theme identifiers. (#5847)
@astryxdesign/theme-chocolate
Fixes
- Rename built-in syntax theme identifiers. (#5847)
@astryxdesign/theme-gothic
Fixes
- Rename built-in syntax theme identifiers. (#5847)
@astryxdesign/theme-matcha
Fixes
- Rename built-in syntax theme identifiers. (#5847)
@astryxdesign/theme-neutral
New Components
- Reuse Neutral-owned local tokens for semantic status fills across badges, status dots, step indicators, and progress bars. (#5854)
- Add Neutral's reproducible, theme-owned OKLCH palette without changing
its runtime token mappings. The request, receipt, generated result, and CLI template artifacts are committed together for review. (#5987)
New Features
- Mute the low-tone edge of Neutral's dark chromatic palette while preserving its light and neutral ramps. (#6069)
Fixes
- Correct Neutral Banner interaction tints so light mode uses translucent light overlays and dark mode uses translucent dark overlays. (#5936)
- Use palette-backed red interaction overlays for Neutral destructive
buttons, solid dark-palette tone-25 backgrounds (tone 20 for gray), and calmer dark-mode text colors. Use a palette-backed muted blue tint for dark info banners while preserving the existing light-mode non-semantic color mappings. (#6049) - Give Neutral segmented controls a roomier inset while preserving their outside height. (#5851)
- Rename built-in syntax theme identifiers. (#5847)
- Remap Neutral's semantic, syntax, and categorical color tokens to the
reviewed theme-owned palette through named stop references. Keep the maintained CLI template synchronized. (#6034)
@astryxdesign/theme-stone
Fixes
- Rename built-in syntax theme identifiers. (#5847)
@astryxdesign/theme-y2k
Fixes
- Rename built-in syntax theme identifiers. (#5847)
Contributors
Thanks to everyone who contributed to this release:
@cixzhang @ernestt @freddymeta @Geervan @harjothkhara @HelloOjasMutreja @imdreamrunner @jiunshinn @josephfarina @kentonquatman @Kyujenius @Lee-Dongwook @ManoharPaturi @mattandryc @nynexman4464 @PRIEYAN @rubyycheung @trakshan-mishra @yyq1025
Full Changelog: v0.5.2...v0.5.3