Skip to content

feat(core): createReorderable's positions() and attach() for grids, fans and plain elements (spec:AST-103) - #8719

Draft
vjeux wants to merge 1978 commits into
meta:mainfrom
vjeux:uikit/c13-reorderable-attach
Draft

vjeux wants to merge 1978 commits into
meta:mainfrom
vjeux:uikit/c13-reorderable-attach

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8683. Review only this PR's change: vjeux/astryx@af760b2...f70367c

Why

Card games, kanban boards and photo grids need a List's reordering (drag, keyboard, screen reader), but they're usually plain elements, rarely one column, and createReorderable only works with List.

What led to it

This is part of the UIKit-parity program, which makes Astryx's components behave like their iOS counterparts on touch screens. #8601 (merged) added two capabilities to spec:AST-103, and this PR implements them:

  • positions(): the app gives each item's slot, such as points on a fan's arc. A drag follows the pointer both ways and drops the item on the nearest slot.
  • attach(container, {items}): plain elements get List's reordering. Grips become reorder-mode buttons, moves are announced, and focus stays on the moved item through re-renders. detach() removes everything it added.
const {detach} = createReorderable({onReorder, positions}).attach(table, {
  items: () => cards.map(c => ({key: c.id, element: c.el, handle: c.grip})),
});

Options and tradeoffs

  • The drop marker in a grid or fan is open for the owner (OQ2): List's accent line turned between the target slot and the next one, or an outline on the target item. The outline is simpler, but it's a new visual.
  • The hint's renderer, react-dom/client (56,867 B gzip), can ship with the import or load when attach() runs. Loading late means a mode started in the first moment shows its hint a little later; the keyboard works meanwhile.
  • The hint has its own React root, so it takes the container's theme but not your providers.

Why this choice

The turned line keeps List's look. Loading the renderer late keeps the import small: createReorderable grows from 76,489 B to 81,242 B, and List doesn't change. One PR, since #8601 specifies both and attach() reuses the slot model.

How to check it

In Storybook Core/createReorderable:

  1. In A Row: drag card A's grip onto the third card. It settles there, and the others shift.
  2. Tab to a grip, press Enter, then use the arrow keys. The card moves one slot each time, and a screen reader reads its new place. Escape ends the mode.
  3. Wrapping: in reorder mode, Down moves a card to the same column in the next row.
  4. In A Dialog: move a card with the keyboard. The move is announced inside the dialog.

I ran the unit tests and the browser tests (Chromium and WebKit) for each step with a mouse, a finger and a pen, and all pass. Two reds come from elsewhere: touch-support-bundle, which #8671 fixes, and pr-rtl, main's newer RTL test, which this branch predates.

Evidence

Browser tests and the stories.

Stack (merge in this order): … → #8140 → #8683 → this.

vjeux and others added 30 commits October 26, 2026 20:35
…g) into meta#8293

# Conflicts:
#	packages/core/src/motion/animator.ts
The note's reword joined its next sentence onto one long line; it now
wraps like the rest of the file. No code change.
Rows move by transform (an update's slide, a swipe, edit mode's slide), and
the shared touch hit area measures them as they stand in one frame: during a
removal it re-measured a frame into the motion and wrote fractional insets,
a layout and a paint in the middle of a compositor-only motion. A
measurement that arrives while the row's list has a finite animation running
or held is now dropped, and once those animations end the row is watched
afresh, so its insets are taken at rest.
…h the pressable-root line offered there) into the touch hit area change
cixzhang's meta#8589 keeps the host's touchend passive, and its contextmenu
and click listeners off, until a claimer is attached
(syncClaimingListeners). Ported onto this pull request's input stream with
no change to what it does: install() subscribes the touchend passive, and
syncClaimingListeners swaps it for a cancelling subscription and adds the
contextmenu and click subscriptions while a claimer is attached. A group
press brings its own click subscription only while the host has none, and
the host's takes it over. Her watcher test reads those listeners on the
window, where the input stream registers them; her new group test (a
finger, then a mouse, under a fine pointer) sits before the detach test.
The test file and the bare fixture import both her
ScreenEdgePanGestureRecognizer and this layer's selection lock from utils.
Here the click guard (utils/clickGuard.ts) owns clicks, so cixzhang's
meta#8589 syncClaimingListeners, ported at meta#8356, switches only the touchend's
cancelability and the contextmenu subscription; the group press subscribes
no click of its own. Her meta#8072 5c59af1 (a finger on an action group
under a fine pointer reaches its recognizers) arms the click guard where it
armed the swallower. Her watcher test reads the touchend and the
contextmenu on the window, and no click listener: the guard's comes only
once a gesture acts.
… the lift, under real touch input

spec:AST-094 FR20's "follows the scroll to the lift" had only a jsdom
test with a synthetic takeover; the FR18-FR22 verification row asks for
real touch input. In Chromium with injected touches, a vertical drag on a
list a watcher pan is attached to scrolls the list natively, no touchmove
is cancelled, and the watcher goes from began to ended at the lift.
… at the edge stays the page's

spec:AST-094 FR10: two fingers before a claim are the page's pinch. The
scroll-edge hold (DEC-18) counted any finger, so with two fingers down it
cancelled the pinch's moves before any drag was claimed. It now holds only
while a single finger is down. The new two-finger test fails on the
previous head.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…s watcher browser test) into the arena fixes
…d a test can reset it

- The press-start check put a pointerdown listener on every watched
  element, so a reorder handle carried one (spec:AST-103 FR5 keeps a list's
  listeners the list's). One shared capture listener on the document, on
  only while the pointer is coarse, now finds the innermost watched element
  a press lands in and checks it.
- The pointer query is read at every watch, not only the first, so an
  environment that replaces matchMedia (a test) never inherits a stale
  coarse answer from a watch that was not stopped; resetHitAreaForTests
  stops and forgets every watch.
- watchHitArea takes its minimum size in an options object.

The new tests (no element listener for two watched controls, one document
listener; a fresh fine answer after a watch left behind; a clean reset)
fail on the previous head.
… row menu

Cindy's rule for rows that both reorder and have a row menu (her 03:55Z
comment): a hold on the reorder handle picks the row up, and a hold anywhere
else on the row opens its menu. A still finger held on a handle opened the
menu around the list: ContextMenu's long press starts from the touchstart
that bubbled up from the handle, and Chromium also sent it the finger's
context request. The handle now keeps both to itself: its touchstart does
not reach a menu around the row, and a touch's context request on it is
refused (a mouse's right click still reaches the menu).

Failing-first: a jsdom test (a hold on the handle past the menu's long
press, a touch context request on it, then a hold on the label), and a
real-touch proof on the Reorder From Your UI story (a hold on the handle
picks the row up with no menu; a hold on the label opens the menu and picks
nothing up).
…ture listeners

The handle stops its touchstart in React's bubble phase so a row menu
around it never starts its long press from the handle. The gesture host
and its recognizers listen in the capture phase (on the document and on
armed elements), so they still see every touch that starts on the handle:
a capture-phase document listener and the host's finger count prove it.
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Thanks, this covers most of spec:AST-103 FR4b. Review findings, most user impact first:

  1. A rejected item still shows its grip (FR1/DEC-2: "a row it rejects shows no handle"). wire() skips rejected and keyless items but leaves the caller's handle visible and inert, so a sighted user sees a grip that does nothing. Please set hidden on it (restored on detach) and update the test that pins inert-but-visible.
  2. Announcements lose a rejected row's name. Rejected and keyless items never register their names, so "Can't move past Pinned" reads "Can't move past item 1". Please register their names in wire().
  3. Per-handle listeners (FR5 "never per row"; spec:AST-094 FR24). Each handle gets its own click, keydown and focusout. Please delegate them from the container, resolving the handle from event.target.
  4. A second live region (FR4: "through the shared polite announcement channel"). attach creates its own role=status. A page that also uses useAnnounce gets two regions talking over each other, without its same-frame joining and repeat dedupe. Please call the shared announcer, exported internally; it already handles modal dialogs.
  5. The hint renders in a separate React root. It loses the app's providers and any theme scoped to the container, and pulls react-dom/client into every bundle that imports createReorderable. Please add a size line to the body, and either render the hint inside the container's theme scope or document it as a known limit.
  6. The pointer kind is read once at attach. A docked tablet or a folded convertible leaves touch-action and the hold stale. Please follow primary-pointer changes with a matchMedia listener.
  7. The commit observer watches childList only. Items nested under a wrapper that the app moves inside it don't wake it. Please use subtree: true, or document that items must be direct children.
  8. Suggestion: one controller method for listener wiring, shared by List's host and attach, so the next pointer rule changes in one place.

English strings outside a provider match the record (internationalization INV3), so no change is needed there. If this stays idle, we'll make these changes after about 30 minutes, keeping your authorship.

@vjeux

vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks. I'm on all eight now, each failing first:

  1. A rejected or keyless item's grip is hidden, restored on detach.
  2. Their names are registered, so the refusal names them.
  3. click, keydown and focusout are delegated from the container.
  4. The shared announcer replaces attach's own region.
  5. The hint's host sits inside the container's theme scope; the lost providers are documented, and a size line is added.
  6. A matchMedia listener follows primary-pointer changes.
  7. The commit observer uses subtree: true.
  8. One controller method wires the pointers for both List and attach.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval is on hold until an owner settles the slot-layout insertion cue. That cue is the accent line turned between the target slot and the next one, and it answers design:ordered-collection-reordering OQ2, which is still open. An engineering owner needs to accept it or choose another cue, and record the decision by commenting /approve-spec <full head SHA> here; the author may do so as an engineering owner. Separately, the diff against main carries a chain of at least 22 open stacked pull requests (266 files, 50,761 authored lines): please land them first and rebase, then split positions() from attach() or explain why the remaining 3,128 lines must land together.

  • In a wrapping grid, the turned line lands between the wrong cards for a drop at a row's end, and it is drawn outside the container after a key move that wraps rows (slotLine and stepLine in reorderModel.ts).
  • Resolve the enclosing modal when attach() announces, not once at attach time, so a container attached before its <dialog> opens still announces inside it (spec:AST-103 FR4b), and add a browser test for that case.
  • Name each handle from the item's accessible name: aria-labelledby before aria-label, then title, then text.
  • Defer the hint root's root.unmount() in detach(), because React logs an error when it runs synchronously inside an effect cleanup, as in the new story.

[Automated review]

* lands in the order; past the last slot, half a step beyond it. None at the
* item's own place. (Proposed for design:ordered-collection-reordering OQ2.)
*/
export function slotLine(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a wrapping grid's row boundary, the step from the target slot to the next slot is a diagonal, so this line lands between the wrong cards. With four 120 x 80 px cards to a row and 16 px gaps, a drop after the row's last card draws a 105 px line tilted 77 degrees in the gap between the 2nd and 3rd cards, where a row would draw "between B and C". stepLine below has the same cause: after a Right move that wraps rows, reorder mode's line is centred 144 px outside the container. Whichever cue OQ2 settles on, marking the target slot's own edge on its row at a row boundary, plus a browser case for it, would keep the cue on the candidate position (design DR3, spec:AST-103 FR4).

);
// Inside the modal dialog that encloses the container, where assistive
// technology still reads (spec:AST-086 FR14); else the document's body.
const host =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The announcement host is resolved once, here, at attach time. A container attached before its <dialog> is opened with showModal() keeps its live region, and the hint root added at line 393, in document.body. The modal makes the body inert, so moves are announced only through the page-body channel, which is the failure spec:AST-103 FR4b's verification names ("an announcement inside a page-opened modal that reaches only the page-body channel"). Resolving the enclosing modal when announcing and when reorder mode starts would fix it, together with a browser test of a dialog opened after attach.

let attachedCount = 0;

/** What an attached item is called: its accessible name, else its text. */
function nameOf(element: HTMLElement): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nameOf reads aria-label before aria-labelledby and never reads title. The accessible-name computation puts aria-labelledby first and falls back to title, and FR4b names the handle from "the item element's accessible name, else its text content". As written, an image card named only by title gets a handle named "Reorder" alone, and its moves are announced by position. Suggested order: aria-labelledby, aria-label, title, then text.

if (movedTimer != null) {
clearTimeout(movedTimer);
}
root.unmount();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detach() is usually returned from a React effect, as in the new story (ReorderableAttach.stories.tsx:161–177). React DOM 19 runs effect cleanups inside its commit, and a synchronous root.unmount() there logs "Attempted to synchronously unmount a root while React was already rendering" in development, on every unmount and on Strict Mode's double-invoke. Deferring the unmount (for example, queueMicrotask(() => root.unmount()) before removing hintHost), or rendering the hint without a root of its own, avoids it.

…shared announcer, pointer changes

The owner's review of attach (spec:AST-103 FR1, FR4, FR4b, FR5; spec:AST-094
FR24, FR43; spec:AST-086 FR14):

1. A rejected or keyless item's handle is hidden (a handle that is the item
   itself stays), restored on detach.
2. Their names are registered, so a refusal past one names it ("Can't move
   past Pinned", not "item 1").
3. click, keydown and focusout are delegated from the container, resolving
   the handle from the event's target; no listener on any handle.
4. Announcements go through the shared channel (announceMessage), or
   announceInModal inside the open modal <dialog> enclosing the container,
   resolved at each announcement; attach owns no live region.
5. The hint's host sits right after the container, in its theme scope, with
   the providers it cannot reach documented; react-dom/client loads when
   attach runs, not with the import.
6. watchCoarsePointer follows the primary pointer turning coarse or fine:
   the session's pointer listeners, each handle's touch-action, the motion.
7. The commit observer watches the container's subtree, and an item under a
   wrapper resolves to itself, not to the wrapper (rowOf; layout offsets
   through positioned wrappers).
8. One controller method, attachPointers(isCoarse), wires the pointers for
   List's host and attach.

The automated review's four:
- In a wrapping grid the slot-layout cue at a row's end, and after a key
  move that wraps rows, stays on the row (slotLine and stepLine treat a step
  that wraps rows as no neighbour).
- A container attached before its dialog opens announces inside the open
  dialog (browser test, Attached Cards In A Dialog).
- Handle names follow the accessible-name order: aria-labelledby, then
  aria-label, then title, then text.
- The hint root unmounts after the current render, so a detach inside a
  React effect cleanup draws no error from React.

Failing-first: each case above failed before its fix (jsdom: hidden grips,
the rejected name, listeners on handles, the pointer change, the hint's
place, a second live region, a move among wrapped items refused, the name
order, React's unmount error; model: the row-end and wrapping cues;
Chromium: the dialog opened after attach).
@vjeux

vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 2e43f0f with all eight, each failing first:

  1. A rejected or keyless item's grip is hidden, restored on detach. A handle that is the item itself stays visible.
  2. Their names are registered, so "Can't move past Pinned" names the item.
  3. click, keydown and focusout are delegated from the container.
  4. Announcements go through announceMessage, or through announceInModal inside an open modal <dialog>, resolved per announcement. Attach owns no live region.
  5. The hint's host sits right after the container, in its theme scope. The lost providers are documented, and react-dom/client now loads when attach runs, not with the import. The size line is in the body.
  6. watchCoarsePointer follows the primary pointer: listeners, touch-action and motion.
  7. The observer uses subtree: true, and an item under a wrapper resolves to itself, not to the wrapper.
  8. attachPointers(isCoarse) wires the pointers for both List's host and attach.

The bot's four are in too:

  • At a row's end, and after a key move that wraps rows, the cue stays on the row.
  • A container attached before its dialog opens announces inside the dialog, with a browser test.
  • Names follow aria-labelledby, then aria-label, title and text.
  • The hint root unmounts after the current render.

One decision is yours: the slot-layout insertion cue (design:ordered-collection-reordering OQ2). I propose the list's accent line, turned to sit between the target slot and the next one along the drag. The alternative is no line, with the target item outlined. Could you accept it with /approve-spec at the head, or name the cue you want?

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Thanks for fixing all eight. Loading the hint on demand is fine: attach isn't a component, and the hint isn't a touch module. Two small asks to go with it:

  1. A size line in the PR body: the static cost of createReorderable and the size of the on-demand hint chunk.
  2. A test that the keyboard path doesn't wait for the hint. Hold the react-dom/client chunk unresolved, for example with a deferred mock. While the hint host is empty, Enter enters the mode (aria-pressed=true), ArrowDown calls onReorder and announces the place, and Escape ends the mode and announces it. Then resolve the chunk and check the hint mounts for a mode that's still on, and that detach before it resolves renders nothing.

…rer, and the hint renders in place

The owner asked for proof that attach's keyboard path never waits for the
on-demand hint renderer. A test of its own file holds `react-dom/client`
unloaded (a deferred mock): while the hint's host is empty, Enter enters
reorder mode (aria-pressed true), ArrowDown calls onReorder and announces
the place, Escape ends the mode and announces it; once the renderer loads,
the hint mounts for a mode still on, and a container detached before then
renders nothing.

Writing it found the hint's layer moving out of its host: the host and its
root were spans, phrasing content a Layer leaves for a safe host
(Layer/layerHost.ts), so the hint rendered at the end of the body, outside
the container's theme scope. They are divs with display: contents now, and
the hint renders in place, right after the container.

Failing-first: with span hosts, the hint's buttons never appeared in its
host.
@vjeux

vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Both are in f70367c:

  1. The size line is in the body. createReorderable costs 81,242 B gzip statically (from 76,489), and the on-demand chunk (react-dom/client with scheduler) is 56,867 B.
  2. attachHintChunk.test.ts holds react-dom/client unloaded with a deferred mock. While the hint's host is empty, Enter, ArrowDown and Escape run reorder mode with their announcements. Once the renderer loads, the hint mounts for a mode that's still on, and a container detached first renders nothing.

Writing the test also found the hint's layer leaving its host for the end of the body, because the host was made of spans. The host is divs now, so the hint renders in place, in the container's theme scope.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval is on hold until an owner settles the slot-layout insertion cue: the accent line turned between the target slot and the next one, which answers the still-open design:ordered-collection-reordering OQ2. An engineering owner can accept that cue or choose another, and records the decision by commenting /approve-spec <full head SHA> here; the author may do this, as an engineering owner. Separately, the diff against main still carries four open stacked pull requests (267 files, 51,451 authored lines). Please land those first and rebase, then split positions() from attach() or explain why the remaining 3,848 lines must land together.

  • In a positions() layout, or a positioned container, that has been scrolled sideways, begin() and track() measure the dragged item on screen but compare it with slot centres in layout coordinates, so the drag proposes the wrong slot; a browser case that scrolls before dragging would pin the fix.
  • In right-to-left, slotLine's fallback for an item alone on its row draws the line on the wrong side; passing the direction, as gridOf already takes it, would fix this, with a model test.

[Automated review]

const slots = this.slotsFor(list, rows);
const listBox = list.getBoundingClientRect();
const originX = listBox.left + list.clientLeft;
const left = row.getBoundingClientRect().left - originX;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a container that scrolls sideways, left is the item's on-screen offset, so it already has the container's scrollLeft taken off. track() then compares session.left + shiftX + width / 2 (line 1430) with slot centres laid out from the unscrolled padding box: positions(), or layoutLeft in slotsFor. After the strip scrolls two card steps, a card held over the fifth slot proposes the third, and the line is drawn there, against FR1's nearest-centre rule. Taking left from layoutLeft(row, list), as lefts does at line 1379, while keeping grabX from the on-screen box, or adding list.scrollLeft, would fix it. A browser case that scrolls the strip before the drag would pin the fix.

return lineAcross(at, {x: 2 * at.x - back.x, y: 2 * at.y - back.y}, size);
}
// Alone on its row and column: at its edge along the reading order.
const side = forward ? size.width : -size.width;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback puts the line on the item's right edge for every forward drop. In a right-to-left wrapping grid whose last row holds one item, a drop at the end lands after that item in reading order, but the line lands on its reading-start side. Passing isRtl, as gridOf and arrowTarget already take it, and mirroring side would keep the cue beyond the slot. A case in reorderModel.test.ts would pin it.

@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

Item (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 284B 284B 0B
Lines of Code 1372 1414 -
Complexity Very High Very High (128) -
TreeList (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 648B 648B 0B
Lines of Code 872 1056 -
Complexity Very High Very High (101) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 58.0KB +1.1KB 12.4KB 12.5KB +24B
core/AppShell 57.9KB 58.9KB +1022B 11.6KB 11.7KB +22B
core/AspectRatio 3.7KB 3.8KB +66B 3.5KB 3.5KB +40B
core/Avatar 37.5KB 37.8KB +363B 6.3KB 6.3KB +23B
core/AvatarGroup 39.8KB 40.1KB +359B 6.4KB 6.5KB +22B
core/AvatarGroupOverflow 39.9KB 41.0KB +1.1KB 6.4KB 6.4KB +23B
core/AvatarStatusDot 7.1KB 7.1KB +61B 3.4KB 3.4KB +41B
core/Badge 3.9KB 4.0KB +64B 3.8KB 3.9KB +39B
core/Banner 47.8KB 48.9KB +1.0KB 9.9KB 10.0KB +22B
core/BaseTypeahead 54.3KB 55.3KB +1.0KB 9.9KB 9.9KB +22B
core/Blockquote 3.5KB 3.6KB +66B 3.4KB 3.4KB +39B
core/BottomSheet 34.0KB 34.0KB +69B 7.9KB 7.9KB +24B
core/BottomSheetSwitcher 14.8KB 14.8KB +61B 3.3KB 3.3KB +41B
core/BreadcrumbItem 66.4KB 68.2KB +1.9KB 11.0KB 11.4KB +412B
core/Breadcrumbs 22.4KB 22.8KB +377B 3.3KB 3.3KB +40B
core/Button 43.3KB 44.3KB +1.1KB 9.2KB 9.2KB +21B
core/ButtonGroup 8.4KB 8.5KB +67B 3.3KB 3.3KB +40B
core/Calendar 54.9KB 56.0KB +1.1KB 10.0KB 10.0KB +22B
core/Card 6.4KB 6.5KB +66B 6.3KB 6.4KB +42B
core/Carousel 50.2KB 51.3KB +1.1KB 10.2KB 10.2KB +21B
core/Center 4.2KB 4.2KB +63B 4.6KB 4.6KB +42B
core/ChatComposer 60.6KB 61.7KB +1.1KB 10.8KB 10.8KB +19B
core/ChatComposerDrawer 23.9KB 24.3KB +393B 4.5KB 4.6KB +39B
core/ChatComposerInput 55.7KB 56.8KB +1.0KB 10.1KB 10.1KB +21B
core/ChatComposerTokenElement 55.7KB 56.8KB +1.0KB 10.1KB 10.1KB +21B
core/ChatDictationButton 46.5KB 47.5KB +1.0KB 9.5KB 9.5KB +20B
core/ChatLayout 48.8KB 49.9KB +1.1KB 9.9KB 10.0KB +20B
core/ChatLayoutScrollButton 46.4KB 47.4KB +1.0KB 9.6KB 9.6KB +20B
core/ChatMessage 23.1KB 23.4KB +365B 3.5KB 3.6KB +39B
core/ChatMessageBubble 7.0KB 7.1KB +67B 3.6KB 3.6KB +42B
core/ChatMessageList 28.9KB 29.3KB +394B 7.8KB 7.8KB +21B
core/ChatMessageMetadata 25.8KB 26.2KB +374B 3.9KB 3.9KB +23B
core/ChatSendButton 45.8KB 46.9KB +1.0KB 9.2KB 9.2KB +21B
core/ChatSystemMessage 6.7KB 6.8KB +71B 3.7KB 3.7KB +39B
core/ChatTokenizedText 6.9KB 7.0KB +63B 3.9KB 3.9KB +40B
core/ChatToolCalls 31.9KB 32.2KB +384B 8.9KB 8.9KB +22B
core/CheckIndicator 9.0KB 9.1KB +68B 3.6KB 3.6KB +39B
core/CheckboxIndicator 4.1KB 4.1KB +65B 3.8KB 3.8KB +22B
core/CheckboxInput 49.9KB 50.9KB +1.0KB 9.3KB 9.3KB +22B
core/CheckboxList 46.0KB 61.0KB +15.0KB ⚠️ 8.1KB 8.2KB +79B
core/CheckboxListItem 54.0KB 58.9KB +4.8KB ⚠️ 10.5KB 11.3KB +804B
core/Citation 25.9KB 26.3KB +395B 4.1KB 4.2KB +41B
core/ClickableCard 12.8KB 13.6KB +810B 6.9KB 7.0KB +46B
core/Code 3.5KB 3.6KB +63B 3.4KB 3.4KB +41B
core/CodeBlock 60.5KB 61.6KB +1.0KB 10.7KB 10.7KB +27B
core/Collapsible 11.7KB 12.5KB +771B 4.2KB 4.3KB +39B
core/CollapsibleGroup 6.4KB 6.4KB +71B 3.2KB 3.2KB +40B
core/CommandPalette 50.9KB 52.0KB +1.1KB 11.8KB 11.8KB +23B
core/CommandPaletteEmpty 6.1KB 6.2KB +64B 3.3KB 3.4KB +42B
core/CommandPaletteFooter 23.3KB 23.7KB +391B 3.5KB 3.6KB +39B
core/CommandPaletteGroup 3.5KB 3.5KB +64B 3.3KB 3.4KB +39B
core/CommandPaletteInput 32.3KB 32.7KB +387B 8.1KB 8.1KB +22B
core/CommandPaletteItem 8.4KB 9.2KB +764B 3.5KB 3.5KB +40B
core/CommandPaletteList 22.4KB 22.8KB +372B 3.3KB 3.3KB +41B
core/ComplexSelector 55.8KB 56.9KB +1.1KB 10.6KB 10.7KB +20B
core/ContextMenu 77.7KB 97.5KB +19.7KB ⚠️ 14.6KB 15.5KB +858B
core/ContextMenuItem 16.6KB 53.3KB +36.7KB ⚠️ 5.2KB 9.6KB +4.4KB ⚠️
core/DateInput 99.9KB 100.8KB +952B 14.8KB 14.8KB +20B
core/DateRangeInput 69.4KB 70.4KB +1.0KB 11.2KB 11.2KB +21B
core/DateTimeInput 110.1KB 111.2KB +1.1KB 15.2KB 15.2KB +20B
core/Dialog 16.2KB 16.3KB +64B 7.1KB 7.1KB +23B
core/DialogHeader 48.8KB 49.9KB +1.1KB 9.8KB 9.8KB +20B
core/Divider 6.5KB 6.5KB +64B 3.6KB 3.6KB +43B
core/Drawer 31.6KB 31.7KB +85B 8.0KB 8.1KB +23B
core/DrawerHeader 48.9KB 50.0KB +1.0KB 11.2KB 11.2KB +22B
core/DropdownMenu 72.7KB 74.5KB +1.9KB 14.7KB 15.6KB +855B
core/DropdownMenuCheckboxItem 16.1KB 17.5KB +1.4KB 5.6KB 6.0KB +404B
core/DropdownMenuDivider 6.6KB 6.6KB +61B 3.6KB 3.6KB +42B
core/DropdownMenuGroup 6.1KB 6.1KB +69B 3.3KB 3.3KB +39B
core/DropdownMenuItem 15.4KB 16.8KB +1.4KB 5.2KB 5.6KB +421B
core/DropdownMenuRadioGroup 6.0KB 6.1KB +62B 3.2KB 3.2KB +43B
core/DropdownMenuRadioItem 16.2KB 17.5KB +1.3KB 5.6KB 6.0KB +404B
core/DropdownMenuSubMenu 58.1KB 59.9KB +1.8KB 9.5KB 9.9KB +408B
core/EmptyState 7.7KB 7.8KB +64B 3.5KB 3.6KB +42B
core/Field 44.2KB 44.6KB +384B 8.0KB 8.0KB +22B
core/FieldLabel 39.8KB 40.1KB +371B 6.5KB 6.5KB +23B
core/FieldStatus 10.4KB 10.5KB +74B 4.2KB 4.3KB +23B
core/FileInput 54.1KB 55.1KB +1.0KB 10.3KB 10.4KB +23B
core/FormLayout 6.3KB 6.3KB +66B 3.4KB 3.4KB +40B
core/Grid 4.3KB 4.4KB +67B 3.7KB 3.7KB +40B
core/GridSpan 3.4KB 3.5KB +63B 3.2KB 3.3KB +40B
core/HStack 7.3KB 7.3KB +62B 4.9KB 4.9KB +44B
core/Heading 9.1KB 9.2KB +64B 6.8KB 6.8KB +23B
core/Icon 8.8KB 8.9KB +60B 3.6KB 3.6KB +39B
core/IconButton 43.3KB 44.4KB +1.1KB 9.2KB 9.2KB +21B
core/InputGroup 44.8KB 45.2KB +387B 8.0KB 8.1KB +21B
core/InputGroupText 6.3KB 6.4KB +66B 3.7KB 3.8KB +40B
core/Item 11.4KB 12.8KB +1.5KB 4.7KB 5.1KB +423B
core/Kbd 6.7KB 6.7KB +67B 3.5KB 3.5KB +40B
core/LayerProvider 63.3KB 64.4KB +1.1KB 10.1KB 10.1KB +23B
core/Layout 8.1KB 8.2KB +63B 5.5KB 5.5KB +43B
core/LayoutContent 7.4KB 7.5KB +64B 4.9KB 5.0KB +43B
core/LayoutFooter 7.3KB 7.4KB +68B 4.8KB 4.9KB +42B
core/LayoutHeader 7.4KB 7.4KB +65B 4.8KB 4.9KB +42B
core/LayoutPanel 7.6KB 7.6KB +65B 4.8KB 4.9KB +43B
core/Lightbox 51.2KB 52.2KB +1.0KB 9.8KB 9.8KB +21B
core/Link 43.5KB 44.5KB +1.0KB 7.6KB 7.6KB +25B
core/List 6.7KB 21.7KB +15.0KB ⚠️ 3.4KB 3.6KB +184B
core/ListItem 53.5KB 58.3KB +4.8KB ⚠️ 10.5KB 11.3KB +804B
core/Markdown 144.5KB 164.2KB +19.8KB ⚠️ 17.4KB 18.2KB +847B
core/MetadataList 23.1KB 23.5KB +376B 3.6KB 3.6KB +42B
core/MetadataListItem 6.5KB 6.5KB +64B 3.4KB 3.4KB +43B
core/MobileNav 51.0KB 52.0KB +1021B 10.1KB 10.1KB +22B
core/MobileNavToggle 46.2KB 47.2KB +1.0KB 9.4KB 9.4KB +20B
core/MoreMenu 72.9KB 74.8KB +1.9KB 14.7KB 15.6KB +855B
core/MultiSelector 76.2KB 77.2KB +1.0KB 14.6KB 14.7KB +24B
core/NavHeadingMenu 8.9KB 9.0KB +65B 3.2KB 3.3KB +42B
core/NavIcon 3.4KB 3.5KB +63B 3.3KB 3.3KB +42B
core/NumberInput 55.7KB 56.7KB +1.0KB 10.7KB 10.7KB +31B
core/Outline 28.6KB 29.7KB +1.1KB 4.0KB 4.1KB +40B
core/OverflowList 8.5KB 8.6KB +67B 3.5KB 3.5KB +40B
core/Overlay 19.5KB 19.6KB +43B 3.8KB 3.9KB +17B
core/Pagination 87.7KB 89.5KB +1.8KB 15.3KB 15.7KB +395B
core/Popover 49.8KB 50.8KB +990B 12.1KB 12.1KB +20B
core/PowerSearch 155.1KB 170.9KB +15.8KB ⚠️ 18.6KB 19.0KB +418B
core/ProgressBar 7.7KB 7.8KB +66B 6.6KB 6.6KB +20B
core/RadioIndicator 3.8KB 3.9KB +61B 3.7KB 3.7KB +20B
core/RadioList 45.6KB 45.9KB +379B 8.0KB 8.0KB +22B
core/RadioListItem 52.1KB 53.7KB +1.6KB 9.7KB 10.1KB +401B
core/ResizeHandle 25.1KB 25.5KB +382B 3.9KB 3.9KB +41B
core/Section 7.5KB 7.6KB +67B 6.2KB 6.2KB +42B
core/SegmentedControl 38.8KB 39.3KB +490B 6.3KB 6.4KB +22B
core/SegmentedControlItem 8.6KB 9.3KB +757B 3.9KB 3.9KB +41B
core/SelectableCard 12.8KB 13.6KB +808B 7.1KB 7.2KB +46B
core/Selector 79.1KB 81.0KB +1.9KB 15.0KB 15.4KB +403B
core/SelectorOption 14.5KB 16.0KB +1.5KB 5.1KB 5.5KB +421B
core/SideNav 58.0KB 59.0KB +1.0KB 10.6KB 10.6KB +21B
core/SideNavCollapseButton 46.6KB 47.7KB +1.0KB 9.5KB 9.5KB +20B
core/SideNavHeading 54.7KB 55.7KB +1.0KB 10.1KB 10.2KB +22B
core/SideNavItem 54.5KB 55.5KB +1.0KB 10.1KB 10.1KB +22B
core/SideNavSection 6.5KB 6.6KB +59B 3.6KB 3.6KB +38B
core/Skeleton 3.8KB 3.8KB +60B 3.6KB 3.6KB +22B
core/Slider 48.5KB 48.8KB +382B 8.5KB 8.5KB +21B
core/Spinner 26.3KB 26.7KB +383B 7.5KB 7.5KB +19B
core/Stack 7.1KB 7.1KB +62B 4.9KB 4.9KB +44B
core/StackItem 6.0KB 6.1KB +63B 3.3KB 3.3KB +38B
core/StatusDot 19.0KB 19.0KB +59B 6.2KB 6.2KB +19B
core/Step 32.4KB 33.5KB +1.1KB 5.3KB 5.3KB +22B
core/Stepper 47.4KB 48.4KB +1.0KB 9.6KB 9.6KB +20B
core/Switch 48.4KB 49.5KB +1.1KB 9.3KB 9.4KB +21B
core/Tab 9.7KB 10.5KB +780B 4.1KB 4.1KB +41B
core/TabList 40.2KB 40.6KB +453B 7.2KB 7.2KB +23B
core/TabMenu 51.6KB 52.6KB +1.0KB 10.0KB 10.0KB +21B
core/Table 84.0KB 103.8KB +19.8KB ⚠️ 15.1KB 16.0KB +843B
core/TableBody 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableCell 78.8KB 98.5KB +19.7KB ⚠️ 14.8KB 15.7KB +853B
core/TableFooter 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableHeader 3.3KB 3.3KB +63B 3.2KB 3.2KB +42B
core/TableHeaderCell 78.6KB 98.3KB +19.7KB ⚠️ 14.8KB 15.7KB +867B
core/TableRow 6.2KB 6.3KB +66B 3.5KB 3.6KB +40B
core/TableSelectionToolbar 52.4KB 53.5KB +1.1KB 11.2KB 11.2KB +21B
core/Text 8.6KB 8.6KB +66B 6.8KB 6.8KB +23B
core/TextArea 49.4KB 49.8KB +379B 9.4KB 9.4KB +21B
core/TextInput 53.5KB 54.5KB +1.0KB 10.5KB 10.5KB +22B
core/Thumbnail 50.7KB 51.7KB +1.1KB 11.0KB 11.0KB +25B
core/TimeInput 86.8KB 87.9KB +1.0KB 13.8KB 13.8KB +21B
core/Timer 9.2KB 9.3KB +70B 6.8KB 6.8KB +23B
core/Timestamp 27.5KB 27.9KB +429B 9.5KB 9.6KB +21B
core/Toast 59.8KB 60.9KB +1.0KB 9.7KB 9.7KB +22B
core/ToggleButton 44.1KB 45.2KB +1.0KB 9.3KB 9.3KB +21B
core/ToggleButtonGroup 6.3KB 6.4KB +68B 3.2KB 3.3KB +42B
core/Token 29.6KB 30.6KB +1.0KB 4.5KB 4.5KB +42B
core/Tokenizer 64.8KB 65.9KB +1.1KB 11.7KB 11.7KB +21B
core/Toolbar 39.8KB 40.2KB +469B 7.7KB 7.8KB +46B
core/TopNav 51.9KB 52.8KB +1015B 10.4KB 10.4KB +23B
core/TopNavHeading 54.2KB 55.2KB +1.0KB 9.9KB 10.0KB +20B
core/TopNavItem 9.0KB 9.8KB +777B 3.8KB 3.9KB +39B
core/TopNavMegaMenu 54.2KB 55.2KB +1.0KB 10.8KB 10.9KB +21B
core/TopNavMegaMenuFeaturedCard 6.7KB 6.8KB +66B 3.4KB 3.4KB +38B
core/TopNavMegaMenuItem 9.1KB 9.9KB +776B 4.1KB 4.1KB +38B
core/TopNavMenu 53.6KB 54.6KB +1019B 10.1KB 10.1KB +23B
core/TreeList 32.5KB 47.3KB +14.8KB ⚠️ 4.7KB 5.1KB +439B
core/Typeahead 61.5KB 62.5KB +1.0KB 11.3KB 11.3KB +21B
core/TypeaheadItem 6.2KB 6.3KB +60B 3.4KB 3.5KB +39B
core/VStack 7.3KB 7.3KB +61B 4.9KB 4.9KB +44B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/CheckboxList +15.0KB ⚠️ +79B
core/CheckboxListItem +4.8KB ⚠️ +804B
core/ContextMenu +19.7KB ⚠️ +858B
core/ContextMenuItem +36.7KB ⚠️ +4.4KB ⚠️
core/List +15.0KB ⚠️ +184B
core/ListItem +4.8KB ⚠️ +804B
core/Markdown +19.8KB ⚠️ +847B
core/PowerSearch +15.8KB ⚠️ +418B
core/Table +19.8KB ⚠️ +843B
core/TableCell +19.7KB ⚠️ +853B
core/TableHeaderCell +19.7KB ⚠️ +867B
core/TreeList +14.8KB ⚠️ +439B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 5.0KB 1.2KB 1.3KB +31B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 36.1KB +1.5KB

Accessibility Audit

Status: 2 accessibility violation(s) found — 2 serious.

Radio group pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/11 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
RadioList - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/14 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

55 of 506 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

cixzhang and others added 7 commits October 27, 2026 20:51
… hold

A divided List row keeps its dividers while it is pressed, as it does today, and Core keeps no selection hold: products show a row kept selected through ListItem's isSelected. This removes the touch press's divider hiding (its ListItem styles, docs, changeset, tests and browser proofs) and the selection hold (selectionHold.ts, its held press state in the shared press code, its internal deselect constants, the row focus-return lines, story, changeset and proofs). The shared test helpers, the press-coverage guard and the press release's own clock stay.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…tion hold) as a dependency

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
The previous merge left List.tsx's hasDividers JSDoc unresolved and the List stories empty. This restores the stories (without the selection hold demo) and the plain hasDividers line.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…tion hold) as a dependency

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…tion hold) as a dependency

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…tion hold) as a dependency

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…tion hold) as a dependency

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval is on hold until an engineering owner settles the slot-layout insertion cue, which answers the still-open design:ordered-collection-reordering OQ2: the accent line turned between the target slot and the next one, or another cue such as an outline on the target item. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author, as an engineering owner, may do so. Separately, the diff against main still carries at least five open stacked pull requests (258 files, 49,271 authored lines): please land them and rebase, then split positions() from attach() or explain why the remaining 3,848 lines must land together.

  • When a positions() layout or an attached container scrolls sideways, begin() measures the dragged item on screen while track() compares it with slot centres in layout coordinates, so the drag proposes the wrong slot (listReorderEngine.tsx:1308, :1430); a browser case that scrolls before dragging would pin the fix.
  • In right-to-left, slotLine's fallback for an item alone on its row draws the line on the reading-start side (reorderModel.ts:245); passing the direction, as gridOf already takes it, with a model test, would fix it.
  • createReorderable.doc.mjs:50 still calls start(key) the object's one method, beside attach.

[Automated review]

name: 'reorderable',
type: 'Reorderable',
description:
"The collection's reorder behavior: pass it as `List`'s `reorderable` prop, with an `itemKey` on each `ListItem`. Make it once per collection (with useMemo); one made on every render still works. Its one method, `start(key)`, enters reorder mode for that item from your own UI (a Move item in its context menu, a toolbar button): the focus moves to its handle, as activating it does; a key that cannot move or is not mounted does nothing and warns in development. One object may serve several collections, each reordering on its own; `start` then uses the one mounted last. Its `attach(container, {items})` reorders plain elements with no collection component: `items()` returns one `{key, element, handle}` per item in order, each handle becomes reorder mode's toggle named after its item (a rejected or keyless item's handle is hidden), and it returns `{detach}`, which removes everything it added. Items may sit under wrappers in the container. Reorder mode's hint renders in a React root of its own right after the container: it takes the theme around the container, not a theme set on the container itself or the providers of your React tree, and speaks the shipped English strings.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence still says “Its one method, start(key)”, and the same description then documents attach(container, {items}) as a second method. spec:AST-103 FR4a now reads “Beside FR4b's attach, nothing else on the object is public”, so the canonical descriptor (FR7) should describe both methods.

@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

This branch was successfully deployed

1 active deployment
Preview — 4cd30499 Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants