` carrying `setProps`, `rendered`, and a `render` callback (`:227-239`, `:255-257`).
+ React commits these into the fake DOM → `Document` builds the `BaseCollection`.
+6. Pass 2: `StandaloneListBox` (`ListBox.tsx:225`) calls `useListState({...props, collection})`.
+ `useListState` (`packages/react-stately/src/list/useListState.ts:50`) → `useCollection`
+ (`packages/react-stately/src/collections/useCollection.ts:30`): because a prebuilt `collection` is
+ passed, it is **returned as-is** (`useCollection.ts:38-39`) — the old `builder.build`/`ListCollection`
+ path is skipped. Then a `SelectionManager` is created over it.
+7. `ListBoxInner` (`ListBox.tsx:239`) renders the real `` and a ``
+ (`:427-432`). The **default** `CollectionRoot`/`CollectionBranch` (`Collection.tsx:201-208`) call
+ `useCollectionRender` → `useCachedChildren` and invoke `node.render!(node)` for each node
+ (`Collection.tsx:225`) — producing the real DOM. (Virtualized collections override
+ `CollectionRendererContext` with a renderer that only renders visible nodes.)
+
+The ComboBox/Select case: those render two copies. The first passes a `Document` via context (so
+`CollectionBuilder` short-circuits at `CollectionBuilder.tsx:53-61`), the second passes a `ListState`
+via `ListStateContext` so the ListBox reuses state without rebuilding (`ListBox.tsx:207-216`).
+
+## Sections, SSR, id/key resolution
+
+- **Sections** are branch nodes (`createBranchComponent(SectionNode, …)`). `` inside becomes a
+ `HeaderNode`. Section children are built recursively via nested ``/`useCachedChildren`.
+- **id/key resolution** (`useCachedChildren.ts:57-69`): explicit `id` prop wins, else `item.key`/
+ `item.id`, else array index as React key (the collection then auto-generates an id). `idScope`
+ prefixes ids (`idScope + ':' + id`) to keep nested collections unique. `addIdAndValue` also injects
+ `value={item}` so the node captures its data object.
+- **SSR**: portals don't exist server-side, so `Collection` renders through `` and
+ `useSSRCollectionNode` appends nodes to the document *during render* (`CollectionBuilder.tsx:184-197`),
+ and `` renders a ``. `Document.isSSR` keeps the collection unfrozen
+ (`BaseCollection.ts:315`, `Document.ts:544-547`); after hydration `resetAfterSSR()` (`Document.ts:589`)
+ clears the document so the client portal can take over.
+- **Async loading**: rendered as `LoaderNode` (`type 'loader'`), e.g. `ListBoxLoadMoreItem`
+ (`ListBox.tsx:742`), plus a `useLoadMoreSentinel` intersection observer.
+
+## Old vs new — how to tell them apart
+
+| | New (RAC/S2) | Old (RSP v3) |
+|---|---|---|
+| Builder | `CollectionBuilder` component (`packages/react-aria/src/collections/CollectionBuilder.tsx`) — renders JSX into a fake DOM | `CollectionBuilder` **class** (`packages/react-stately/src/collections/CollectionBuilder.ts:25`) — `build()` reflects over element `props`/`type` via `getFullNode` |
+| Collection | `BaseCollection` (`react-aria`) | `ListCollection`/`TreeCollection` (`react-stately`) built from `builder.build` |
+| Item/Section | `createLeafComponent`/`createBranchComponent`; ``, `` | `Item`/`Section` from `@react-stately/collections` (`react-stately/src/collections/Item.ts`, `Section.ts`) with a static `getCollectionNode` generator |
+| Entry point | component passes prebuilt `collection` prop → `useCollection` returns it unchanged (`useCollection.ts:38`) | `useCollection` runs `builder.build({children, items})` then `factory` (`useCollection.ts:41-42`) |
+
+Both paths funnel through the same `useListState`/`useTreeState` + shared `Collection`/`Node` types, so
+state, selection, and keyboard code is reused. **New is preferred** for all RAC and S2 work; the old
+reflective builder only remains for legacy RSP v3 components. Quick tell: if a component is defined with
+`createLeafComponent`/`createBranchComponent` and wrapped in a ``, it's new.
+
+## Common tasks & gotchas
+
+- **What triggers a rebuild (pass 1)**: any fake-DOM mutation (`appendChild`, `setProps`, `style.display`
+ change) marks nodes dirty and calls `queueUpdate()`, which clones the collection so
+ `useSyncExternalStore` sees a new snapshot and re-renders. `useCachedChildren` caches by item object
+ identity; pass a `dependencies` array to bust the cache when a render closure captures external state
+ (`useCachedChildren.ts:48`, `Collection` merges parent+child deps at `CollectionBuilder.tsx:276`).
+- **Copy-on-write / freezing**: committed collections are frozen — never mutate a collection you got
+ from state; call `.clone()` or go through the document. `addNode`/`removeNode`/`commit` throw on a
+ frozen collection (`BaseCollection.ts:275`, `:297`, `:309`).
+- **Suspense / hidden items**: React sets `display:none`; the fake `style` setter (`Document.ts:379-416`)
+ flips `isHidden`, which removes the node from the collection but keeps it in the document. Use
+ `useIsHidden`/`createHideableComponent` (`Hidden.tsx:84`) for components that must render nothing while
+ in a hidden collection subtree.
+- **Don't render collection item components outside a collection**: leaf components whose render fn
+ takes a `node` arg throw "cannot be rendered outside a collection" when not shallow
+ (`CollectionBuilder.tsx:220-224`).
+- **`id` is immutable** once set on a node (`Document.ts:366-368`) — changing an item's `id` between
+ renders throws.
+- **`node.childNodes` is deprecated** — iterate with `collection.getChildren(key)`
+ (`BaseCollection.ts:49`, `collections.d.ts:210-214`).
diff --git a/.claude/skills/drag-and-drop/SKILL.md b/.claude/skills/drag-and-drop/SKILL.md
new file mode 100644
index 00000000000..371c18aa76e
--- /dev/null
+++ b/.claude/skills/drag-and-drop/SKILL.md
@@ -0,0 +1,144 @@
+---
+description: Use when investigating, explaining, or modifying drag and drop (DnD) in react-spectrum — including keyboard/screen-reader accessible dragging, drop indicators, drop targets/operations, or adding DnD to a collection. Covers useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDragAndDrop, DragManager, DropTarget, DataTransfer, and AT live-region announcements.
+---
+
+# Drag and Drop (DnD)
+
+The single hardest part of this system is that **keyboard and screen-reader (AT) dragging is implemented from scratch** — the browser's native HTML5 DnD only serves pointer users. There are effectively **two parallel code paths** that resolve to the *same* drop-target model. Understand both before changing anything.
+
+Design rationale lives in `rfcs/2020-v3-dnd.md`. The code has since diverged from the RFC — trust the code. Notable divergences: `renderPreview` is now `preview`/`renderDragPreview`; `getDropOperationForPoint` (RFC on `useDrop`) is real, but collections resolve targets via a `DropTargetDelegate.getDropTargetFromPoint(x, y, isValid)` (3-arg) instead; keyboard "activation" (spring-loading) uses **Alt+Enter**, an RFC open question now answered in code.
+
+## Source layout
+
+Implementations live under `packages/react-aria/src/dnd/` and `packages/react-stately/src/dnd/`, re-exported by the thin `@react-aria/dnd` / `@react-stately/dnd` index files (`packages/@react-aria/dnd/src/index.ts`). Types live in `@react-types/shared`.
+
+## Layers
+
+| Layer | Files | Responsibility |
+|---|---|---|
+| Low-level primitives | `useDrag.ts`, `useDrop.ts`, `useClipboard.ts` | Make one element draggable / a single drop zone. Native HTML5 DnD + AT entry points. |
+| AT coordinator | `DragManager.ts` | Global keyboard/screen-reader "drag session" — the from-scratch a11y engine. Module-level singletons. |
+| Collection hooks | `useDraggableCollection.ts`, `useDroppableCollection.ts`, `useDraggableItem.ts`, `useDroppableItem.ts`, `useDropIndicator.ts` | Drag/drop across a collection of items with insertion positions. |
+| Collection state | `react-stately/.../useDraggableCollectionState.ts`, `useDroppableCollectionState.ts` | Track dragging keys, current drop target, compute drop operation. |
+| RAC sugar | `react-aria-components/src/useDragAndDrop.tsx`, `DragAndDrop.tsx` | `useDragAndDrop({...})` → `dragAndDropHooks` object consumed by ListBox/GridList/Table/Tree. |
+| S2 | `@react-spectrum/s2/src/useDragAndDrop.ts` | Thin wrapper re-exporting RAC's `useDragAndDrop` (omits `renderDropIndicator`). |
+
+## The native pointer path
+
+`useDrag.ts:108` returns `{dragProps, dragButtonProps, isDragging}`. `dragProps` sets `draggable: 'true'` + `onDragStart/onDrag/onDragEnd`.
+
+- **onDragStart** (`useDrag.ts:127`): calls `getItems()`, serializes to the native `DataTransfer` via `writeToDataTransfer` (`utils.ts:97`). Items with multiple representations, or multiple items of one type, are JSON-serialized under the custom type `application/vnd.react-aria.items+json` (`constants.ts:90`); single native types (`text/plain`, `text/uri-list`, `text/html`, `constants.ts:89`) are also written directly for cross-app interop.
+- **Drop operations** are a bitmask enum `DROP_OPERATION {none/cancel=0, move=1, copy=2, link=4, all=7}` (`constants.ts:24`). `getAllowedDropOperations()` → `effectAllowed`. Native `dropEffect` maps back via `DROP_EFFECT_TO_DROP_OPERATION` (`constants.ts:67`) in `onDragEnd`.
+- **Custom preview**: `useDrag.ts:171` calls the `preview` ref (a `DragPreview` component, `DragPreview.tsx`) synchronously inside onDragStart, `flushSync`-renders it offscreen, and hands the node to `dataTransfer.setDragImage`.
+- **Enforcement**: `useDrag.ts:214` installs a one-time window `drop` listener that `preventDefault`s and warns — drags started by `useDrag` may only be dropped on a `useDrop` target, guaranteeing an accessible alternative exists.
+
+`useDrop.ts:104` is the pointer drop zone: `onDragEnter/Over` call `getDropOperationForPoint`, a hover longer than `DROP_ACTIVATE_TIMEOUT = 800ms` (`useDrop.ts:98`) fires `onDropActivate` (spring-loading), and `onDrop` parses the DataTransfer back into `DropItem[]` via `readFromDataTransfer` (`utils.ts:216`). Drop items have `kind: 'text' | 'file' | 'directory'` (`utils.ts:232/318/328`); directories use `webkitGetAsEntry` (`utils.ts:257`) and the `DIRECTORY_DRAG_TYPE` symbol. Type guards: `isTextDropItem/isFileDropItem/isDirectoryDropItem`.
+
+## The accessibility path (keyboard + screen reader) — THE KEY PART
+
+No pointer is involved. `DragManager.ts` is a **module-level singleton** holding `dropTargets`, `dropItems`, and one live `dragSession` (`DragManager.ts:34-37`). Anything droppable registers itself globally via `registerDropTarget` (`DragManager.ts:52`, collection-level) and `registerDropItem` (`:68`, per item), regardless of where it is in the tree.
+
+### Starting a session
+`useDrag.ts` enters AT mode on **Enter keyup** (`useDrag.ts:396`, keyboard) or a **virtual click** (`:403`, screen readers — NVDA/JAWS browse mode, VoiceOver/TalkBack detected in `onPointerDown` at `:366`). Both call `startDragging` → `DragManager.beginDragging(target, stringFormatter)` (`useDrag.ts:330`, `DragManager.ts:82`). If there is a conflicting item action (selection), `useDraggableItem.ts:138` requires **Alt+Enter** and swaps the intl message to the `...Alt` variant.
+
+`beginDragging` constructs a `DragSession` and, on the next frame, calls `setup()` then (for keyboard modality) `next()` to focus the first target (`DragManager.ts:88`).
+
+### Session mechanics (`DragSession`, `DragManager.ts:168`)
+`setup()` (`:194`):
+- Installs **capture-phase listeners** for `keydown/keyup/focus/blur/click/pointerdown` and a long list of `CANCELED_EVENTS` (`:138`) that are `preventDefault`+`stopImmediatePropagation`'d (`cancelEvent`, `:373`). This is how *all* normal interaction is suppressed during a drag — only drop targets are reachable.
+- Calls `updateValidDropTargets()`.
+- **Announces** drag start into a live region via `announce()` (from `LiveAnnouncer`) using `MESSAGES[modality]` → intl keys `dragStartedKeyboard` / `dragStartedTouch` / `dragStartedVirtual` (`:162`, `:209`).
+
+`updateValidDropTargets()` (`:392`) is the heart of AT focus management:
+1. `findValidDropTargets` (`:707`) filters registered targets: skips anything inside `[aria-hidden="true"]`/`[inert]`, and calls each target's `getDropOperation(types, allowed)`, dropping those that return `'cancel'`.
+2. Reorders targets so the one nearest the drag origin comes first (`findNearestDropTarget`, `:527`; prefers an ancestor if the drag target is inside one).
+3. **`ariaHideOutside(...)`** (`:433`) hides *everything except* the drag source, valid drop items, and valid drop targets from AT (`shouldUseInert: true`). This is why an AT user only encounters valid drop locations.
+4. A `MutationObserver` on `aria-hidden`/`inert` re-runs this when the DOM changes (`:450`) — so newly registered targets stay consistent.
+
+### Navigation and commit
+- **Tab / Shift+Tab** → `next()` / `previous()` (`:236`, `:457`, `:492`) cycle through `validDropTargets`; at either end they cycle back to the original drag source so users without an Escape key (e.g. iPad) can cancel.
+- **Escape** → `cancel()` (`:231`, `:634`): ends the session, restores focus to the drag source, announces `dropCanceled`.
+- **Enter (keyup)** → `onKeyUp` (`:249`): if `altKey` or focus is on an "activate button", calls `activate()` (spring-load, `:691`); otherwise `drop()` (`:647`).
+- **Arrow keys inside a collection**: `onKeyDown` (`:228`) forwards the event to `currentDropTarget.onKeyDown` — the collection's registered handler (`useDroppableCollection.ts:590`) moves between insertion/on positions (see below).
+- Focus moving onto/off a target is intercepted in `onFocus`/`onBlur` (`:269`, `:310`) and translated into `setCurrentDropTarget` (`:552`), which fires `onDropEnter`/`onDropExit` and focuses the element.
+- `drop()` (`:647`) computes the operation from the item/target `getDropOperation` (falling back to the first allowed op), fires `onDrop` with synthesized `DropItem[]`, then `end()` and announces `dropComplete`.
+
+### Key/gesture bindings (during a session)
+
+| Input | Handler | Effect |
+|---|---|---|
+| `Enter` (start) | `useDrag.ts:396` onKeyUpCapture | Begin AT drag from the handle |
+| `Alt+Enter` (start) | `useDraggableItem.ts:148` | Begin drag when item has a conflicting action |
+| `Tab` / `Shift+Tab` | `DragManager.ts:236` → `next()`/`previous()` | Move between valid drop *targets* (collection = 1 stop) |
+| `ArrowUp/Down/Left/Right` | forwarded to `useDroppableCollection.ts:590` | Move between drop *positions* within a collection (`before`/`on`/`after`) |
+| `Home` / `End` / `PageUp` / `PageDown` | `useDroppableCollection.ts:642-788` | Jump within collection |
+| `Enter` (commit) | `DragManager.ts:249` `onKeyUp` → `drop()` | Drop on current target |
+| `Alt+Enter` (over target) | `DragManager.ts:253` → `activate()` | Spring-load / navigate into target |
+| `Escape` | `DragManager.ts:231` → `cancel()` | Cancel, restore focus to source |
+| Screen-reader double-tap / click | `DragManager.ts:335` `onClick` (virtual) | Drop, activate, or cancel (on source) |
+
+### Modality detection
+
+`getDragModality()` (`utils.ts:93`) maps the interaction modality to `keyboard | touch | virtual`, driving which intl strings and gestures apply. `useDrag.ts:366` `onPointerDown` sniffs virtual (AT) pointer events: iOS VoiceOver (`width<1 && height<1 && isIOS && isWebKit`) and Android TalkBack (pointer at the exact element center) are forced to `'virtual'`, so `onDragStart` (`:136`) enters the DragManager path instead of native HTML5 DnD. Getting this wrong is a common source of "drag doesn't start under a screen reader" bugs.
+
+### Announcements (intl)
+All AT strings are keys in `packages/react-aria/intl/dnd/en-US.json`, formatted with `useLocalizedStringFormatter(intlMessages, '@react-aria/dnd')`.
+
+| Key | Where | Purpose |
+|---|---|---|
+| `dragDescriptionKeyboard` / `...Touch` / `...Virtual` | `useDrag.ts:89`, `useDraggableItem.ts:53` | `aria-describedby` on the drag handle: how to start |
+| `dragDescriptionKeyboardAlt` / `dragSelectedKeyboardAlt` | `useDraggableItem.ts:110` (`msg += 'Alt'`) | when item has a conflicting action → Alt+Enter |
+| `dragSelectedItems`, `dragItem` | `useDraggableItem.ts:126-129` | drag button label incl. selected count |
+| `dragStartedKeyboard/Touch/Virtual` | `DragManager.ts:162` | live announcement on session start |
+| `endDragKeyboard/Touch/Virtual` | `useDrag.ts:89` | description while dragging (press Enter to cancel) |
+| `dropOnRoot`, `dropOnItem`, `insertBefore`, `insertAfter`, `insertBetween` | `useDropIndicator.ts:67-107` | `aria-label` on each drop target/indicator |
+| `dropIndicator` | `useDropIndicator.ts:115` | `aria-roledescription` on indicators |
+| `dropComplete`, `dropCanceled` | `DragManager.ts:644/688` | live announcement on end |
+
+If announcements go wrong, suspect: (a) missing `aria-label`/textValue on items feeding `getText` in `useDropIndicator.ts`; (b) the assertive drag-start announcement swallowing the first target announcement — handled deliberately at `DragManager.ts:596` (first target announced `'polite'`); (c) `ariaHideOutside` hiding something it shouldn't, or not hiding enough.
+
+## Drop targets & indicators
+
+`DropTarget = RootDropTarget | ItemDropTarget` (`@react-types/shared`):
+
+| Type | Shape | Meaning |
+|---|---|---|
+| root | `{type: 'root'}` | Drop on the whole collection |
+| item / on | `{type: 'item', key, dropPosition: 'on'}` | Drop onto an item (e.g. a folder) |
+| item / before | `{type: 'item', key, dropPosition: 'before'}` | Insert before item |
+| item / after | `{type: 'item', key, dropPosition: 'after'}` | Insert after item |
+
+Both paths converge on this model:
+- **Pointer** → `DropTargetDelegate.getDropTargetFromPoint(x, y, isValidDropTarget)` (`ListDropTargetDelegate.ts:88`). Binary-searches item rects; picks `on` if valid, else `before`/`after` based on which half of the item the point is in (with a 5px edge bias, `:167`). RTL and stack/grid handled via primary/secondary/flow axes.
+- **Keyboard** → `DropTargetKeyboardNavigation.ts` `navigate(...)` walks the collection using the `KeyboardDelegate`, producing the next `before`/`on`/`after` target (handles nesting/levels for Tree). `useDroppableCollection.ts:443` `nextValidTarget` skips targets whose `getDropOperation` returns `'cancel'`.
+
+Two `before`/`after` targets pointing at the same gap are treated as equal in `useDroppableCollectionState.ts:207` (`isDropTarget` + `getOppositeTarget`), so the indicator doesn't flicker.
+
+**Indicators**: `useDropIndicator.ts` builds the `aria-label`, `aria-roledescription`, and hides the element when not in a session / not the active target (`isHidden`, `:125`). In RAC, `DragAndDrop.tsx` `useRenderDropIndicator` (`:77`) renders a `` (or the app's `renderDropIndicator`) between items.
+
+## State
+
+- `useDraggableCollectionState.ts`: `draggingKeys` / `draggedKey`, `getKeysForDrag` (drags all selected items if the pressed item is selected, else just it — filtering out descendants of other dragged keys, `:90`), `startDrag/moveDrag/endDrag`.
+- `useDroppableCollectionState.ts`: current `target`, `setTarget` (fires `onDropEnter`/`onDropExit`), `isDropTarget`, and **`getDropOperation`** (`:233`) — the policy engine. It prevents dropping an item on itself/its descendants (`:237`), then `defaultGetDropOperation` (`:102`) maps the target + which handlers exist (`onInsert`/`onReorder`/`onMove`/`onRootDrop`/`onItemDrop`) + `isInternal` + `acceptedDragTypes`/`shouldAcceptItemDrop` to an operation or `'cancel'`.
+
+`isInternal` (drag source === drop collection) is tracked in module-level global DnD state in `utils.ts` (`globalDndState`, `setDraggingKeys`, `isInternalDropOperation`, `setDropCollectionRef`).
+
+## How RAC consumes it — ListBox trace
+
+1. App calls `useDragAndDrop({getItems, onReorder, onInsert, renderDropIndicator, ...})` (`react-aria-components/src/useDragAndDrop.tsx:147`). It computes `isDraggable = !!getItems` and `isDroppable = !!(onDrop||onInsert||onItemDrop||onReorder||onMove||onRootDrop)` and returns a `dragAndDropHooks` bag wiring the low-level hooks with the options pre-bound (`:166-207`).
+2. `ListBox.tsx:246` reads `isListDraggable/isListDroppable` off that bag. When draggable it calls `dragAndDropHooks.useDraggableCollectionState({collection, selectionManager, preview})` then `useDraggableCollection({}, dragState, listBoxRef)` (`ListBox.tsx:322`).
+3. When droppable it calls `useDroppableCollectionState({collection, selectionManager})`, constructs a `ListDropTargetDelegate(collection, listBoxRef, {orientation, layout, direction})` (unless one is provided), then `useDroppableCollection({keyboardDelegate, dropTargetDelegate}, dropState, listBoxRef)` (`ListBox.tsx:338-361`). `useDroppableCollection.ts:479` registers the collection as a `DragManager` drop target and installs the arrow-key `onKeyDown` handler.
+4. Per option, `Option` calls `dragAndDropHooks.useDraggableItem` / `useDroppableItem` (`ListBox.tsx:563-576`). `useDroppableItem.ts:51` registers each item with `DragManager` and focuses it when it becomes the active target (`:84`).
+5. Indicators render via `DragAndDropContext` + `useRenderDropIndicator` → `ListBoxDropIndicatorWrapper` (`ListBox.tsx:662`), which calls `dragAndDropHooks.useDropIndicator` and omits the DOM node when `isHidden`.
+6. `defaultOnDrop` (`useDroppableCollection.ts:96`) routes the drop to the right high-level callback (`onRootDrop`/`onItemDrop`/`onMove`/`onInsert`/`onReorder`) based on `target` + `isInternal`, filtering items by `acceptedDragTypes`/`shouldAcceptItemDrop`.
+
+## Gotchas & common tasks
+
+- **Adding DnD to a new collection component**: it must supply a `KeyboardDelegate` and a `DropTargetDelegate` (reuse `ListDropTargetDelegate` for lists/grids), render items with `data-key`, and call the six item/collection hooks like ListBox does. Without a delegate, keyboard navigation between drop positions can't work.
+- **Reorder within a list vs. drop into another list**: same DropTarget model; distinguished by `isInternal`. `onReorder`/`onMove` fire only when internal; `onInsert`/`onRootDrop` only when external (`useDroppableCollectionState.ts:110-155`, `useDroppableCollection.ts:136-159`).
+- **External sources / files**: the RFC (`limitations`) notes AT DnD **cannot** cross application boundaries (files, iframes) — those work pointer-only. `useClipboard.ts` provides copy/paste as the accessible alternative for cross-app transfer. Files arrive as `FileDropItem`/`DirectoryDropItem` via `readFromDataTransfer`.
+- **Alt+Enter requirement**: if a draggable item also has a primary action, dragging needs Alt (`useDraggableItem.ts:138`). Forgetting `hasAction` breaks either dragging or the action.
+- **`isValidDropTarget` must be a pure function of the target** — `getDropTargetFromPoint` calls it repeatedly during binary search; side effects there cause subtle bugs.
+- **Never move focus manually during a drag.** `DragManager` owns focus during a session; steer it through the registered target callbacks (`onDropEnter`/`onDropExit` and the `next()`/`previous()` cycle), not `element.focus()`.
+- **Announcement timing**: assertive drag-start can clobber a target announcement — the polite re-announce at `DragManager.ts:596` exists for exactly this; preserve it.
+- Module-level singletons in `DragManager.ts` and `utils.ts` (`globalDndState`) mean only one drag can be active at a time and state leaks across tests if not cleared (`clearGlobalDnDState`).
diff --git a/.claude/skills/virtualizer/SKILL.md b/.claude/skills/virtualizer/SKILL.md
new file mode 100644
index 00000000000..037afe53a05
--- /dev/null
+++ b/.claude/skills/virtualizer/SKILL.md
@@ -0,0 +1,181 @@
+---
+description: Use when working on virtualization/scrolling in react-spectrum — reading, debugging, or modifying the Virtualizer, ScrollView, Layout (ListLayout/GridLayout/TableLayout/WaterfallLayout), LayoutInfo, Rect, ReusableView, VirtualizerState, overscan, view recycling, estimated/measured sizes, sticky headers, or how RAC/S2 collections (ListBox, GridList, Table, Tree, CardView, Picker, ComboBox) opt into virtualization.
+---
+
+# Virtualizer & ScrollView
+
+How react-spectrum renders huge collections by only mounting the views inside (or near) the visible rectangle, recycling DOM as you scroll.
+
+## Two layers
+
+- **Framework-agnostic core** — `@react-stately/virtualizer`. Source lives in `packages/react-stately/src/virtualizer/` and `packages/react-stately/src/layout/`. Pure TS: layout math + which-views-are-visible. No React, no DOM (except `performance.now`/`clientWidth` guards). Re-exported through `react-stately/useVirtualizerState`.
+- **React binding** — `@react-aria/virtualizer`. Source in `packages/react-aria/src/virtualizer/`. Owns the scroll container, scroll/resize listeners, and turning `ReusableView`s into DOM. Re-exported through `react-aria/private/virtualizer/*`.
+- **RAC integration** — `packages/react-aria-components/src/Virtualizer.tsx` wires a `Layout` into RAC's collection renderer so ListBox/GridList/Table/Tree virtualize. S2 (`@react-spectrum/s2`) consumes that RAC ``.
+
+Note: the `src/` dirs under `packages/@react-stately/virtualizer` and `packages/@react-aria/virtualizer` only contain `index.ts` re-exports; the real code is in the mono-package `packages/react-stately` / `packages/react-aria`.
+
+## Data flow (one frame)
+
+```
+ScrollView (DOM scroll/resize)
+ → onVisibleRectChange(rect) / onSizeChange(size)
+ → useVirtualizerState setState
+ → Virtualizer.render({visibleRect, size, collection, layout, ...})
+ → relayout(): layout.update() → layout.getContentSize()
+ → updateSubviews(): getVisibleLayoutInfos() → diff → recycle ReusableViews
+ → returns visibleViews (root's children)
+ → React renders each as (absolute-positioned from LayoutInfo.rect)
+```
+
+---
+
+## ScrollView — `packages/react-aria/src/virtualizer/ScrollView.tsx`
+
+The scroll container. `useScrollView` (`ScrollView.tsx:70`) returns `scrollViewProps` (outer, `overflow: auto`) and `contentProps` (inner "sizer" div).
+
+- **Sizer element**: inner div gets `width`/`height` = `contentSize` and `position: relative` (`ScrollView.tsx:382`). This is what gives the scrollbar its length; items are absolutely positioned inside it. Non-finite content sizes are dropped so an axis can be unbounded.
+- **Scroll handling**: a single **document-level capturing** `scroll` listener (`ScrollView.tsx:222`), not a React `onScroll`. The handler (`ScrollView.tsx:139`) checks whether the event target is the scroll view itself vs. an ancestor/window:
+ - target IS the scroll view → read `scrollTop` + `getScrollLeft(target, direction)`, clamp to `[0, contentSize - size]` to stop rubber-band jitter (`ScrollView.tsx:163`).
+ - target is an ancestor/window → recompute `viewportOffset` from `getBoundingClientRect` (window-scrolling case).
+- **Visible rect** (`updateVisibleRect`, `ScrollView.tsx:104`): with `allowsWindowScrolling`, intersects the window viewport with the scroll view so an unbounded-height list still virtualizes as the *page* scrolls; otherwise it's just `scrollPosition + size`. Emitted via `flushSync` so layout is synchronous with the scroll (avoids blank flashes).
+- **isScrolling**: set true on first scroll, reset by a **300ms** idle timeout (`ScrollView.tsx:196`). While scrolling, inner div gets `pointerEvents: none` and DOM reordering is deferred (see recycling). Also toggles a typekit MutationObserver pause via `tk.disconnect-observer`/`tk.connect-observer` events.
+- **Resize**: `useResizeObserver` on **border-box** (`ScrollView.tsx:360`) + a `window` resize listener. `updateSize` (`ScrollView.tsx:241`) guards reentrancy (`isUpdatingSize`) and does **at most two** layout passes to settle scrollbar appear/disappear, matching browser CSS-grid behavior (`ScrollView.tsx:285`).
+- **Overflow rules** (`ScrollView.tsx:368`): forces `overflow-x: hidden` when `contentSize.width === size.width` to stop a resize-observer/frame-rate feedback loop that flickers the horizontal scrollbar.
+- **RTL**: `getScrollLeft`/`setScrollLeft` in `packages/react-aria/src/virtualizer/utils.ts` normalize `scrollLeft` across the three browser RTL conventions (`negative`, `positive-descending`, `positive-ascending`) detected once by `getRTLOffsetType` (`utils.ts:29`).
+
+---
+
+## Virtualizer core — `packages/react-stately/src/virtualizer/Virtualizer.ts`
+
+Central class. Holds `collection`, `layout`, `contentSize`, `visibleRect`, `size`, `persistedKeys`, a `Map` of visible views, a `RootView`, and an `OverscanManager`.
+
+- **`render(opts)`** (`Virtualizer.ts:280`) — the entry point called every React render. Diffs incoming `collection`/`layout`/`persistedKeys`/`visibleRect`/`size`/`invalidationContext`/`isScrolling` against current state, sets `needsLayout` vs `needsUpdate`, then calls `relayout()` or `updateSubviews()`. Returns `Array.from(this._rootView.children)`.
+ - A visibleRect/size change only forces layout if `layout.shouldInvalidate(newRect, oldRect)` returns true (`Virtualizer.ts:326`) — otherwise it's a cheap `updateSubviews`. Note `size` here is a **layout size** (scroll-view dimensions), distinct from `visibleRect` whose w/h can change during window scrolling.
+- **`relayout(context)`** (`Virtualizer.ts:169`) — `layout.update(context)`, then `contentSize = layout.getContentSize()`, then clamps scroll offset into content (scrolls to top if `contentChanged`). If offset changed it asks the delegate to re-scroll; else `updateSubviews()`.
+- **`getVisibleLayoutInfos()`** (`Virtualizer.ts:197`) — asks the `OverscanManager` for the overscanned rect, calls `layout.getVisibleLayoutInfos(rect)`, returns a `Map`. In **test env** (unless `VIRT_ON`) and when `clientWidth/Height` aren't mocked, it uses the **full content rect** so tests render everything.
+- **`updateSubviews()`** (`Virtualizer.ts:223`) — the reconciliation:
+ 1. Delete views whose key is gone or whose parent changed; hand them back to the parent's reuse queue (`reuseChild`).
+ 2. For each visible LayoutInfo: reuse an existing view (re-render only if the backing collection item identity changed) or pull a recycled view via `getReusableView`.
+ 3. Views never reused get removed from DOM and the parent's reuse queue is cleared (FIFO churn hurts later reuse).
+ 4. **DOM reordering is deferred until scrolling stops** (`Virtualizer.ts:269`): absolute positioning means visual order is independent of DOM order, but DOM order matters for screen readers, so it's fixed to topological (parents-before-children) order only when `!isScrolling`.
+- **`updateItemSize(key, size)`** (`Virtualizer.ts:389`) — forwards to `layout.updateItemSize`; if it reports a change, invalidates with `itemSizeChanged: true`.
+- **Persisted keys** (`isPersistedKey`, `Virtualizer.ts:91`) — a key is persisted if it's in the set OR is an *ancestor* of a persisted key. Persisted views stay mounted even when scrolled out of view (used to keep the focused/active item in the DOM so focus isn't lost).
+
+### OverscanManager — `packages/react-stately/src/virtualizer/OverscanManager.ts`
+
+Expands the visible rect so views just outside the viewport are pre-rendered. Overscan = **1/3 of the visible height/width** on the leading edge (`OverscanManager.ts:37`). Direction is chosen from scroll **velocity** (tracked over a 500ms window, `OverscanManager.ts:21`): only extends in the direction of travel — scrolling up extends `y` upward, down just extends height.
+
+---
+
+## Layout base contract — `packages/react-stately/src/virtualizer/Layout.ts`
+
+Abstract class; subclass and implement the three abstract methods.
+
+| Method | Required? | Purpose | Source |
+|---|---|---|---|
+| `getVisibleLayoutInfos(rect)` | abstract | Return `LayoutInfo[]` intersecting `rect` (+ sticky/persisted). Hot path — called every frame. | `Layout.ts:41` |
+| `getLayoutInfo(key)` | abstract | `LayoutInfo` for one key (random access, e.g. Home/End, drop targets). | `Layout.ts:49` |
+| `getContentSize()` | abstract | Total scrollable size → drives the sizer/scrollbar. | `Layout.ts:54` |
+| `update(invalidationContext)` | optional | Pre-compute before the getters. Where most layouts build their cache. | `Layout.ts:82` |
+| `shouldInvalidate(newRect, oldRect)` | optional | Default: re-layout only when **size** changes. Return `true` always for sticky-header layouts that must recompute while scrolling. | `Layout.ts:62` |
+| `shouldInvalidateLayoutOptions(new, old)` | optional | Default: identity compare. Override to skip re-layout on irrelevant option changes. | `Layout.ts:72` |
+| `updateItemSize(key, size)` | optional | Record a measured size; return `true` if it changed the layout. | `Layout.ts:87` |
+| `getDropTargetLayoutInfo(target)` | optional | Position the drop indicator. | `Layout.ts:92` |
+
+`virtualizer` back-reference is set by `Virtualizer.render` (`Virtualizer.ts:294`); inside a layout, read `this.virtualizer.collection`, `.size`, `.visibleRect`, `.persistedKeys`, `.contentSize`.
+
+### Concrete layouts
+
+- **`ListLayout`** — `packages/react-stately/src/layout/ListLayout.ts`. Vertical (or horizontal) stack, fixed or variable row sizes, sections/headers/loaders. The base for most others.
+- **`GridLayout`** — `packages/react-stately/src/layout/GridLayout.ts`. Fixed-size cells in rows/columns (CardView grid, ListBox `layout="grid"`).
+- **`TableLayout` extends `ListLayout`** — `packages/react-stately/src/layout/TableLayout.ts`. Adds column widths (`TableColumnLayout`), **sticky** columns/headers (`isSticky = true`, `zIndex`, `TableLayout.ts:359`), and persisted column indices per row (`persistedIndices`, `TableLayout.ts:566`).
+- **`WaterfallLayout`** — `packages/react-stately/src/layout/WaterfallLayout.ts`. Masonry columns.
+
+RAC subclasses in `packages/react-aria-components/src/GridLayout.ts` / `TableLayout.ts` just add `useLayoutOptions()` to inject locale `direction` (see integration below).
+
+---
+
+## LayoutInfo & Rect — the lightweight position records
+
+`LayoutInfo` (`packages/react-stately/src/virtualizer/LayoutInfo.ts`) — one per rendered element; layouts create them, the Virtualizer turns them into DOM.
+
+| Field | Meaning |
+|---|---|
+| `type` | matches collection node `type` (`item`, `header`, `section`, `loader`, `dropIndicator`…); picks the reuse pool. |
+| `key` | matches collection node key. |
+| `parentKey` | parent LayoutInfo key (hierarchy: sections, table rows→cells). `null` = child of root. |
+| `rect` | `Rect` position+size (see below). |
+| `estimatedSize` | `true` → measured on first mount, then `updateItemSize` corrects it. |
+| `isSticky` | positioned `sticky` instead of `absolute`; stays visible while scrolling. |
+| `opacity` / `transform` / `zIndex` / `allowOverflow` | passed straight to element style. |
+
+`Rect` (`packages/react-stately/src/virtualizer/Rect.ts`) — `{x, y, width, height}` with getters `maxX/maxY/area/topLeft…` and helpers `intersects`, `containsRect`, `containsPoint`, `union`, `intersection`, `equals`. `intersects` short-circuits to `true` in test env unless `VIRT_ON` (`Rect.ts:96`). `Size` and `Point` are the trivial companions in the same dir.
+
+---
+
+## View recycling — `packages/react-stately/src/virtualizer/ReusableView.ts`
+
+`ReusableView` = a slot that can be re-pointed at different content as you scroll, avoiding mount/unmount churn.
+
+- Tree of views: one `RootView`; `ChildView`s each have a `parent`, a `children` Set, and per-type `reusableViews` queues.
+- **Keyed by `type`** (`getReusableView(reuseType)`, `ReusableView.ts:61`): a scrolled-off row view is reused only for another row, a cell for another cell.
+- Reuse queues are **FIFO** (`shift`/`push`) so sibling DOM order (e.g. cells in a row) stays stable across reuse (`ReusableView.ts:62`).
+- `reuseChild` calls `prepareForReuse()` (clears `content`/`rendered`/`layoutInfo`) then queues it (`ReusableView.ts:75`).
+- The Virtualizer caches rendered React elements per collection item in a `WeakMap` (`_renderedContent`, `Virtualizer.ts:138`) so re-rendering a still-present item is free.
+
+---
+
+## React binding
+
+- **`useVirtualizerState`** — `packages/react-stately/src/virtualizer/useVirtualizerState.ts:50`. Owns `visibleRect`/`size`/`isScrolling`/`invalidationContext` state, constructs one `Virtualizer` with a delegate (`setVisibleRect`, `renderView`, `invalidate`, `useVirtualizerState.ts:58`), calls `virtualizer.render(...)` **during render**, and fires `onVisibleRectChange` from a layout effect. `size` passed to the core is `visibleRect` unless `allowsWindowScrolling` (then the real scroll-view `size`).
+- **`Virtualizer` (standalone, v3)** — `packages/react-aria/src/virtualizer/Virtualizer.tsx`. Composes `useVirtualizerState` + ``, syncs `scrollLeft/scrollTop` back to the DOM on visible-rect change (`Virtualizer.tsx:71`), renders each `ReusableView` via `renderWrapper` → ``, and wires `useLoadMore`.
+- **`VirtualizerItem` / `layoutInfoToStyle`** — `packages/react-aria/src/virtualizer/VirtualizerItem.tsx`. Turns a `LayoutInfo` into inline style: `position: sticky|absolute`, top/left offset **relative to parent** (RTL flips to `right`, `VirtualizerItem.tsx:54`), `contain: size layout style`, `overflow: hidden` unless `allowOverflow`. Cached per LayoutInfo in a `WeakMap`.
+- **`useVirtualizerItem`** — `packages/react-aria/src/virtualizer/useVirtualizerItem.ts`. When `layoutInfo.estimatedSize`, measures the DOM node (`scrollWidth/scrollHeight` after clearing `height`) and calls `virtualizer.updateItemSize` (`useVirtualizerItem.ts:46`). Skips measurement when the element is `display:none` to avoid reporting size 0.
+
+---
+
+## RAC / S2 integration — `packages/react-aria-components/src/Virtualizer.tsx`
+
+Opt in by wrapping a collection component:
+
+```tsx
+
+ {/* items */}
+
+```
+
+- `` (`Virtualizer.tsx:64`) instantiates the layout (accepts a class or instance) and publishes a `CollectionRenderer` with `isVirtualized: true`, `layoutDelegate`, `dropTargetDelegate` through `CollectionRendererContext`. The child collection reads that context and switches from plain rendering to virtualized rendering.
+- `CollectionRoot` (`Virtualizer.tsx:88`) runs `useVirtualizerState({allowsWindowScrolling: true, ...})` + `useScrollView`, and renders the visible views. `CollectionBranch` (`Virtualizer.tsx:146`) renders a parent view's children (nested rows/sections).
+- `useLayoutOptions()` (a `LayoutOptionsDelegate` hook on the layout) lets a layout pull hook-derived options like locale `direction` and merge them with the user's `layoutOptions` (`Virtualizer.tsx:100`).
+- **Persisted keys**: `usePersistedKeys(focusedKey)` (`packages/react-aria-components/src/Collection.tsx:291`) returns `new Set([focusedKey])` so the focused item stays mounted when scrolled away — critical for keyboard nav.
+- **Which layout**: `ListLayout` = vertical/horizontal lists (ListBox stack, GridList, Menu, Picker, ComboBox, Tree); `GridLayout` = fixed grid of cards/tiles (ListBox `layout="grid"`, CardView); `WaterfallLayout` = masonry (CardView); `TableLayout` = tables with sticky headers/columns. RAC ListBox also exposes a plain `layout="stack" | "grid"` prop (`packages/react-aria-components/src/ListBox.tsx:141`) that is a separate, simpler concept from the virtualizer `layout`. S2 examples: `packages/@react-spectrum/s2/src/{ListView,TableView,CardView,Picker,ComboBox,TreeView}.tsx`.
+
+---
+
+## Worked example — ListLayout vertical trace
+
+Given `rowHeight: 40`, `gap: 0`, `padding: 0`, a 1000-item list, viewport 400px tall scrolled to `y=2000`:
+
+1. **`update()`** (`ListLayout.ts:338`) — checks `shouldInvalidateEverything` (size/gap/rowSize/orientation change → clear cache, `ListLayout.ts:303`), applies option overrides, calls `buildCollection()`, prunes deleted keys, snapshots `validRect = requestedRect`.
+2. **`buildCollection(offset=padding)`** (`ListLayout.ts:381`) — walks collection nodes, accumulating `offset`. Rows **entirely before `requestedRect`** are skipped (offset advanced without building a node) unless already cached (`ListLayout.ts:399`). Once `offset` passes `requestedRect.maxY` it stops building and just adds the remaining rows' heights as estimate (`ListLayout.ts:422`). Sets `contentSize = Size(virtualizer.size.width, offset)` → ~`1000*40 = 40000px` tall.
+3. **`buildItem(node, x, y)`** (`ListLayout.ts:627`) — fixed `rowSize` → `rect = Rect(0, y, width, 40)`, `estimatedSize=false`. If no `rowSize`, reuses the previous height or `estimatedRowSize` and marks `estimatedSize=true` (measured later).
+4. **Overscan**: Virtualizer expands the ~`[2000,2400]` visible rect by 1/3 (≈133px, leading edge by velocity).
+5. **`getVisibleLayoutInfos(rect)`** (`ListLayout.ts:219`) — snaps `rect` to whole-row multiples so the count stays stable, `layoutIfNeeded(rect)` unions+rebuilds if the rect grew (`ListLayout.ts:257`), then collects nodes where `isVisible` (`ListLayout.ts:293`): rect-intersecting **OR** sticky/header/loader/persisted. Returns ~13–14 rows around index 50.
+6. React mounts only those ``s, each `position: absolute; top: `.
+
+**Variable sizes**: an item with `estimatedRowSize` renders estimated, `useVirtualizerItem` measures it → `updateItemSize` (`ListLayout.ts:674`) writes the measured height into a **copied** LayoutInfo (so caches invalidate), shrinks `validRect` to only rows above it, bumps `requestedRect`, and invalidates parents up the tree. Next frame re-lays-out from that point down.
+
+---
+
+## Gotchas & common tasks
+
+- **What triggers a re-layout** (vs. cheap subview update): collection identity change, layout identity change, `size` change where `shouldInvalidate` returns true, or an `invalidationContext` with `itemSizeChanged`/`sizeChanged`/`offsetChanged`/`layoutOptionsChanged` (`Virtualizer.ts:340`). Pure scroll within the same size is *not* a re-layout unless the layout opts in.
+- **Estimated vs. measured**: set `estimatedRowSize`/`estimatedHeadingSize` (not `rowSize`) for variable content. Estimated items flash to their real height after mount+measure; the scrollbar length is approximate until measured. Fixed `rowSize` skips measurement entirely — cheapest.
+- **Custom Layout**: extend `Layout`, implement the 3 abstract methods; build your cache in `update()`, keyed so `getLayoutInfo` is O(1). Return sticky items and persisted keys from `getVisibleLayoutInfos` even when outside the rect. Override `shouldInvalidate` to return `true` if positions depend on scroll offset (sticky headers).
+- **Sticky headers/columns**: set `LayoutInfo.isSticky = true` (+ `zIndex`); `layoutInfoToStyle` emits `position: sticky` and keeps it in normal flow (`display: inline-block`). See `TableLayout.ts:359`.
+- **Persisted (offscreen) keys**: focused/active items are kept mounted via `persistedKeys`; don't assume `getVisibleView(key)` returning a view means the key is on screen.
+- **Load more**: `useLoadMore` (`packages/react-aria/src/utils/useLoadMore.ts:45`) fires `onLoadMore` when `scrollHeight - scrollTop - clientHeight < clientHeight * scrollOffset`. Loader sentinels are laid out at estimated positions even when off-screen (`ListLayout.ts:422`) so the scrollbar accounts for them.
+- **Window scrolling**: `allowsWindowScrolling` (RAC default) lets an unbounded-height list virtualize against the page viewport by intersecting viewport ∩ scroll-view (`ScrollView.tsx:104`).
+- **Test env quirks**: in Jest without `VIRT_ON`, `Rect.intersects` and `getVisibleLayoutInfos` short-circuit to render the **entire** collection (`Rect.ts:96`, `Virtualizer.ts:197`), and unmocked `clientWidth/Height` become `Infinity` (`ScrollView.tsx:261`). To exercise real virtualization in a test, set `process.env.VIRT_ON` and mock `clientWidth`/`clientHeight`.
+- **Drag & drop**: `getDropTargetFromPoint` (`ListLayout.ts:737`) and `getDropTargetLayoutInfo` (`ListLayout.ts:799`) position the drop indicator; RAC wraps items with before/after drop indicators in `renderWrapper` (`react-aria-components/src/Virtualizer.tsx:177`).
+- **Don't move DOM order to fix visual order** — items are absolutely positioned; ordering only matters for a11y and is intentionally deferred until scroll stops (`Virtualizer.ts:269`).
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 85fa50515bf..3c0ae90bdb2 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,3 +1,4 @@
+
Closes
@@ -8,6 +9,8 @@ Closes
- [ ] Filled out test instructions.
- [ ] Updated documentation (if it already exists for this component).
- [ ] Looked at the Accessibility Practices for this feature - [Aria Practices](https://www.w3.org/WAI/ARIA/apg/)
+- [ ] I understand every change in this PR and can explain why it's there.
+- [ ] If AI-assisted, I followed our [AI contribution guidance](https://github.com/adobe/react-spectrum/blob/main/CONTRIBUTING.md#ai-assisted-contributions) and pointed my assistant at [CLAUDE.md](https://github.com/adobe/react-spectrum/blob/main/CLAUDE.md).
## 📝 Test Instructions:
diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 00000000000..681311eb9cf
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000000..d12a9d54e38
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,36 @@
+# CLAUDE.md
+
+Guidance for working in the react-spectrum monorepo.
+
+## Repo layout
+
+The repo is layered. Changes flow up from the lowest level:
+
+- **`@internationalized/*` and `@react-stately/*`** — the two lowest levels (i18n utilities and state management).
+- **`@react-aria/*`** — behavior and accessibility hooks built on the above.
+- **`react-aria-components` (RAC)** and some **React Spectrum v3 (RSP)** — component layer built on the hooks.
+- **RSP S2 (`@react-spectrum/s2`)** — the Spectrum 2 design system, the highest level.
+
+## Toolchain guardrails
+
+This repo does **not** use the conventional JS toolchain — use these, don't swap in defaults:
+
+- Format with `yarn format` (oxfmt), **not** Prettier. Lint with `yarn lint` (oxlint), **not** ESLint. Type-check with `yarn check-types` (tsgo), **not** tsc. Build with `yarn build` (Parcel), **not** rollup/tsc.
+- **Don't run `yarn chromatic` / `yarn chromatic:forced-colors`** — maintainers run the VRT suites.
+- All commonly used commands live in the root `package.json` scripts.
+
+## Contributing
+
+- **Match the surrounding code** — follow the naming, structure, and patterns of neighboring files.
+- **Commit format** — use conventional-commit prefixes (`fix:`, `feat:`, `chore:`, `docs:`) as seen in the git history.
+
+## Task-specific workflows
+
+Read the relevant file before starting that kind of work (other agents: read the file directly; Claude will surface it):
+
+- Writing or running tests → [`docs/contributing/testing.md`](docs/contributing/testing.md)
+- Tooling details (format/lint/type-check/build, Storybook, workspaces) → [`docs/contributing/tooling.md`](docs/contributing/tooling.md)
+- Styling S2 components → [`docs/contributing/s2-styling.md`](docs/contributing/s2-styling.md)
+- Adding user-facing strings → [`docs/contributing/i18n-strings.md`](docs/contributing/i18n-strings.md)
+- Touching generated code (icons) → [`docs/contributing/codegen.md`](docs/contributing/codegen.md)
+- Comments and opening a PR → [`docs/contributing/pull-requests.md`](docs/contributing/pull-requests.md)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ff52e57539a..8553be3bf2f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -44,6 +44,52 @@ Read [GitHub's pull request documentation](https://help.github.com/articles/abou
Lastly, please follow the pull request template when submitting a pull request!
+
+## AI-assisted contributions
+Setting expectations: the AI doesn't contribute to React Spectrum or Quarry, you do. The AI is a tool, but you are still the author, and you own every line, every decision, and every explanation.
+
+If you use an AI assistant, point it at our [CLAUDE.md](CLAUDE.md), which captures the repo conventions we expect it to follow. The detailed conventions are split across [`docs/contributing/`](docs/contributing/) — see testing, tooling, s2-styling, i18n-strings, codegen, and pull-requests.
+
+### Aligning on a solution
+
+Open an issue or discussion, or at minimum, describe the problem and intended solution in the PR description. Otherwise, we have to reverse-engineer both the problem and the intended solution before we can even begin reviewing. An issue with: "here's what I'm seeing, here's what I plan to do, does that sound right?" really helps.
+
+### Give us the intent, not just the fix
+
+Tell us what you want and why, separately from how you did it. Then if we need to make a change to the PR, we can be confident that we're satisfying the goal you had. This framing helps when prompting the AI as well.
+
+### Tell us what you tested
+
+* mouse / touch / keyboard / screen reader
+* LTR / RTL
+* light / dark / high-contrast
+* disabled / loading / error / empty
+* narrow / wide / truncated / very long / wrapping text
+* component sizes and zoom levels
+
+Even if you haven't tested all of these, it's hugely helpful to us to know where to focus efforts.
+
+### Beware false confidence
+
+One of the biggest issues we face with the rise of AI contributions is the wrong root cause but with a lot of details asserting why it is, in fact, the issue. Press the AI, and ask both it and yourself "is this the root cause, or a symptom?" and "what else could cause this?" before committing to a solution.
+
+### Keep a human in the loop of the conversation
+
+When you iterate between reviews, be sure you can say what changed and why, one sentence is enough. A PR that transforms completely between every review without explanation is exhausting to follow and makes us feel like we're arguing with a machine instead of collaborating with a person.
+
+
+#### Requirements of front end code
+
+These can be useful constraints or reminders as AI is not inherently good at these things. For the toolchain that enforces some of this, see [`docs/contributing/tooling.md`](docs/contributing/tooling.md).
+
+* **Small** — everything you add ships across the network to the client, so more code means slower load times.
+* **Fast** — must run well on constrained CPU and memory, not just the latest MacBook Pro. Many users are on years-old, low-end Android devices.
+* **Mindful of the shared environment** — keep the global namespace clean, don't hog resources or throw uncaught errors, avoid CSS that leaks across boundaries, and keep ids unique.
+* **Stable** — RSP and Quarry are libraries with many downstream dependents who upgrade on their own schedule, so avoid breaking them.
+* **Accessible** — accessibility is still a relatively new web requirement, so strong examples are scarce and bad ones are common. use other examples in the repo or the APG examples first.
+* **Cross-environment** — works across browsers, assistive technologies, and devices.
+
+
### Contributor License Agreement
All third-party contributions to this project must be accompanied by a signed contributor license agreement. This gives Adobe permission to redistribute your contributions as part of the project. [Sign our CLA](https://opensource.adobe.com/cla.html). You only need to submit an Adobe CLA one time, so if you have submitted one previously, you are good to go!
diff --git a/docs/contributing/codegen.md b/docs/contributing/codegen.md
new file mode 100644
index 00000000000..d4bbc00bf73
--- /dev/null
+++ b/docs/contributing/codegen.md
@@ -0,0 +1,3 @@
+# Generated code
+
+v3 icon components are generated (`yarn build:icons`) and s2 are handled through a parcel transformer, not hand-written, and `postinstall` runs `patch-package`, so run install on a fresh clone.
diff --git a/docs/contributing/i18n-strings.md b/docs/contributing/i18n-strings.md
new file mode 100644
index 00000000000..bea91a05b9a
--- /dev/null
+++ b/docs/contributing/i18n-strings.md
@@ -0,0 +1,3 @@
+# User-facing strings (i18n)
+
+Add the key to the package's `intl/en-US.json` (ICU MessageFormat) and read it via the localized string hook. Never hardcode UI text, and don't hand-edit the other locale files (translators own those).
diff --git a/docs/contributing/pull-requests.md b/docs/contributing/pull-requests.md
new file mode 100644
index 00000000000..961b90db254
--- /dev/null
+++ b/docs/contributing/pull-requests.md
@@ -0,0 +1,11 @@
+# Pull requests
+
+## Commenting
+
+Comments while developing are fine. Before presenting code for review, trim verbose comments so the diff reads cleanly. When a genuinely complex section still warrants a comment, prefer a higher-level explanation of the whole section over annotating individual lines.
+
+## Opening a PR
+
+Start from `.github/PULL_REQUEST_TEMPLATE.md` (e.g. `gh pr create --body-file .github/PULL_REQUEST_TEMPLATE.md`) rather than writing a body from scratch: fill in every section, complete the checklist honestly, and disclose AI use. Above the checklist, add a holistic summary of how the changes work and why this approach was chosen — give the intent separately from the implementation.
+
+See also `CONTRIBUTING.md` (AI-assisted contributions).
diff --git a/docs/contributing/s2-styling.md b/docs/contributing/s2-styling.md
new file mode 100644
index 00000000000..4a4fc5f3d82
--- /dev/null
+++ b/docs/contributing/s2-styling.md
@@ -0,0 +1,3 @@
+# S2 styling
+
+Style with the `style` macro (`import {style} from '../style' with {type: 'macro'};` — the `with {type: 'macro'}` attribute is required). Pass typed style objects to it; don't write CSS files or hand-rolled className strings for S2.
diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md
new file mode 100644
index 00000000000..c5c16b7cbb0
--- /dev/null
+++ b/docs/contributing/testing.md
@@ -0,0 +1,26 @@
+# Testing
+
+Test suites are split by type:
+
+- **Jest tests** — `yarn test`
+- **SSR tests** — `yarn test:ssr`
+- **Browser tests** — `yarn test:browser`
+- **Visual regression tests (VRT)** — `yarn chromatic`
+- **High-contrast-mode VRT** — `yarn chromatic:forced-colors`
+
+Maintainers run the Chromatic VRT suites themselves — don't run `yarn chromatic` / `yarn chromatic:forced-colors`. You can still start the VRT Storybooks locally to verify visual state: `yarn start:chromatic` and `yarn start:chromatic-fc`.
+
+Tests are **not** co-located with source — each package keeps them in a sibling `test/` directory. The file suffix routes the test to a runner: `*.ssr.test.*` → `yarn test:ssr`, `*.browser.test.*` → `yarn test:browser`, plain `*.test.*` → `yarn test` (Jest). Shared test helpers live in `@react-aria/test-utils` / `@react-spectrum/test-utils` (the `User` event abstraction and per-component testers).
+
+## Writing tests
+
+- **Run the full suite before committing.** Do not write PR descriptions that list a subset of specific passing tests — run `yarn test`, `yarn test:ssr` and (when relevant) `yarn test:browser` — do not run the Chromatic VRT suites (see above).
+- **Run lint and formatting before committing** (`yarn lint`, `yarn format`).
+- **Test at the right level.** For any change at the RAC level or below (including hooks), write the test at the RAC level ideally. If the change lives at a higher level, test at that level.
+- **Move to browser tests when needed.** If a test requires mocking specific browser behavior, consider moving it to the browser run (`yarn test:browser`).
+- **Cover the reported issue.** When fixing a reported issue, add a test that reproduces the specific example given in the issue.
+- **Check whether the test already exists.** Find a home for it near other similar tests.
+- **Check code coverage** to help decide whether a new test adds value — this is subjective.
+- **In unit tests, prefer** fake timers, our test utils, and user event. Aside from those, prefer not mocking other modules, instead, move the test to a higher level.
+- **Combine tests** that share the same setup before an assertion.
+- **Ground test titles in the goal**, not the implementation — double-check they are accurate.
diff --git a/docs/contributing/tooling.md b/docs/contributing/tooling.md
new file mode 100644
index 00000000000..5eeca4509ae
--- /dev/null
+++ b/docs/contributing/tooling.md
@@ -0,0 +1,13 @@
+# Tooling
+
+This repo does **not** use the conventional JS toolchain — reach for these, and don't hand-format code or swap in defaults:
+
+- **Format** — `oxfmt` (`yarn format`), not Prettier. The style is opinionated (single quotes, no bracket spacing → `{foo}`, no trailing commas). Always run the tool rather than formatting by hand.
+- **Lint** — `oxlint` plus repo-local rules, not ESLint. `yarn lint` bundles format-check, type-check, `oxlint`, and Yarn `constraints` (which enforce cross-package dependency versions).
+- **Type-check** — `tsgo` (`yarn check-types`), the native TypeScript compiler — not `tsc`. A `tsc` fallback exists as `yarn check-types:tsc`.
+- **Build** — Parcel driven by `make` (`yarn build`), not plain `tsc`/rollup.
+- **Yarn 4 workspaces** monorepo; use `yarn workspaces foreach` for cross-package operations.
+
+## Storybook
+
+Storybook is the main way to develop and view components: `yarn start` (v3/RAC) and `yarn start:s2` (S2).