Skip to content

feat(core): BottomSheet stacks by open order, replaces on a same-update switch, and BottomSheetSwitcher is deprecated - #7648

Merged
imdreamrunner merged 1 commit into
mainfrom
feat/core-bottom-sheet-open-order
Oct 27, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
feat/core-bottom-sheet-open-order

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

feat(core): BottomSheet stacks by open order, replaces on a same-update switch, and BottomSheetSwitcher is deprecated

Status: the owner's chosen model (C+), implementing current records. The record pull request spec/bottom-sheet-open-order-stacking (#8010) is merged into main (b88081d9f), so this PR is now based on main directly and carries one commit; #8010 recorded open-order stacking, the same-update replace handoff, non-modal top-layer hosting, a switcher flow as one stack level, and the switcher's deprecation as current authority: component:BottomSheet DEC-2 / FR5–FR12 / AR3–AR4 / PR1–PR2, component:BottomSheetSwitcher DEC-3 / DEP-0015 / FR10 (the record stays current, every existing requirement in force). This PR deletes the "Implementation gap" notes those merged records carry for what it implements, adds its suites to verified_by, rewords PR1 and DEC-2's PR1 sentence (the one-stack promise is scoped to copies on the same stack protocol) and adds one clause to the element-jobs passage (a covered sheet's recede is the dialog's transform) — the exact list is under Writing rubric results — and edits architecture:layer-runtime — which describes the runtime shipped on main and so is not touched by the record PR — adding the shipped passages as a records change in this implementation PR, to be approved via /approve-spec here, the way the per-step switcher change did; its approval metadata keeps main's values until that approval is recorded. The other candidates (#6728 model A, #7503 model B, and the AST-044 draft #6042) are superseded by the records. This PR is large (+9,300 / −610 lines against the record base, most of them tests and stories) and deliberately one piece: the owner accepts the single pull request and has waived the request to split it (quoted under Scope).

User need

Mobile product flows drill in: a list opens a detail, the detail opens an editor, a form opens a picker. On touch, each of those steps is a BottomSheet, and the step beneath should stay visible, receded, while the next one is on top — the pattern every mobile platform ships (iOS card presentation, Material bottom sheets, Ionic card modals).

Why can't builders do this today? On main, two open modal sheets are two unrelated native dialogs: both scrims paint, so the page darkens per level; the covered sheet does not recede, so the person cannot tell there is a sheet beneath; a sheet nested in another sheet's content that opens in the same commit as its parent (a deep link, a reload) ends up under its parent in the top layer, because React runs child effects before parent effects; and a parent sheet closed from code leaves a nested child dialog open in the top layer with no box to paint — an invisible modal that blocks the page. Why does that matter? Every team that needs a drill-in either hoists all steps into one BottomSheetSwitcher (which replaces steps, so the covered step is gone), or rebuilds stacking with its own wrappers and z-indexes, each slightly differently. Why does that matter? The design system exists so that a flow's layering, dismissal and focus behave the same everywhere, and this is the one overlay family where it does not.

A second need sits inside the first. Every multi-step flow today — set-up wizards, filters, pickers — hoists its steps into one BottomSheetSwitcher, because that is the only way to get the replace handoff: the next step rises above the current one, a taller current step moves down to meet it, the current step fades after both motions, the scrim never dips, focus lands in the new step and returns to the flow's opener when it closes. Once sheets stack by open order, a flow written as plain sheets (setStep('next') closes one and opens the next in one update) reads as a push plus a pop: the old step recedes while sliding out, two backdrops cross, focus bookkeeping follows the stacking rules. Builders would have to choose between two components with two mental models for one UI. This PR makes the standalone sheet do both, measured identical to the switcher, and deprecates the switcher.

Problem and solution fit

Candidate C is the model the established libraries use (Radix, Headless UI, React Aria, Ariakit, MUI, Base UI, Ark/Zag, Ionic, Mantine): each sheet keeps its own open state; a sheet opened while another is open stacks on top of it; the top sheet is the most recently opened one; nesting in JSX is optional. There is no new component and no new prop. Stacking, and the replace between steps, are behaviour of the component that already exists.

Delta Need it serves
A private, commit-phase registry of presented modal sheets (bottomSheetStack.ts) ranks sheets by open order, breaks same-commit ties by tree order, and reconciles the native top layer The deep-link / reload order bug; "top is the most recently opened"
Covered sheets recede (0.96 / −8px, then 0.92 / −16px) and are blocked The person can see there is a sheet beneath and cannot interact with it
Only the bottom sheet's ::backdrop is visible; a change of scrim owner lands with no transition No darkening per level; a stable dim through push, pop and handoff
Dismissal goes through the shared layer stack; a covered sheet stands down, so the top sheet's own callback and purpose apply Escape / scrim / swipe / platform close pop exactly one, also with nested sheets
Focus returns into the revealed sheet; registry-driven closes never move focus Keyboard and screen-reader users land where they were
A sheet — or a BottomSheetSwitcher flow — rendered in a closing sheet's content leaves with it, and comes back with it if still open; its state is never written for it No invisible stuck modal; the nested sheet's state stays its owner's
A whole BottomSheetSwitcher flow is one level: the active step's dialog is the level, the registry is told when a handoff changes that dialog, and the flow's steps keep clear backdrops when a lower sheet owns the scrim Existing switcher flows take part without changes (component:BottomSheet FR12, component:BottomSheetSwitcher FR10)
Replace: the top sheet closing while one other sheet opens in the same update, from a control inside it, runs the switcher's handoff on the two standalone dialogs A multi-step flow is one sheet per step and one state value
Non-modal sheets (hasScrim={false}) are hosted in the top layer as manual popovers and replace each other the same way; the toast viewport re-enters above them (through modalOutlet, as it does for Dialogs) Every switcher flow, modal or not, has a replacement; the non-modal show() + z-index path AST-027 lists as a leak is closed for the standalone sheet, and app-global toasts stay above it
One registry state per page, shared by every copy of the package on the same stack protocol version, and one stack per document Two bundles or a dev server's duplicate module must not produce two stacks; a copy on another protocol version keeps its own
@deprecated on BottomSheetSwitcher, BottomSheetSwitcherProps and sheetId; docs teach the replacement first The switcher's one job is now the standalone sheet's; spec:AST-017 FR26/FR28

No tagalongs: no visual change to a modal sheet on its own, no new exports. Two files outside BottomSheet/ change: Layer/modalOutlet.ts gains a top-layer-entry notice, an isLatest/group pair for callers whose modals are ordered by something other than supply order (sheets; Dialogs keep the default), and a stale-close guard; Toast/ToastViewport.tsx re-enters the top layer on the notice (no behaviour change for toasts over Dialogs). One current architecture record, layer-runtime.md, gains the sentences for the shipped sheet behaviour. (A lone non-modal sheet changes host — top layer, no page-level z-index, focus return on close while focus is inside; named in the behaviour table and the replace Changeset.)

Current specification

  • component:BottomSheet (packages/core/src/BottomSheet/BottomSheet.spec.md, current; amended on this PR's base): DEC-2 and FR5–FR12 own the stack (open order, tree-order ties, one visible scrim, covered sheets receded and blocked, dismissal to the top sheet only, nested sheets that leave and return with their host, focus on a pop and on a close of the whole stack), the replace handoff and its conditions, non-modal manual-popover hosting, and a switcher flow as one level; AR3–AR4 the inertness and focus contract; PR1–PR2 the one registry per page and one visible backdrop. This PR deletes the gap notes on FR5–FR12, AR3–AR4 and the two verification rows, and adds BottomSheetStacking.test.tsx and BottomSheetReplace.test.tsx to verified_by.
  • component:BottomSheetSwitcher (current; amended on the base): stays current with DEC-1/DEC-2 (each step owns its native dialog), FR1–FR9, AR1–AR3 and the theming anatomy all in force. DEC-3 and DEP-0015 record the lifecycle as deprecated — supported and maintained, docs and templates point to standalone step sheets, no runtime warning, removal only in a future declared minor through a [breaking] change — and FR10 records how a flow takes part in the stack (the active step's dialog is the level and keeps the flow's place across handoffs; covered → receded, blocked, transparent backdrop, dismissal stands down; closing over a lower sheet returns focus into it; a step is never paired in a replace). This PR deletes FR10's gap note and its verification row's, and adds the stacking suite to verified_by.
  • architecture:layer-runtime (current): this PR edits it, as a records change in the implementation pull request, the way the per-step switcher change did. The record describes the runtime shipped on main, so the record pull request leaves it alone and keeps the not-yet-shipped rules as component:BottomSheet FR7/FR13/FR14 with gap notes; once this PR ships them, their shipped form belongs here. It adds three passages — a covered or replaced sheet reports itself absent to the shared dismissal stack; every open scrim sheet and a modal switcher flow's active step supply toast outlets, the latest among sheets following the stack and never raised above a later Dialog; the toast re-entry above an announcing non-modal sheet, with the non-modal Drawer excluded — and removes the scrim switcher from the list of modals toasts stay behind. Its approved_by/approved_at lines keep main's values until an engineering owner's /approve-spec is recorded on this pull request; the maintainer rewrites them after that approval.
  • family:overlay-dismissal (docs/families/overlay-dismissal.md, current) governs dismissal: FR2 (one request affects one surface) and FR3 (platform close requests use the same topmost rule) are what stacked sheets rely on. Both members already register in the shared stack; this PR adds no second registry — a covered or replaced sheet answers isPresent: false so the shared stack picks the right owner.
  • spec:AST-027 (current): z-index stays local; this PR adds no page-level band. Non-modal standalone sheets move from show() to a manual popover in the top layer (FR8), and the page-level 1000 is removed with the move (IR4); the show() fallback below the Popover API floor is page stacking order, the reduced fallback AST-003 DEC-5 describes. AST-027's conformance table still lists "BottomSheet and BottomSheetSwitcher non-modal paths" as a confirmed leak: after this PR that row holds only for the deprecated switcher's own non-modal shell, which component:BottomSheetSwitcher now records as left to removal rather than re-hosting.
  • spec:AST-017 FR26, FR28, FR29, FR29a, FR29b: the deprecation is explicit on the declarations, ships with its replacement and an ordered migration, adds no runtime behaviour, and is removed by a separate [breaking] change in a declared minor.

Public delta and caller burden

Public API: no additions. BottomSheet (isOpen / onOpenChange / hasScrim / finalFocusRef / purpose …) and BottomSheetSwitcher (activeSheet / onActiveSheetChange / hasScrim …) keep every prop, type, default and export. The Core API audit (dist .d.ts + exports map, origin/main vs this branch) shows an identical exports map; the public declaration files differ only in doc comments and the three new @deprecated tags (BottomSheetSwitcher, BottomSheetSwitcherProps, BottomSheet's sheetId); the two new internal declaration files (bottomSheetStack.d.ts, BottomSheetHostContext.d.ts) are unreachable through the exports map.

Release tier — a patch, for the owner to confirm under spec:AST-017 FR25. The Changesets are patch ([feat] ×3, [fix], and the CLI's). The argument, stated plainly:

  • Additive (FR25): no released contract described two presented sheets, a same-update close-and-open of the top sheet, or a flow stacked with other sheets; the stack and the replace handoff give behaviour to states no contract covered. No public prop, type, default or export changes (the dist API audit shows an identical exports map).
  • Implementation-equivalent for a sheet on its own (FR25): a modal sheet on its own behaves as released (panel focus on open, finalFocusRef after close, dismissal by purpose); a non-modal sheet on its own keeps its documented contract (no scrim, no scroll lock, page interactive, dismissal by purpose, focus return) while its host moves from show() + a page-level z-index to the top layer with none — and stays beneath an open outside modal exactly as released.
  • Deprecation (FR28): the switcher keeps working unchanged on its own, with its @deprecated declaration, replacement docs and migrated templates; removal is a future declared minor (FR28/FR31). The one behaviour change to a lone flow — toasts raised from a step become pressable — is a [fix] toward AST-003 FR16/FR18, named in its own Changeset.
  • What would make this a minor instead: if the owner reads any of the three as a change to a released contract (for example, the non-modal host leaving page stacking order, or the stack replacing "two unrelated modals" as a contract rather than an uncovered state), AST-017 FR31 puts it in a declared minor; the Changesets would then carry [breaking] and the records a compatibility note. The author's reading is that none crosses that line; the owner decides the tier before this lands.

Behaviour delta (this is the proposal):

Situation main today This PR
Modal sheet B opens while modal sheet A is open Two unrelated dialogs; A keeps full size; two scrims darken the page twice B stacks on A; A recedes (0.96 / −8px), stays mounted with its state, is blocked; A's backdrop is the one visible scrim, B's is transparent
Three or more open Three scrims Depth 1 recedes a step, depth 2+ share the second step (0.92 / −16px); still one scrim
A and a sheet nested in A's content open in one commit (deep link, reload) Child shows first → parent paints over the child (effects run child-first) Tree order: parent below child, right in the first painted frame
Close the top sheet Focus returns to the element that was active when it opened (often inside A), with a ring only after keyboard input Same opener rule; if the opener is gone, the revealed sheet's panel takes focus. Last sheet: unchanged standalone rules (finalFocusRef / opener)
Close a covered sheet from code Siblings above stay; a child nested in its content stays open invisibly (stuck modal) Siblings above stay open and keep their order; children rendered in its content leave with it and come back with it if their own isOpen is still true (their state is never written); focus stays in the top sheet
Escape / platform close with a sheet nested in another's content and a later sheet opened outside it Would reach the covered nested sheet (the dismissal stack ranks tree depth first) Reaches the top sheet; the covered sheet stands down
The top sheet closes and one other sheet opens in the same update, from a control inside the closing sheet A pop beneath a push: the old sheet recedes while sliding out, backdrops cross, focus follows stacking rules A replace: the new sheet rises above the retained one; a taller retained sheet aligns down to meet it; the retained sheet fades after both motions, inert and hidden from the first frame, and keeps the scrim until the new sheet takes it over with no transition; focus moves into the new sheet; closing the flow returns focus to its first opener. Measured identical to BottomSheetSwitcher
The same close and open driven from a control outside the closing sheet (two unrelated owners) — A pop and a push, each with its own focus return
hasScrim={false} sheet show(), z-index: 1000, independent; focus never returned on close A manual popover in the top layer (show() below the Popover API floor): no backdrop, no scroll lock, no focus steal, page interactive, dismissal by purpose as before; never stacks; replaces another non-modal sheet with the same handoff; returns focus to its opener on close while the person is still in it
BottomSheetSwitcher Supported Supported; deprecated with sheetId, removal in a future minor. A flow used on its own behaves as released; a flow stacked with other sheets is one level of the stack: it registers, recedes and is inert/aria-hidden when covered, carries data-sheet-stack*, stands its focus trap and dismissal down while covered, keeps a transparent backdrop above the scrim owner, and returns focus by the stacking rules (into the revealed sheet, or once for a "Close all")
A modal sheet on its own — Exactly as today: no stack attributes, no transform, same showModal count, same scrim, same focus
A non-modal sheet on its own show() in page stacking order with z-index: 1000; focus never returned on close Top layer as a manual popover, no page-level z-index; focus on open unchanged (panel when the browser moves focus in, otherwise none); returns focus to its opener on close while focus is still inside
Every sheet of a stack closes in one update ("Close all") Each sheet returns focus at depth 0; the bottom sheet's page opener is refused while the others are still modal and the upper openers are in closed dialogs — focus ends on body Focus returns once, to the bottom sheet's finalFocusRef or opener, after the last dialog has closed

Before / after callsites. Stacking needs nothing new:

// before and after — nothing changes at the callsite
<BottomSheet isOpen={isListOpen} onOpenChange={setIsListOpen} label="Orders">…</BottomSheet>
<BottomSheet isOpen={orderId != null} onOpenChange={o => !o && setOrderId(null)} label="Order">…</BottomSheet>

A multi-step flow drops the switcher:

// before (deprecated, still works): one switcher, steps by sheetId
<BottomSheetSwitcher activeSheet={step} onActiveSheetChange={setStep}>
  <BottomSheet sheetId="details" label="Details">…</BottomSheet>
  <BottomSheet sheetId="confirm" label="Confirm">…</BottomSheet>
</BottomSheetSwitcher>

// after: one sheet per step, one state value; moving on closes one and opens the next in one update
const close = own => o => !o && setStep(c => (c === own ? null : c));
<BottomSheet isOpen={step === 'details'} onOpenChange={close('details')} label="Details">…</BottomSheet>
<BottomSheet isOpen={step === 'confirm'} onOpenChange={close('confirm')} label="Confirm">…</BottomSheet>

Caller burden: nothing new to learn to get stacking. For a flow: one state value naming the open step, and knowing that a same-update close and open of the top sheet, made from inside it, is a replace (the "Replacing a sheet" paragraph of the Stacking docs). Compatibility: three [feat] patch Changesets for Core (stacking, replace, deprecation) and one for the CLI (the two templates). No consumer source change is required; a consumer who relied on two open sheets each painting its own scrim and not receding sees the new behaviour. A switcher flow on its own changes nothing at runtime; one stacked with other sheets takes part in the stack as one level (above). Editors show the @deprecated tags. Theming: no new theme target; the recede uses the spacing token for its offset and the standard duration/easing tokens. Accessibility: covered sheets are blocked by the browser's own modal-dialog rules (the top sheet's showModal() makes everything outside it inert, lower dialogs included); a covered or replaced sheet that contains no presented sheet is additionally marked inert + aria-hidden; one that contains the top sheet is left alone, because inert on it would reach the top sheet.

Deprecation and migration

BottomSheetSwitcher, BottomSheetSwitcherProps and BottomSheet's sheetId carry @deprecated JSDoc naming the replacement; BottomSheetSwitcher.doc.mjs leads with "Deprecated: …", ships the replacement example as its only example (the switcher example is removed); BottomSheet.doc.mjs teaches one state value and standalone sheets everywhere it used to send multi-step flows to the switcher. There is no runtime warning: the repository's whole-component deprecations warn through declarations only (the 0.6 deprecations, removed in #6118, followed the same pattern), per spec:AST-017 FR29/FR29d. Migration, per flow:

  1. Replace the activeSheet / onActiveSheetChange pair with one step state value.
  2. Give each step isOpen={step === 'id'} and onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))} in place of sheetId="id" — the guard keeps a step that closes because the person moved on from clearing the step they chose.
  3. Remove the BottomSheetSwitcher wrapper, moving its hasScrim onto every step.
  4. Keep the step buttons as they were (setStep('next')).
  5. The handoff runs when the move is made from inside the open step (its own Continue or Back, or code while focus is in it). A step changed from a control outside the flow's sheets — a page control beside a hasScrim={false} flow, or code after focus has left the step — closes and opens with the sheets' own push and pop motion, where the switcher handed off on any activeSheet change; move such a control into the step, or accept the motion.

The migration is component:BottomSheetSwitcher DEP-0015's, step for step. Every switcher use has the replacement (spec:AST-017 FR28): modal and hasScrim={false} flows migrate the same way, because non-modal sheets replace too (below). Maintained surfaces teach only the replacement (FR29b/FR29c): BottomSheet.doc.mjs (both best-practice lists, the hasScrim doc, the Stacking section), BottomSheetSwitcher.doc.mjs (replacement example only; best practices say how to migrate), the two CLI block templates (BottomSheetSwitcherShowcase, BottomSheetSwitcherReviewFlow: rewritten as standalone steps; ids and files keep their names because a released template id is part of the CLI's JSON contract, and their published ShadCN registry identities are pinned too — exampleFor: 'BottomSheetSwitcher' with alsoExampleFor: ['BottomSheet'] and registry: {slug} keep showcases/bottom-sheet-switcher/default and examples/bottom-sheet-switcher/review-flow exactly, so the reviewed route lock is untouched and both blocks are also listed on the BottomSheet page), Lab MobileTokenizer (its one-step switcher is a standalone BottomSheet with isOpen; behaviour unchanged, Lab is private so no Changeset), the BottomSheetStacking and LayerDismissal stories (standalone steps), and BottomSheetSwitcher.stories.tsx, which stays as the deprecated component's own documentation, titled "(deprecated)" with a description pointing at the BottomSheetReplace stories that mirror it. The v0.5.0 blog post and the current records that name the switcher as a dismissal-stack member are left as they are (history, and still true).

Design and alternatives

Chosen model. Every modal sheet (and every modal switcher flow) keeps its own <dialog> and calls showModal() on it, so the browser's top layer already paints later opens above earlier ones and already blocks the dialogs beneath the topmost modal one. What the browser cannot know is that these dialogs belong to one stack. A small private registry fills that in:

  • Registration happens only in the commit phase (useLayoutEffect), for as long as a sheet is presented (including its exit animation). A render that never commits — a transition that suspends, a discarded concurrent render — registers nothing.
  • Order is open order. Each open cycle takes a rank (batch, seq). A batch is one React commit: hosts seal the batch in a passive effect, and React runs every pending passive effect before the next commit's layout effects (synchronously after a sync commit). Later batch is higher. Within one batch there is no open order, so document order of the dialog elements decides — a sheet before the sheets rendered in its content, siblings as written. A sheet that closes and reopens takes a new rank and comes back on top.
  • The native top layer is reconciled, not trusted. The registry remembers the order it called showModal() in; where the ranked order diverges (a deep link whose inner sheet registered first), it closes the dialogs from the first divergence up and shows them again in rank order — inside the same commit, before paint. A depth-3 deep link is therefore correct in the first painted frame, under flushSync, before any microtask (tested).
  • Views, not commands. The registry hands each host a view — top / covered / depth / owns-scrim / blocked — through a layout-effect callback into React state, so hosts render the recede transform, inert, data attributes and backdrop opacity declaratively and the first frame after the layout phase already has them. SSR renders no stack state; hydration matches.
  • A sheet rendered in another sheet's content is part of it (BottomSheetHostContext): it is presented only while its host is open: when the host closes, it leaves with the host without its isOpen being written, and if it is still open when the host reopens it is presented again, on top. This is what prevents an invisible stuck modal, and it is also what makes a row-owned detail sheet work with no state hoisting.

The replace. The registry pairs a move the person makes from one sheet to the next:

  1. exactly one sheet closes and exactly one sheet opens in the same update (one React commit);
  2. both are standalone sheets of one document and one modality — both modal, or both non-modal (a switcher flow replaces its own steps and is never paired; a modal and a non-modal sheet never pair);
  3. the closing sheet was the top sheet of its stack once the opening sheet is set aside — a covered sheet closing while a sheet opens is a pop beneath a push;
  4. neither dialog contains the other — a sheet in another sheet's content closes with it, it does not replace it;
  5. the interaction came from inside the closing sheet: the control the person last pressed is in the closing sheet's dialog, or nowhere (a control that left with the step). The press is the target of the last pointerdown, click or keydown in the document, recorded by one capture-phase listener per document, and counts only while fresh (one second: the handler a press runs flips the sheets in the same task). With no fresh press (a step change after a save resolves) the actor is the element focused when the update's first flip arrived, read before any dialog of the update is shown so the opening sheet's own showModal() cannot have moved focus yet. A close and an open driven from a control outside the closing sheet are two unrelated owners acting in one update: they keep their own openers and their own push and pop motion.

Condition 5 is the answer to the one case an independent verification of the registry found wrong (two owners in one tree; a page control closes X's sheet and opens Y's; B adopted A's opener and closing B returned focus to X's control). Every real step flow passes it by construction: Continue and Back live in the step being left, and a step change made by code after a delay (a save that resolves) passes too, because under a modal sheet focus cannot be anywhere but inside it. The press, rather than focus alone, is read because focus says nothing in two real cases: a non-modal sheet never takes focus, and some browsers do not focus a clicked button, so under either the click's target is the only evidence of where the person acted. The one-second window keeps a stale press (an Escape key pressed on a sheet that has since closed) from vetoing a later, unrelated step change. The handoff itself is the switcher's state machine, ported into the registry and driven by the panels' existing motion reports: covered → (entering starts; a taller retained sheet gets aligning with the measured offset) → both complete → fading → fade complete → the replaced dialog closes and the replacing sheet takes the scrim in the same frame. The replaced sheet is inert/aria-hidden at once, stands down from the dismissal stack, and lends its opener to the replacer. A reversal mid-handoff swaps the roles; a dismissal mid-handoff drops the retained sheet at once, as the switcher does. The host holds its open pose for one synchronous re-render when it closes, until the registry has paired or not: a first-rendered exit pose, read back by the panel's own style query, started the exit transition, and the reversal into the retained pose then snapped the panel to rest.

Alternatives considered and why not:

  • An ordered list on the switcher (candidate A, feat(core): BottomSheetSwitcher ordered activeSheets path (AST-044) #6728): explicit and easy to reason about, but it changes the switcher's public contract, requires every step of a flow to live under one switcher, and leaves standalone sheets (and sheets from inside Dialogs, Selectors, row components) out of the model.
  • One shared dialog with tree-order levels (candidate B, feat(core): BottomSheetStack — stack bottom sheets in one dialog #7503): one scrim and one modal boundary for free, and tree order is deterministic — but it needs a new component, portals every panel out of where it is written (CSS inheritance and gesture bubbling caveats), and tree order means the top sheet is not the most recently opened one, which is the opposite of what the libraries above do and of what a person who just opened a sheet expects.
  • Deferring showModal() to the end of the commit instead of reconciling: there is no synchronous "end of all layout effects" hook, and requestAnimationFrame / microtasks run after the first synchronous frame check the requirement asks for. The reconcile only re-shows dialogs for same-commit nested opens (invisible, pre-paint); normal opens append.
  • Marking every covered sheet inert: wrong for a covered sheet that contains the top sheet in its DOM (inert would reach the top sheet). Hence: native modal blocking always, explicit inert only when safe.
  • Cross-fading backdrops when the scrim owner closes under others: dips to ~75% dim halfway. Hence: the new owner's backdrop appears instantly while the old one fades.
  • A stacked prop or a BottomSheetStack wrapper to opt in: rejected by requirement 1; a lone sheet is unaffected, so there is nothing to opt into.
  • A <BottomSheetStack> provider, public or internal: the coordinated thing (the document's top layer) is wider than any React tree. A context cannot reach a second React root, a second package copy or a widget mounted outside it, and in every one of those cases the module registry does the right thing or no worse than today; the repository already uses module singletons for document-wide facts (layerStack, modalOutlet, useScrollLock). The only scoping a provider adds is a "same level" boundary, and it adds it only if products wrap unrelated regions in separate providers — candidate B's problem back again. Condition 5 covers that case without a wrapper.
  • Calling a nested sheet's onOpenChange(false) when its host closes: the earlier revisions did this from an Effect reacting to the host's state, which is a component writing a consumer's state with no external system to synchronise (architecture:react-component-runtime INV2/INV3), and there is no event path for it either — a host learns it is closing only through its own props. Dropped: the gate isOpen && isHostOpen already keeps the nested dialog out of the top layer while the host is closed; the nested sheet leaves with its host, its isOpen stays the consumer's, and if it is still open when the host reopens it is presented again, on top — the same contract as a Dialog rendered inside a closed Dialog. The docs' Stacking section says so and tells the consumer to close it when the flow is done.
  • For the replace, a prop on BottomSheet (replaces / a flow key): unnecessary once the rule above is unambiguous; it would make the author say twice what one state value already says. Kept in reserve.
  • A helper hook or component over standalone sheets: it would need the same registry-level facts (who is top, who owns the scrim, the top-layer order) — a second coordinator — and cannot stop per-step dialogs fighting on backdrops and focus without the registry's cooperation anyway.
  • Opener adoption only when the replacer had none (the verification's minimum for the unrelated-owners case): under a modal sheet the replacer's own opener is always inside the closing sheet or nowhere, so the guard would never fire and the visual replace between unrelated owners would remain.
  • Pairing by React tree position (siblings only, a common parent): a flow with steps wrapped in their own components has neither, and unrelated owners at the app root have both.
  • Reading document.activeElement at pairing time: wrong on a reversal, because the returning sheet's layout effect runs first in tree order and its showModal() moves focus before the pairing is seen. Hence the actor is captured at the update's first flip.
  • Letting the replacer own the scrim from the start: its backdrop paints above and dims the retained panel. Cross-fading the two backdrops in a replace: dips to ~44% of the dim halfway. A 0.01s transition for scrim handoffs: still paints one frame at the old value; the pin is 0s, inline, held for two animation frames.
  • Non-modal replace with a z-index: 1001 on the entering show() dialog: the first version did this; spec:AST-027 rules it out. Hence the top layer: a non-modal sheet's <dialog> carries popover="manual" and is shown with showPopover() (as Core Drawer already does for hasScrim={false}), so the browser orders the entering step above the retained one with no page-level band. A manual popover has no light dismiss, no backdrop and no scroll side effects; its one difference from show() is the browser's focusing step, which lands on the scroll region's focusable group (and draws a ring) where show() landed on the panel (tabindex="-1", no ring), so when the browser moves focus into the sheet the host puts it on the panel and a lone non-modal sheet focuses exactly as before (verified in Chromium: same target, outline: none, no outlined element; a jsdom test stubs the browser's focus move). The non-modal contract is otherwise as released: page interactive and scrollable, no scroll lock, no focus steal on a lone open, dismissal by purpose (Escape and swipe) through the shared stack. In a replace the entering step takes focus explicitly (Chromium's showPopover() happens to move focus into it, Safari's does not; the registry does not depend on either), so the keyboard stays in the flow and the final close finds focus inside. One addition: the sheet records its opener on show (as the modal path does) and returns focus to it when it closes while the person is still in the sheet — read at the start of the close, before the browser's focus fixup for the exiting sheet's inert runs — and leaves focus alone when the person had moved on to the live page. An explicit finalFocusRef keeps its released contract for a sheet closing on its own or last: it is focused wherever focus was, modal or not. When a sheet closes with a modal sheet still open beneath it (new in this PR), focus returns into that sheet — to the finalFocusRef element if it is inside it, else the revealed panel — because the page is still inert and a target on it cannot take focus; the prop's JSDoc and the Stacking docs say so; hidePopover() itself never moves focus, and main's show()/close() pair never restored it either (the inert blur came first). Because the sheet now enters the top layer, the toast viewport (a popover="manual" host shown earlier) would paint beneath it; the sheet's show path therefore calls modalOutlet's notifyTopLayerEntry(), the same subscription ToastViewport already uses to follow Dialogs, and the viewport re-enters the top layer in place (hide + show, focus inside it kept) — it does not move into the sheet, because a non-modal sheet blocks nothing and is not an outlet (architecture:layer-runtime's ToastViewport paragraph records this). Tooltips, popovers and menus opened from the sheet are shown after it and already order above it. The non-modal sheet's page-level z-index: 1000 is removed with the move (spec:AST-027 IR4). Below the Popover API floor (Tier 2 in the browser support guide: Chrome 114+, Safari 17+, Firefox 125+; the Popover API is the guide's one Tier 2 requirement) the attribute is inert and the sheet falls back to show() in page stacking order — the documented reduced fallback of spec:AST-003 DEC-5: the sheet opens, replaces and dismisses, but makes no promise over later page content and no z-index ladder compensates; nothing in the fallback path reads the removed value. In the registry non-modal sheets are a group of their own per document: they never stack, recede or own a scrim, and only replace each other; a non-modal and a modal sheet never pair. Leaving non-modal flows on the switcher was the alternative: it leaves the deprecation with a gap (spec:AST-017 FR28) and the AST-027 leak open.

Decisions on the open items

  1. Covered sheets: stay mounted with their state; blocked by the native modal (verified in Chromium: not hit-testable, not focusable) plus explicit inert/aria-hidden when the covered dialog contains no presented sheet; recede 0.96/−8px at depth 1 and 0.92/−16px from depth 2, with transform-origin: 50% 0 on the positioner; only sheets count (a Core Dialog over a sheet does not recede it).
  2. Scrim: only the bottom open sheet's backdrop is visible (--_sheet-scrim-opacity: 1), every upper sheet's is 0; a drag on an upper sheet never thins the visible scrim; when the owner closes under others, the next owner's backdrop appears with transition-duration: 0s while the old one fades out on its usual linear curve.
  3. Closing a covered sheet from code: sheets rendered in its content leave with it (they are presented only while their host is open; their own isOpen is never written, so one still open returns when its host does); siblings above that are not in its content stay open and keep their order, the closing sheet slides out from beneath them. No stuck or orphaned sheets. Prior art: native <dialog>s are independent (kept for siblings); Radix unmounts nested children (kept for nested content, through presentation rather than an unmount); Zag/UIKit close everything above (not adopted — a sibling's state belongs to its owner).
  4. Concurrent React: order state is written only in layout effects; StrictMode keeps one entry per sheet (ranks persist across the replay by host identity); a transition that never commits has no effect; a Suspense-hidden or <Activity mode="hidden"> sheet leaves the stack and closes its dialog (a modal dialog with no box would block the page), and on reveal is presented again on top; SSR and hydration markup match (stack state is never rendered on the server).
  5. Non-modal sheets (hasScrim={false}): never stack — they neither make a modal sheet recede nor are receded by one — but they are hosted in the top layer as manual popovers (show() below the Popover API floor), replace each other with the same handoff, and return focus to their opener on close while focus is inside. No scrim, no scroll lock; the page stays live.

Caveats (documented in the Stacking section):

  • Only sheets of the same document stack (an iframe is its own stack).
  • A Core Dialog/AlertDialog/Popover opened from a sheet is not a level: it paints above the sheet without receding it; a sheet opened from inside it paints above the Dialog and stacks over the sheet beneath.
  • A Dialog opened in the same commit as the sheet that contains it still shows before the sheet (that is Dialog's own ordering; this PR fixes same-commit order for sheets only).
  • A deep link re-shows inner dialogs during the commit (invisible, before paint); a close event fires for each re-show.
  • A covered switcher's focus trap stands down while a non-contained sheet covers it, and resumes on reveal.

Registry hardening

Item What
One registry per page per stack protocol version The state lives on globalThis under Symbol.for('astryx.bottomSheetStack.v<N>'), the key naming the protocol version, with the shape as a collision guard; two copies of the package on one page (two bundles, a dev server serving one file under two URLs, a micro-frontend) that speak the same protocol share one stack; a copy on another version finds no state under its key and keeps a stack of its own (PR1). Everything the reconcile reads is in that state — the entries, each document's shown order (keyed by Document, per modal/non-modal group) and which documents already carry the press listener — so nothing is per copy
Per-document stacks Entries are keyed by ownerDocument; a sheet portaled into another document has its own stack, top layer and focus
Closes never move focus closeDialogKeepingFocus: close() returns focus to the element recorded at showModal(); every registry-driven close (a re-show that repairs order, a replaced sheet leaving) and every close beneath an open sheet restores the active element if the browser moved it
Scrim changes hands with no transition writeScrimOpacity pins --_sheet-scrim-duration: 0s inline for two animation frames (two nested requestAnimationFrames) on both the new owner (appears) and the old owner (leaves); the stylesheet reads the pin in the default and the reduced-motion declaration; the pending restore is cancelled on unmount
Drops wait for the pairing A replacing sheet that closes drops the sheet it was replacing, as a switcher drops a retained step when the flow is left — but not before the update's other flip has arrived: the retained sheet may be the one coming back (Continue pressed during a Back handoff), in which case the roles swap. Pending drops are flushed once the batch has both flips, or at the seal
Presentation derived in render A sheet stays presented from its open until its exit motion completes (or a replace finishes); the open flip is derived during render with the previous-value pattern (no Effect sets state for it), the close flip is the host's own motion event
"Close all" returns focus once Two or more modal sheets of one stack closing in one update with nothing opening and none left open form a group; focus returns to the bottom-most member's target after the last dialog has closed
Test reset Also clears the seal flag
Follow-up, out of scope The shared dismissal stack (layerStack) has the same two-copies problem

Evidence

Review findings resolved

Every finding of the automated reviews on this work (the stacking review and the replace-prototype review), with the fix and the test that pins it.

Finding Fix Test
Escape order with nested sheets. With a sheet rendered in another sheet's content and a later sheet opened outside it, Escape closed the covered nested sheet and the top sheet declined a platform close, because the shared dismissal stack ranks tree depth first. A covered sheet (and a covered switcher flow) answers isPresent: false to the dismissal stack while it is covered, so the press and the cancel request go to the top sheet, as the Stacking docs say. The dismissal stack itself is untouched; the sheet stack tells it who is on screen. R5 "Escape and a platform close reach the top sheet, not a covered sheet nested deeper in the tree"
The nested-close effect called the consumer's onOpenChange(false) on mount and on every rerender (twice under StrictMode), so a host that opened after its child wiped the child's open state. First narrowed to once per transition; then, on the follow-up finding below, removed. R7 "a sheet in a closed sheet's content keeps its state and presents when that sheet opens" (the test the review asked for; it replaces the misleading one)
Focus pulled out of the active sheet by close(). Closing a modal dialog returns focus to the element recorded at showModal(); the reconcile's close + re-show and a covered sheet closing from code could pull focus out of the sheet on top. closeDialogKeepingFocus: every registry-driven close and every close beneath an open sheet restores the active element if the browser moved it; a sheet closing beneath others never runs its own focus return. R8 "closing a covered sheet from code leaves focus in the top sheet" (emulates the browser's focus return in jsdom)
Scrim handoff dip / double dim. When the scrim owner closed under others the new owner's backdrop appeared with transition-duration: 0.01s, which still paints one frame at the old value, and the old owner's backdrop faded over 410 ms underneath it: measured page dim 0.5 → 0.74 → 0.5. Under prefers-reduced-motion the stylesheet's literal 0.01s overrode the pin. A change of scrim owner is written with an inline --_sheet-scrim-duration: 0s pin that stays for two animation frames (two nested requestAnimationFrames; cancelled on unmount), on both sides: the new owner's backdrop appears at once and the old owner's leaves at once (isScrimTakenOver in the view). The stylesheet reads the pin in both the default and the reduced-motion declaration. Measured: dim constant at 0.5 through the handoff, with and without reduced motion. R6 "hands the scrim over at once when the visible-backdrop sheet closes under others" (records the pin at write time), "a sheet closing alone fades its scrim out; no pin is applied"
One registry per page, per document (from an independent verification of the registry design). Two copies of the package on one page (two bundles, a dev server serving one file under two URLs) each had their own registry: two scrims, no recede, wrong Escape target. The registry state lives on globalThis under Symbol.for('astryx.bottomSheetStack') with a shape number; a copy that finds a state of another shape keeps its own. Entries are keyed by ownerDocument, so a sheet portaled into another document is its own stack with that document's top layer and focus. The test reset also clears the seal flag. The shared dismissal stack (layerStack) has the same duplication problem and is a follow-up outside this PR. R13 "a second copy of the module shares the same state", "a copy that finds a state of another shape keeps its own", "sheets in another document form their own stack"
Non-modal replace ordered two show() dialogs with a page-level z-index: 1001 (AST-027). First taken out of the replace; then, on the deprecation finding below, hosted in the top layer as a manual popover so non-modal sheets replace with no page band. "non-modal sheets are manual popovers that replace each other without stacking"; "a non-modal reversal re-shows the returning sheet above the other"; "a non-modal sheet never pairs with a modal one"; "falls back to show() and still replaces below the Popover API support floor"; BottomSheet.test "opens non-modally: a manual popover…" and "falls back to show()…"
BottomSheet.doc.mjs still sent multi-step flows to the switcher (the hasScrim doc, both descriptions, a best practice, the example). All now teach one state value and standalone sheets; the example is rewritten; the switcher's own surfaces carry the @deprecated wording. check:knowledge
setIsCloseSettled(false) reset in a layout effect with no external system. The flag is keyed to the close it settles: every change of isOpen resets it during render (the previous-value pattern), and only the layout effect that follows the registry's answer sets it. existing handoff tests (a wrong pose would snap the retained panel)
The frames writeScrimInstantly scheduled were not cancelled on unmount. The scrim write is writeScrimOpacity in the registry with a per-host pending handle; hosts cancel it in an effect cleanup. R6 scrim tests
Reversal mid-handoff kept the old step on screen ~400 ms (Continue during a Back handoff, steps in flow order): setBottomSheetOpen dropped the replaced sheet when its replacer closed, before the returning sheet's open flip of the same update arrived, so the isEnteringInPlace path never ran. The drop waits for the batch's pairing: pending drops are flushed once both flips of the update are in (or at the seal), and detectReplace swaps the roles instead of dropping when the sheet a closing replacer was replacing is the one coming back. "reversals with the steps written in flow order / reverse order" × (Back during a Continue handoff, Continue during a Back handoff, a double reversal) — three of the six fail on the previous head; Chromium reversal rows in the tables below
The deprecation left hasScrim={false} flows without a replacement (AST-017 FR28). Non-modal sheets replace (top-layer manual popover, above). The non-modal suite above; the No-scrim rows in the tables below
Docs, templates, stories and Lab still taught the switcher (AST-017 FR29b/FR29c). All migrated; see "Deprecation and migration". check:knowledge; CLI template tests; Lab MobileTokenizer tests unchanged and passing; storybook:build
onOpenChange(false) in an Effect reacting to host state, with no external system named (react-component-runtime INV3, INV2). Removed; a nested sheet's state is the consumer's (design note above). R7 "takes the sheets rendered in its content with it, leaving their state to their owners", "a nested sheet still open when its host reopens is presented again, on top", "never writes a nested sheet's state: no onOpenChange on mount, rerender, StrictMode replay, or host close"
CI: ShadCN registry identities changed (registry-contract, then test, docsite lanes and the Vercel build): the template rewrite moved exampleFor and renamed the docs, which renamed showcase-bottom-sheet-switcher-default and moved examples/bottom-sheet-switcher/review-flow. Both templates keep exampleFor: 'BottomSheetSwitcher' (+ alsoExampleFor: ['BottomSheet']) and pin the leaf with registry: {slug: 'default'} / {slug: 'review-flow'}; zero route changes, lock not refreshed. generate-data (canary and latest) passes the lock check; verify-production-gate, verify-full-catalog (1030 preview items, 909 hidden production items), the hidden-production discovery test and the docsite build pass locally
Focus regression for a lone non-modal sheet: Chromium's showPopover() focuses the scroll region's group (2 px ring) where show() focused the panel. The host puts focus on the panel when the browser moved it into the sheet (design note above). BottomSheet.test "focuses the panel, as show() did, when the browser focuses the popover on show" and "leaves focus alone when the browser does not move it on show"; Chromium keyboard-open check: lone NoScrim story main vs branch both div.astryx-bottom-sheet, outline: none, no outlined element; no-scrim replace step 2: switcher → body, standalone → the new step's panel, no ring
Presentation latch in a useLayoutEffect that only set state (react-component-runtime INV3). Derived during render with the previous-value pattern; the Effect is gone. The whole BottomSheet suite (exit animations, Suspense/Activity hide and reveal, replace handoffs) unchanged and green
Lone non-modal sheet lost its focus return after the move to hidePopover(). The non-modal path records the opener on show too, and the close returns focus to it when focus was still inside the sheet when the close began (read in the close's layout effect, before inert's focus fixup); focus the person took elsewhere stays. Measured on main first: its show()/close() never returned focus either (the exiting shell's inert blurs the panel before close()), so this is a fix over main, consistent with Core Drawer's non-modal path. BottomSheet.test "returns focus to its opener on close when the person was still in the sheet", "leaves focus where the person took it when the sheet closes from the page"; Replace "a non-modal flow returns focus to its opener when its last step closes"; Chromium vs main: lone NoScrim Escape and swipe, flow Cancel / Escape / swipe / final Done — main → body in all six, branch → the opener in all six
Two package copies across documents: groupKeys, nextGroupId and the press-listener set were per module copy while shownOrder was shared, so two copies meeting different documents both derived 0:modal and one reconcile hid the other's sheet. The shown order is keyed by Document (per modal/non-modal group) inside the shared state, and the listened-documents set lives there too; no per-copy state remains. R13 "two copies, one presenting in an iframe document and one in the main document, keep both sheets shown" (fails on the previous head with show:framed, hide:framed, show:main; passes now with both shown)
Docs and changesets vs code (nested sheets "close" vs leave-and-return; the actor rule as "the focused control"; the CLI changeset's listing). Every surface brought in line (see the consistency sweep in this description's history). check:knowledge; no test
finalFocusRef skipped on a non-modal close when focus had left the sheet (the "only while focus is inside" rule covered it too). The rule applies to the recorded opener only; for a sheet closing on its own or last, finalFocusRef is unconditional, exactly as on main, on both paths (`finalFocusRef ?? (modal
The deprecated switcher still shipped copyable examples (AST-017 FR29d): the "Three-step flow (deprecated switcher)" entry in BottomSheetSwitcher.doc.mjs and the @example on the BottomSheetSwitcher function. Both removed; the @deprecated tag keeps the replacement example, the doc's only example is the replacement, and its best practices now say how to migrate instead of how to use the switcher. No other copyable switcher example remains in .doc.mjs files, CLI docs or templates (the two templates are standalone steps; the switcher's own tests and the deprecated stories stay as its documentation). check:knowledge; docsite build
Toasts painted under an open non-modal sheet: the sheet's showPopover() put it in the top layer after the toast viewport, so a toast raised while it was open (or before) hit-tested to the sheet. modalOutlet notifies a top-layer entry after the sheet's showPopover(); ToastViewport.place() re-enters the viewport in place on that notice (the existing subscription; a non-modal sheet is not an outlet, so the viewport does not move). ToastViewport.modalHost.test "re-enters the top layer in place when a non-modal sheet is shown after it" (hide + show after the sheet's own show, parent unchanged, a toast raised inside the sheet lands in the same viewport, closing the sheet re-enters nothing) and "keeps focus inside the viewport across a re-entry"; Chromium hit-test on the new Non-modal over a live page story — toast raised before the sheet opens, toast raised from the sheet while open: both hit the toast after the fix (both hit the sheet on the previous head); tooltip and popover opened from the sheet hit themselves; WebKit agrees
z-index: 1000 on the non-modal shell (AST-027 IR4). Removed; the show() fallback is page stacking order, as DEC-5 describes. The switcher's own non-modal shell keeps its value: that path is deprecated and not re-hosted here (AST-027's table already lists it). No fallback code reads it; the show() fallback test still replaces
A switcher flow stacked over a sheet dropped focus to <body> when it closed after its opener had gone: React runs the registration effect's cleanup (same isFlowVisible dependency) before the close effect, so the flow had already left the registry — getBottomSheetBeneath was null and the depth read the reset view. The registration cleanup records the flow's depth and the sheet beneath it before unregistering; the close effect reads that record. The standalone sheet does not have the ordering problem: its close runs from the panel's motion-complete event while the sheet is still presented and registered. R8 "a switcher flow closing over a sheet focuses that sheet's panel when the opener is gone" (one-step flow opened from a button that unmounts; close over a still-open sheet) — fails on the previous head (focus on body), passes now; Replace "after a reversal, closing the flow still returns focus to the flow's opener" added while validating the Safari script
Safari: a no-scrim replace left focus on <body>, so the flow's final close could not return it to the opener (real Safari 27.0.1 run of the WebDriver check; Chromium's showPopover() had been moving focus into the entering step, Safari's does not). The registry moves focus into the replacing sheet explicitly once its dialog is shown (focus('replace'): the panel, or a data-autofocus descendant), whatever the native host did, when the pairing is a replace — i.e. the person was in the replaced step. A lone non-modal sheet still takes no focus on open; two unrelated owners swapped from the page still leave focus on the page. Every other focus move (modal open, reveal, switcher step, flow close) was already explicit; none relied on the browser. Replace "with a showPopover() that does not move focus (Safari)": replace → entering step's panel and final close → opener; reversal → the returning step; two non-modal owners from a page control → focus stays on the page. The first two fail on the previous registry. Safari runs 2 and 3: both no-scrim focus rows pass (table below)
"Close all" left focus on <body>: with several sheets closing in one update each returned focus at depth 0 — the bottom sheet's page opener refused while the upper dialogs were still modal, the upper openers inside dialogs already closed (the exits complete bottom-first, in tree order of the transitionend events). main behaves the same, but the Stacking docs promise a return to the opener. The registry seals a closing group at the batch's end when two or more modal sheets of one stack close together, nothing opens, and no sheet of the stack stays open; each member closes keeping focus and reports; when the last report lands, focus returns once to the bottom-most member's finalFocusRef or opener. A single close, a batch that also opens (the replace), a batch leaving sheets open (pops return into the revealed sheet), and non-modal sheets are not groups; a modal sheet closing with a non-modal one is not a group either. The switcher reads its membership in its registration cleanup, like its depth. R8 "closing every sheet in one update": depth-3 standalone → bottom opener; finalFocusRef preferred; switcher flow on top closing with the sheet beneath → bottom opener; non-modal pair keeps its own rule; modal + non-modal is not a group — the first and third fail on the previous head under an emulation of the browser's focus refusals (closed dialog, outside every modal). Chromium and WebKit: Orders drill-in "Close all" from depth 3 → "Open orders" (previously body)
Description and Changesets vs code (an independent consistency check): the deprecation text overclaimed "nothing changes at runtime" for the switcher; the commit message still described the earlier non-modal and nested-sheet behaviour; this description contradicted itself on non-modal sheets (condition 2, open item 11), on the switcher example, on records and files touched, and on the size; finalFocusRef was described as unconditional on every close although a pop with a sheet beneath returns into that sheet; evidence was cited by hashes no branch reaches and a harness not in the tree. Every surface says what the code does: a switcher flow on its own behaves as released, a stacked one is a stack level (changeset, .doc.mjs, JSDoc, this description); the commit message is rewritten; the finalFocusRef JSDoc and prop doc state the stacked rule; the migration recipe uses the guarded setStep everywhere (changesets, docs, templates, stories, this description); the pin is "two animation frames" everywhere; runs are described by what they ran against; the Safari harness is published in its own pull request (#8297, chore/bottom-sheet-safari-harness) rather than internal/scripts/bottom-sheet-safari-check.mjs (the directory for one-off automation scripts, beside the Crowdin screenshot uploader). Also, a glued two-in-one evidence row inherited from main in BottomSheet.spec.md's evidence table is split back into two rows (no content change). R8 "a popped sheet's finalFocusRef outside the revealed sheet yields to that sheet's panel; inside it, it is focused"; check:knowledge, check:sync
Base and authority (every review): no current record admitted stacked sheets, the replace rule or the deprecation. The record pull request this PR is based on records all three as current authority with the owner as approver; this PR deletes its gap notes. check:knowledge on both heads
Per-step switcher dialogs landed on main (DEC-1; this PR was written against the shared dialog). Reconciled as described under "Reconciliation with the per-step switcher dialogs": the active step's dialog is the level, a tree anchor keeps same-commit ties stable across handoffs, the registry is told when the host changes, the flow's scrim is multiplied by the stack's, step dialogs carry the stack state. R14 (5 tests); Chromium switcher-over / switcher-under / alone checks above
Four inline comments re-anchored onto this head from earlier revisions (the Showcase template's registry slug; Escape on a lone hasScrim={false} sheet leaving focus on body; a toast painting beneath a non-modal sheet; "Close all" ending on body). Each was fixed on the revision after it was raised and is verified on this head: the Showcase doc carries registry: {slug: 'default'} (route lock unchanged, 783 preview routes verified); the standalone host captures the opener before the non-modal branch and returns to it while focus is inside (Chromium: Escape on the lone NoScrim sheet → "Set up notifications"); showSheetHostNonModal notifies the modal-outlet registry and the toast viewport re-enters the top layer (Chromium: the toast overlaps the sheet and is what the pointer hits; focus stays in the sheet); closes of a whole stack go through the registry's closing group and return focus once after the last dialog has closed (Chromium: Close all from depth 3 → "Open orders"). Chromium checks on this head (below); R8, R13, BottomSheet.test non-modal focus tests, ToastViewport.modalHost tests
The replacement promised the handoff for every flow while FR10 runs it only for a move made from inside the closing sheet. The condition is stated on every surface that promises the replacement: the deprecation Changeset, BottomSheetSwitcher.doc.mjs (both descriptions and the summary), both @deprecated JSDoc blocks, and the records (DEP-0015 replacement + migration step 5, DEC-3). The BottomSheet docs' "Replacing a sheet" already stated it. —
Story ids moved with the "(deprecated)" title, so BottomSheetNativeModal.a11y.browser.spec.ts no longer found core-bottomsheetswitcher--*. id: 'core-bottomsheetswitcher' on the stories' meta keeps every id as it was (browser spec, docs, links); the Safari harness's dead -deprecated candidates removed. Storybook index on this head has core-bottomsheetswitcher--hug-content; the Chromium checks drive the switcher stories by those ids
CLI search goldens drifted (astryx search close 44 → 45 with BottomSheet; icon set 366 → 367). The new BottomSheet docs are the intended change ("close" and "set" now match the Stacking text), so the goldens are regenerated in-branch per the test file's phase-2 instructions; the diff is the BottomSheet row and the two counts — no icon set, icon definition or experimental leak. experimental-isolation.test.mjs 34/34
MobilePowerSearch still uses the switcher while the DEP's downstream row said Lab uses the replacement. Not migrated here (two open Lab changes edit that file and the owner wants the Lab flow left alone while they are in flight). The record's downstream row and Intent keep a gap note naming MobilePowerSearch; MobileTokenizer, the templates and the stories are migrated. Whether FR29c requires MobilePowerSearch to migrate in this patch is an open owner decision (see the FR29c row below). —
main now makes a modal standalone sheet a toast outlet (merged while this was in review). Merged and made stack-aware in the way spec:AST-003 FR18 allows: every open scrim sheet supplies an outlet (useModalOutlet(dialogRef, isOpen && hasScrim, stackView.isTop) — a covered sheet stays registered), and the latest outlet follows the sheet stack: useModalOutlet gained an isLatest argument that, when it turns true (a sheet shown on top, or revealed by a pop), moves that outlet to the end of the registry so the toast viewport moves into it. Switcher flows remain non-outlets, as on main. ToastViewport.modalHost: main's four sheet tests pass unchanged; "is hosted by the top sheet of a stack, and by the sheet beneath once that one closes" asserts the covered sheet is still registered (getActiveModalOutlet) while the viewport is in the top sheet; the non-modal test asserts :popover-open
Lone non-modal open focus: the record had said a non-modal sheet takes no focus on open; Chromium on main moves focus to the panel (show()'s focusing steps). The code never changed this — showSheetHostNonModal re-homes whatever the browser focused to the panel, so the behaviour is main's; the record is corrected (spec PR). Verified: keyboard open of the NoScrim story lands on the panel on main's build and on this head, identically. Chromium "lone no-scrim open (keyboard)" ×2 (branch, and same as the main build)
A switcher flow in a standalone sheet's content stayed open when that sheet closed from code (the switcher never read BottomSheetHostContext). The switcher now reads the host context and derives activeSheet = isHostOpen ? activeSheetProp : null: with the host closed the flow reads as closed everywhere (presentation, registry, dismissal, context) and leaves with its host through its own exit; its activeSheet is never written, so it comes back with the host on its step. Same rule as a standalone sheet. The "Stacked over a standalone sheet" story gains a page-level "Close settings" control to show it. R7 "a switcher flow in a sheet's content leaves with the sheet and returns with it; its state is never written"; Chromium: closing the host from code leaves no dialog open, reopening brings the flow back on top on its step
Lone non-modal open with a data-autofocus field: the record had said the field is focused; on main the native show() focuses the panel first and the autofocus is not applied. Code unchanged (same as main); the record corrected (spec PR). Verified identical on a static build of main and on this head with a new NoScrimAutofocus story (a hasAutoFocus TextInput in a no-scrim sheet): panel focused, field not. Chromium "lone no-scrim open with data-autofocus" ×2 (this head; same as the main build)
Replace pairing decided on the first two flips of a commit — with sheets written C, B, A and a control in B closing A and B and opening C in one handler, C wrongly replaced B (and adopted B's opener inside the closing A); written A, B, C the same update was a pop and a push. The pairing is still made eagerly (the first paint must show the handoff) but is now undone when a later flip of the same commit changes the tally: the registry remembers the batch's pair and the replacer's own opener, and detectReplace reverts both — replaced sheet back to an ordinary exit, replacer back to an ordinary push with its own opener, no owed focus — before the commit paints. Several sheets changing in one update keep push and pop motion in both tree orders. State shape bumped to 6. Replace suite "several sheets changing in one update" ×3 (written ABC, written CBA, and the explicit undo); new story "Finish closes two sheets and opens one" (written confirmation-first); Chromium: neither closing sheet is replaced, Saved is a fresh top, dim 0.5 throughout, closing Saved does not land on the List opener
Modal data-autofocus on a lone open: the record had said a modal sheet focuses the field; Chromium on main focuses the panel (native showModal()), the field is not applied. Code unchanged (same as main, verified with a new ModalAutofocus story on a static build of main and on this head: panel focused, field not, identically). Records corrected in the spec PR; the consumer doc's "Focus" sentence and focusSheetPanel's comment now describe the panel focus, with the replacement entry as the stated exception. The two Stacking stories that put data-autofocus on a TextInput wrapper (where it did nothing) use hasAutoFocus. Chromium "lone modal open with data-autofocus" ×2
BottomSheetSwitcher.doc.mjs and the file header still said each step dialog works "exactly like a standalone sheet: … no-scrim flows with show()", while a standalone hasScrim={false} sheet is now a popover="manual" host. Both reworded: a modal flow presents each step with showModal() as a standalone modal sheet does; a no-scrim flow presents each step with show() inside the switcher's isolated flow wrapper (local ordering), which differs from a standalone no-scrim sheet's top-layer popover host and is named as one reason to migrate. Searched the other docs, JSDoc, Changesets, templates and stories for the same claim: none. The record's DEC-1/FR5 already name show() for the switcher and leave that path to removal. check:knowledge, check:sync; template suites
FR29c and Lab's MobilePowerSearch (raised on the spec PR). An open owner decision, stated in the spec PR's body with its two options (owner exception — Lab is private, MobilePowerSearch migrates in its own follow-up — or migrating MobilePowerSearch before the deprecation lands). This PR currently follows the first because the owner asked that the Lab flow be left alone while two Lab changes are in flight; nothing here is decided. —
The deprecation Changeset and the switcher docs' migration stopped at four steps. Step 6 added to the Changeset and to both BottomSheetSwitcher.doc.mjs migration strings (the best-practice entry and its dense twin), worded as the record's DEP-0015: onCancel → a guarded onOpenChange (holds the step against Escape, platform close, scrim tap and swipe alike — onOpenChange(false) carries no reason, so an Escape-only veto has no equivalent; purpose is the only per-path lever); aria-label/aria-labelledby → each step's label; ref/id/data-testid/className/style/xstyle and other dialog props have no dialog-level equivalent (query the panel's closest('dialog'); dialog-level styling dropped). astryx component BottomSheetSwitcher --dense shows it; CLI template suites, experimental-isolation 34/34
Stale native close took the outlet away from a re-shown sheet — a registry re-show (close() then showModal(), on a reversal or a reopen during exit) queues a close event that useModalOutlet read as the sheet closing, so a toast raised from the reopened sheet could no longer be pressed (spec:AST-003 FR16). useModalOutlet's close handler ignores the event while dialog.open is true again (the dialog was closed and re-shown in one task). ToastViewport.modalHost "keeps a reopened sheet as the outlet when the stale native close event of its re-show arrives" (fails without the check); new story "Modal flow with toasts (reversal keeps the outlet)"; Chromium: after Back mid-handoff, and after a reopen during exit, the toast renders inside the reopened sheet, not inert, and is what the pointer hits
A sheet revealed beneath an open Dialog took the toast outlet from the Dialog (sheet → Dialog → sheet → close the inner sheet): the revealed sheet moved its outlet to the end of the registry above the Dialog supplied later, so a toast raised from the Dialog rendered in the covered sheet, could not be pressed, and its dismiss closed the Dialog. Outlets now carry a group; useModalOutlet(ref, isActive, isLatest, group) raises a sheet's outlet only among sheet outlets (raiseWithinGroup), never above an outlet of another group supplied later. Dialogs are untouched (no group, default ordering). ToastViewport.modalHost "a sheet revealed beneath an open Dialog does not take the outlet from the Dialog" (fails on the previous behaviour); the Dialog-interop story gained a "Toast from dialog" control; Chromium: after the inner sheet closes, the toast renders inside the Dialog, not inert, is what the pointer hits, and dismissing it leaves the Dialog open
layer-runtime carried rules main does not meet, against its own scope sentence. The spec PR moved them into component:BottomSheet FR7/FR13/FR14 with gap notes; this PR adds the shipped sentences to layer-runtime (dismissal standing-down, outlets per AST-003 FR18 with the sheet-stack order and the Dialog rule, the non-modal re-entry and its Drawer exclusion) as a records change, the way the per-step switcher change did, its approval metadata keeps main's values until an engineering owner's /approve-spec is recorded on this pull request. check:knowledge
"A switcher flow used on its own behaves as released" was no longer true: a lone modal flow now hosts toasts in its active step, where on the base a toast rendered behind the flow and a press on it hit the step. Named as a [fix]: new Changeset bottom-sheet-switcher-toast-outlet.md (AST-003 FR18 conformance — a toast raised from a flow is now pressable; the host follows each handoff; a hasScrim={false} flow leaves the viewport in place). The deprecation Changeset, both doc.mjs descriptions, the story description, the two JSDoc summaries and the Switcher record's Compatibility statement now say "behaves as released, with one fix: a toast raised from a step renders above the flow and can be pressed". check:changesets (25), changeset coverage
The description still read as if the layer-runtime passages lived on the base. Every place that said or implied it is rewritten — the Status line, the Current-specification bullet, the reconciliation note, the Writing-rubric paragraph and its R1 line, and the findings row: this PR adds the shipped passages as a records change (the #7720 precedent), to be approved via /approve-spec on this pull request; the record's approval metadata keeps main's values until that is recorded. —
layer-runtime's approved_by/approved_at were rewritten ahead of the approval. Reverted to main's values; the maintainer rewrites them once an engineering owner's /approve-spec is recorded on this pull request. git diff origin/main -- docs/architecture/layer-runtime.md shows no change to those lines
A step entering by a replace focused its panel, not its data-autofocus field, against AR4 and the Focus guidance: the dialog is shown (and the native focusing steps have put focus on the entering panel) before the registry's placement runs, and both the placement's caller and focusSheetPanel stopped at "focus is already inside". For a replace entry the placement runs after the native steps and treats focus on the panel itself as the native steps' doing, not a placement: it moves to the data-autofocus descendant when there is one, and otherwise leaves the panel focus alone (no second focus()); only focus the person already moved to a control inside the step stays put. Lone opens are unchanged (panel). Replace suite: "a modal/non-modal step entering by a replace focuses its data-autofocus field, even though the native focusing steps already put focus on its panel" — the stubs put focus on the entering panel on showModal()/showPopover() as a browser does, so the condition is not stubbed away; both fail without the fix; a control case without a field keeps the panel. Two new stories (modal, no scrim); Chromium: step one alone has the panel focused, and after Continue the entering step ends with its Name field focused, both hostings
A hasScrim={false} sheet opened while a modal it is not inside is open painted above the modal via showPopover() while staying inert, so a press landed on the modal beneath (on the base it stayed hidden under the modal). The non-modal host checks for an open modal dialog that does not contain the sheet (:modal, with the modal hosts' own aria-modal where an engine lacks it): with one open, the sheet is presented by show() beneath the modal, with no page-level z-index, and announces no top-layer entry; it enters the top layer when no outside modal is open, or at once when it is rendered inside the modal's content (not inert there). The decision sits in the one presentation path, so a stack re-show takes the same branch. Stacking suite R11: the existing "opened from a modal sheet, rendered outside it" case now asserts show() and no showPopover() (fails without the fix), plus a Dialog case (fails without the fix), an inside-the-modal case and a no-modal case (top layer); new story "A no-scrim sheet opened from a modal sheet, declared outside it"; Chromium: the hint is shown by show() (not a popover) while the modal is open, a press at its button does not reach it, and once the modal closes it is pressable
The host was picked once: a sheet left on show() (because an outside modal was open, or a closing modal still matched :modal through its exit) stayed there after the modal had gone, with no z-index, so a fixed tab bar painted over it and took its presses (AST-027 FR8). The decision is re-evaluated: a sheet held on show() is remembered per document and, on any dialog close (one capturing listener per document) and on every sheet-stack update, is moved into the top layer — close() then showPopover(), announcing the entry so toasts re-enter, focus kept — once no outside modal remains. A modal on its way out (inert or aria-hidden) counts as gone, so a sheet opened in the update that closes a modal sheet enters the top layer at once. Stacking R11: "shown beneath a Dialog enters the top layer once the Dialog closes, announcing its entry" and "opened in the update that closes a modal sheet enters the top layer at once" (the latter gives the closing dialog the browser's :modal answer) — both fail without the fix. The story gained a fixed z-index: 10 tab bar and a Swap control; Chromium: beneath the modal while open; once the modal has gone the hint is a popover, pressable, above the tab bar it overlaps; after Swap the hint is in the top layer while the modal sheet is still mid-exit, and ends pressable above the tab bar
The package typecheck failed (BottomSheetStacking.test.tsx: a plain function assigned to modal.matches is not a type predicate under TypeScript's overloads) and the storybook typecheck failed (the new autofocus story's TextInput lacked value). The earlier validation reported "typecheck 0" from a root pnpm typecheck that does not exist (it exited 1 with "command not found", and a grep -c on its output read 0). The stub is vi.spyOn(modal, 'matches').mockImplementation(...); the story's TextInput is controlled. Validation now runs the typecheck steps CI runs, per package, and reports their exit codes: core typecheck and typecheck:docs, lab typecheck:docs, storybook typecheck, a11y-spec typecheck, cli typecheck:strict and typecheck:template-docs — all exit 0 on this head. (@astryxdesign/lab typecheck, not a CI step, fails on origin/main itself in MobileTokenizer.test.tsx, unrelated to this PR.) the seven CI typecheck steps, exit 0 each
AR3 had no real-browser accessibility-tree evidence. The Chromium harness reads the browser's exposed tree: covered and replaced sheets are hidden, the top or entering sheet is the one dialog exposed, with its name — a lone sheet, stacks of two and three, and a replace handoff before and after it settles. The record's stacking verification row names the check. Chromium 5 accessibility-tree checks (in the 72)
Lone-open focus was described as "the panel" universally. Measured per engine on the same markup (Playwright, this head's static Storybook plus a bare-dialog probe): Chromium's show() and showModal() land on the first focusable area in tree order — the panel (tabindex="-1") — while its showPopover() skips such containers and lands on the first control; Firefox 151 lands on the first control on all three paths (the data-autofocus field only when it comes first). WebKit could not be launched on the Linux node (missing system libraries), so it is unmeasured here. The record, the Compatibility class, AR4, FR11, the failure list and the consumer doc now say "wherever the engine's native focusing steps land" with the per-engine results, and keep that as released behaviour. Code: the non-modal host's re-targeting to the panel was Chromium-only reasoning and would have moved Firefox's released first-control focus to the panel; it is now conditional on what the engine's own show() does, measured once on a throwaway non-modal dialog inside one task (focus restored) — Chromium re-targets to the panel as show() did, Firefox is left alone as show() did. BottomSheet suite: the Chromium-shaped test now stubs show() to focus the container, and a Firefox-shaped twin ("an engine whose show() skips the panel container") asserts the first control keeps focus; per-engine probe scripts in the evidence (engines-focus.mjs, engines-native-focusing-steps.mjs) with their output; Chromium harness unchanged for lone opens (panel)
Reduced-motion and screen-reader behaviour of the recede and the handoff were undefined. Record: new FR15 — under prefers-reduced-motion: reduce no motion plays and nothing cross-fades in its place: the recede and return land in their end state the frame they apply, the entering step is at rest from its first painted frame, the retained step's alignment and fade each land instantly, the handoff's steps still run in the released order (enter, align, fade, close) so it settles within a few frames with no intermediate values painted, and the dim stays constant; new AR5 — a push or pop adds no announcement beyond the focus move (no live region), a replace is perceived as focus entering the new step (its data-autofocus field or panel), whose dialog name is announced as focus enters; the retained step is hidden from the first frame. Code already matched (every motion's transitionDuration collapses to 0.01s under the query; no live region exists), so no change. Chromium with reducedMotion: 'reduce' emulated: the covered sheet is in its receded end state within ~one frame of the push and the dim is constant; the entering step is at its resting transform within ~one frame with the retained step already faded, the handoff still completes to one presented step, dim constant (5 checks); the accessibility-tree checks cover AR3/AR5's exposure
The engine probe was appended to document.body and cached for the page: if the first non-modal sheet to reach it sat inside an open modal, the probe ran under that modal's inertness (where show() moves no focus), cached the wrong answer, and every later non-modal sheet focused wrongly. The probe now lives inside the sheet's own dialog — in the top layer and interactive at that point (a lone sheet, or one inside the open modal's content) — so it cannot be inert; the cache stays. BottomSheet suite: "measures the engine inside its own dialog, so a first non-modal sheet opened inside an open modal does not cache the wrong answer" — its show() stub moves no focus for a dialog under an open modal outside it; fails with the body-level probe. Firefox and Chromium probes re-run on this head: unchanged (panel / first control)
Every lone hasScrim={false} open in Chromium focused and blurred the sheet's first control before the panel (showPopover() ran its focusing steps onto the first control, and focus moved to the panel only afterwards), so a field validating on blur showed its error on open; the page's first open added the probe's round. The released show() focused the panel directly. The popover's focusing steps are pointed at the panel first: autofocus is set on the panel for the duration of showPopover() (it becomes the steps' autofocus delegate in every engine; the attribute is removed at once), so focus lands on the panel with no control touched. Then, only in an engine whose show() skips the container (Firefox), focus moves once from the panel to the first sequentially focusable control — one focus on it, no blur. The engine probe runs after that, inside the shown sheet, and only blurs and refocuses the panel: no page content and no control sees an event. BottomSheet suite: the Chromium-shaped test records focus/blur on the first control and asserts none (fails with the previous approach); the Firefox-shaped test asserts the first control ends focused; both assert autofocus is not left on the panel. Real browsers (evidence engines-focus.txt, per-open focusin/focusout capture, page's first non-modal open so the probe round is included): Chromium — trigger blur, panel focus, then the probe's own container focus/blur inside the sheet, panel focus; no sheet control and no page content sees an event. Firefox — trigger blur, panel focus/blur, the probe's own button focus/blur inside the sheet, panel focus/blur, then one focus on the first control (Search) and no blur on it — its released result. Modal opens: trigger blur + one focus (panel in Chromium; the field or first button in Firefox)
The verification row carried a measurement status ("WebKit unmeasured …"), which architecture:knowledge-contracts INV10 keeps out of records. The row names the per-engine probe in Chromium, Firefox and WebKit and the focus/blur recording; the status lives here: WebKit is unmeasured — Playwright's headless WebKit build does not launch on the Linux evidence host (missing system libraries), so its lone-open result is unknown and the record states only the rule ("wherever its show() lands"). —
The manual real-Safari harness (internal/scripts/bottom-sheet-safari-check.mjs, 658 lines, and its README section) ships nothing and is separable. Removed from this PR and published as its own pull request, chore/bottom-sheet-safari-harness (branch off main: the script plus its README row and section, noting that the stacking and replace stories ship with this PR and are recorded as skipped on a deployment without them). The three real-Safari runs quoted below were made with that script; the Evidence section points at the harness PR. check:repo on both branches
A press stayed the actor for 1,000 ms even after its sheet had been replaced: in a flow whose middle step is advanced by code shortly after Submit (a "Processing" interstitial), the Submit press made detectReplace reject Processing → Done as a page-control push/pop, although focus was inside Processing; in Chromium the final close then left focus on body. A press is spent once the replace it made is paired (the registry forgets it there), and a press whose sheet was already closed or replaced before the flip — not the sheet the flip itself closes — no longer counts; the actor falls back to the focused element. Replace suite: "a step advanced by code shortly after a Submit press replaces the interstitial, and the final close returns to the opener" (300 ms inside the window; fails without the fix); the existing non-modal replace and show()-fallback tests guard the ordinary press-from-inside case
FR10 said the retained sheet keeps "the one scrim", while FR6/PR2 put it on the bottom open sheet. Record reconciled: the owner is FR6's bottom open sheet — a replace at the bottom of the stack keeps the scrim on the retained sheet until it leaves; a replace above a covered sheet leaves that sheet the owner and the retained sheet's backdrop transparent, so it only fades. The Replacing state and the Replaced-sheet row say the same. The code already does this (owner is the bottom open or replaced entry): the existing test "runs the replace one level up a stack and leaves the sheet beneath covered" asserts Base keeps --_sheet-scrim-opacity: 1 while the retained A and entering B are 0. existing Replace test; the FR10–FR11 verification row gained the state
main landed design:reduced-motion (#8272) while this round was in review: under reduced motion a transition must not move or scale (DR1), cross-fades on a coarse pointer (DR2) and changes at once on any other (DR3); the switcher record on main carried a gap note that the shipped switcher still runs shortened transforms and does not fade in on touch. Implemented in the sheet panel, which standalone sheets and switcher steps share, keyed on the record's own queries: under prefers-reduced-motion: reduce no transform runs (@starting-style keeps translateY(0), the exit keeps translateY(0), the recede lands in its destination at once); with pointer: coarse the entrance starts from opacity: 0 and the exit ends at opacity: 0 with transition-duration: 0.01s, 200ms (transform, opacity), so the entering sheet fades in at rest while the retained or leaving sheet fades out where it is — a cross-fade of 200 ms; on a fine pointer the 0.01s collapse stays. Completion: an exit under the cross-fade waits on its opacity fade before the dialog closes (useMediaQuery on the same query); the entrance keeps waiting on its instant transform so the retained fade starts at once and the two fades cross. Two residues the browser exposed and this PR also fixes: the retained sheet's alignment offset was still written inline (156 px of travel under DR1) — a retained sheet no longer aligns under reduced motion, it fades where it is; and the gesture hook pinned an inline transition: none on every open sheet under reduced motion (made redundant by the stylesheet's own collapse), which silenced the opacity fade — the inline pin now applies only to a drag or a scroll-area reconcile. Both gap notes close (FR15's and the switcher's FR3); only the MobilePowerSearch note outlives this PR. New Changeset bottom-sheet-reduced-motion-cross-fade.md ([fix]). Replace suite: "coarse pointer (DR2): an exit ends on its opacity fade, not a transform, and a handoff still closes the retained step after its fade" (fails without the completion change) and "fine pointer (DR3): an exit ends on its (collapsed) transform"; the panel test "completes immediately when the rendered transition is disabled" now models a rendered transition: none instead of the removed inline pin; Chromium with reduced motion emulated under an iPhone profile (coarse) and a desktop one (fine), 15 checks: no transform travel in the handoff (entering and retained each hold one transform) or a lone exit, on either pointer; coarse — both steps paint intermediate opacity frames (7 each), the entering fade 133 ms and the retained fade 183 ms, the retained fade starting (97 ms) before the entering fade completes (146 ms) so they cross; fine — no intermediate opacity frame, the retained dialog closes within two frames, a lone exit leaves at once; the recede lands at scale 0.96 at once on both; dim constant
DR2's drag-release clause: design:reduced-motion cross-fades a transition the release of a touch drag started even on a fine-pointer device, so a swipe dismissal under reduced motion must fade out in place rather than vanish. The gesture hook reports isDismissedByDrag (set when a release coasts past the dismiss line, cleared when the sheet reopens); the panel treats an exit that follows such a release as a cross-fade on every pointer — translateY(0), opacity: 0, transition-duration: 0.01s, 200ms — and waits on the opacity fade before closing. Dismissals from a control, Escape, the scrim or code keep DR3 on a fine pointer. FR15 states the clause; the Switcher's Handoff state says the retained step paints the scrim only when the flow owns it; main's reduced-motion failure signal is back in the Switcher's FR1–FR4/FR8 row. BottomSheet suite: "a swipe dismissal under reduced motion fades out in place on a fine pointer" (fails without the clause), plus the control-dismissal fine (at once) and coarse (fades) cases; Chromium, desktop profile with touch, reduced motion: a fling on the handle leaves intermediate opacity frames, no exit travel after the release, the dialog closes within ~200 ms of the fade start
Two changesets said "a modal sheet on its own behaves exactly as before", contradicting the reduced-motion changeset. Both qualified: "(apart from the reduced-motion fix released alongside)"; the reduced-motion changeset names the swipe clause. check:changesets
The drag-release cross-fade applied to any primary-button drag, so a mouse or pen drag dismissal on a fine pointer also faded for 200 ms; DR2 gives the cross-fade only to a touch drag's release. The gesture hook records the drag's pointerType (a synthetic touch counts as touch) and reports isDismissedByTouchDrag only for a finger; a mouse or pen drag keeps DR3 on a fine pointer. The deprecation Changeset, the switcher docs, story and JSDoc now say "two fixes" (the toast outlet and reduced motion). BottomSheet suite: "a touch swipe dismissal under reduced motion fades out in place on a fine pointer" and "a mouse drag dismissal under reduced motion on a fine pointer leaves at once" (fails if any drag counts); Chromium, desktop profile (pointer: coarse false): a CDP-driven finger swipe paints intermediate opacity frames and closes within ~200 ms with no travel after the release, a mouse drag on the same handle closes with no intermediate frame
The touch-drag flag's reset lived in the controlled-reopen effect with a reason naming no external system (architecture:react-component-runtime INV3). Derived instead: the flag is set on the dismissing release and read while the sheet closes; when isOpen turns true again it is reset during render (the previous-prop pattern) and the exposed value is touchDragDismissal && !isOpen. No effect, no lint exemption. BottomSheet suite: "a touch swipe dismissal does not outlive the sheet: reopened and closed from a control, it leaves at once again" (fails without the derived reset) plus the earlier touch/mouse/control cases
The touch-drag flag outlived a refused swipe: it cleared only when isOpen went false → true, so a guarded onOpenChange (the documented replacement for the switcher's onCancel) that ignored the swipe left it set, and the next close from a control, Escape or code cross-faded as if it were a drag. The flag is scoped to the one close the release requested: the release records it as pending, and the first render afterwards settles it against the host's controlled answer — the panel now receives isOpenRequested (the host's own isOpen, distinct from the panel state that stays open until the close settles): false → the exit under way is the drag's; still true → the request is spent. Derived during render, no effect. BottomSheet suite: "a refused touch swipe spends its request: a guarded onOpenChange keeps the sheet open, and the next close from a control leaves at once" (fails without the scoping), plus the earlier touch/mouse/control/reopen cases
BottomSheet's consumer doc is an authoring surface that migrates in the deprecation patch (spec:AST-017 FR29c); on main it says "use BottomSheetSwitcher for multi-step flows" and configures the flow's scrim on the switcher. Already rewritten in this PR to recommend stacked or replacing sheets (the usage description, the Lite description, the Stacking/Replacing passages and examples, hasScrim on a standalone sheet); this round the three prop tails that still said "Omit inside BottomSheetSwitcher" name the switcher as deprecated too, so every mention of it in the doc does. Template suites (CLI docs render); check:repo; the DEP-0015 row in the Switcher record names this doc
A deferred hasScrim={false} sheet was promoted only on a document close or the next stack update; an open <dialog> removed from the document fires no close, so a blocking Dialog that unmounts while open ({isEditing && <Dialog isOpen>}) left the sheet on show() beneath nothing. While anything is deferred, one MutationObserver per document watches childList removals. The dialogs that caused each deferral — every :modal dialog outside the sheet — are recorded at defer time in a per-document WeakSet, because :modal leaves with the dialog's top-layer membership and cannot be read off a removed node, and aria-modal is not set by showModal() hosts such as MobileNav or Lightbox; a removed subtree holding a recorded blocker triggers the same promotion pass (a blocker that closes still arrives through the document close listener). The observer disconnects once nothing waits (promotion or hideSheetHost). Stacking suite: "a hasScrim={false} sheet shown beneath a Dialog enters the top layer when that Dialog unmounts while open (no close event fires)" (fails without the observer) and "…deferred under a plain showModal() dialog with no aria-modal (a MobileNav or Lightbox host) is promoted when that dialog is removed while open" (fails with the aria-modal predicate); new story "A no-scrim sheet beneath a Dialog that unmounts while open"; Chromium: the hint opens by show() beneath the modal Dialog, Discard removes the open Dialog, the hint is :popover-open (no longer a show() dialog) and hit-tests above the fixed tab bar, and still closes from its control
A standalone sheet unmounted mid-exit left its "Close all" group waiting forever: the registry parked the group for a host report that only the exit motion's completion sends, and an unmounted host never sends it, so focus never returned. Leaving the registry is the host's completion: the cleanup hides the dialog and then removes the entry from its group (completing the group when it was the last member) — the parked-report maps are gone; a host that still reports afterwards finds nothing to do. Stacking suite: "returns focus when a member of the group unmounts mid-exit instead of reporting its motion (Close all with the upper sheet unmounting)" — both exits under way, the upper sheet's owner stops rendering it, the lower exit completes, focus is on the opener (fails on the previous head)
The new story's Show hint and Discard sat in the page behind the modal Dialog, inert. Both moved into the Dialog's content, as the Settings story does; the Chromium check presses them there. Chromium, re-run: same three checks pass
Blockers were recorded only at defer time: when the re-check kept a sheet waiting because a different modal was now open (one conditional Dialog replaced by another in one update), that modal was never recorded, so its later removal while open promoted nothing. The re-check adds every blocker it finds to the document's set before keeping the sheet waiting. Stacking suite: "…deferred under one Dialog that is replaced by another in a single update is promoted when the second unmounts while open (the re-check records the new blocker)" (fails without the re-check recording)
Story controls behind a modal: the switcher story's "Close settings" sat in the page behind the modal Settings sheet; the Settings story's page "Swap" duplicated the in-sheet "Swap to hint" from behind the modal. "Close settings" moved into the Settings sheet's content (while the modal flow is open above it the browser's modal blocking still keeps it out of reach — that scenario, the host closed from code as a route change does, is what the Chromium check drives; a person reaches it before the flow opens and after a step closes it); the page "Swap" removed, the description names "Swap to hint". Sweep of the stacking, replace and switcher stories: every other page-level control is an opener or sits under a no-scrim sheet (page interactive). Chromium check for the switcher-over-sheet flow re-run; check:repo
architecture:component-style-authoring INV7 / spec:AST-017 FR60 (element-count parity, now on main) BottomSheet is one component on every pointer: the stack, the replace handoff and the non-modal host change attributes, styles and native presentation calls on the same <dialog> and panel — no pointer-specific element, no second mounted presentation, and the only transient copy is none at all: the replace handoff retains the existing step's dialog while the new one enters, so no content is mounted twice. Chromium, 3 scenarios (stacked depth 2, no-scrim sheet, settled replace handoff) measured under an iPhone profile (pointer: coarse) and a desktop one (fine) at the same viewport: every open layer's element count is identical on both pointers (stacked: 47 + 31; no-scrim: 36; settled handoff: 58), one presentation each
A sheet stacked over a switcher flow lost the field being typed in when code moved the flow on. The flow's level is its current step's dialog, so a step change puts a new dialog in the top layer beneath the sheet; the registry closes and re-shows the sheet to keep it on top, and two things moved focus off the field: the step present blurred the active element (so its own dialog records no return target) and the re-show's showModal() plus the stack's focus('open') landed on the sheet's panel. The next keystrokes went to the panel. Two changes in the registry. (1) presentStepModally remembers the active element when it is inside another presented dialog that neither contains nor is contained by the step (a sheet over the flow) before it blurs, per document for the one commit. (2) reconcileTopLayer reads that element (or the live active element inside a re-shown open sheet), makes it the dialog's autofocus delegate for exactly the re-show's showModal() — the earlier popover technique, now for the modal path — so the native focusing steps land on it rather than the panel, focuses it again if an engine left it elsewhere, and skips focus('open'). The top layer is append-only, so the re-show must close the dialog first and the field sees one blur and one focus; nothing shorter exists. A covered sheet's panel also no longer takes focus (focusSheetPanel returns when its dialog is inert/aria-hidden), so the entering step beneath never pulls focus either. Stacking suite: "a sheet over a switcher flow keeps the field being typed in when the flow changes step from code (the re-show lands on it, no panel focus)" — C re-shown exactly once, focus still in the field, its value intact, the autofocus delegate removed again, ≤1 blur and ≤1 focus, the panel not focused (fails on the previous head: focus ended on the step's panel, then on C's); new story "Typing in a sheet over a flow that advances from code"; Chromium: 25 characters typed at 120 ms intervals across the flow's 2 s code-driven step change all land in the field, focus stays on it, the flow is on step 2 beneath (inert) with the Note sheet on top, and the field saw ≤1 blur/≤1 focus
Toasts raised from a sheet over a switcher flow landed in the covered step when code moved the flow on (component:BottomSheet FR13). The flow's outlet is re-supplied from its new step dialog on a handoff (hostKey), and a supply went to the top of the outlet order, above the sheet still on top of the flow — the stack's isLatest for that sheet had not changed, so nothing raised it back. Two halves. (1) Layer/modalOutlet.ts: a re-supply made while the caller is not its group's latest goes directly beneath its group's current top instead of to the end, so the sheet on top stays the latest outlet. A first supply still goes on top (the stack's answer arrives a render later and the existing raise settles it), and a flip of isLatest alone never re-supplies (isLatest is read through a ref at supply time). (2) Found by the browser check: the stack's re-show of the top sheet (close + showModal(), to keep it above the flow's new dialog) carries the toast viewport's popover — hosted inside that sheet — back beneath the sheet in the top layer while it still reads :popover-open, so a toast landed in the right dialog but painted under its panel. reconcileTopLayer now announces a top-layer entry when it re-shows a dialog that was already shown (the same notifyTopLayerEntry a non-modal sheet's entry uses), and the viewport re-enters in place as it already did for that case. A first show announces nothing: its outlet moves the hosts. Modal-host suite: "the top sheet stays the latest outlet when the covered flow beneath it changes step from code: a toast raised from the sheet lands in it, not in the inert step" — C stays the outlet, the toast's dialog is C, the viewport re-entered the top layer exactly once (hide, then show) across the re-show, closing C hands the outlet to step two (fails on the previous head); Chromium, the typing story: after the code-driven step change a toast raised from the Note sheet is hosted by Note, not inert, and is what the pointer hits (before the second half of the fix the toast was hosted by Note but painted beneath its panel)
architecture:layer-runtime INV14 (on main) gives top-layer mounting and the backdrop tint to the shared runtime; this PR's three added passages had to read consistently with it. Reworded: the dismissal passage states that the shared stack stays the one dismissal registry and only the order among sheets is the sheet stack's; the outlet passage says the registry orders outlets and mounts nothing (INV14 names the mounting), and adds the step-change case; the non-modal entry passage drops the show()/popover mechanics and says how the surface is put in the top layer is the component's own today, inside the INV14 gap. Honest note: the code does not use a shared runtime mounting — bottomSheetStack.ts calls showModal()/showPopover()/close() itself (the re-show that keeps open order, the step present, the non-modal host), which is exactly the BottomSheet entry main's Current gaps already lists; the Current gaps paragraph now names the stack's mounting as part of that entry rather than papering over it. Frontmatter trigger list and the INV14/INV15 sentence are main's. check:knowledge
Rebased over main's #8229 (Popover presented as a BottomSheet on a phone), which touched the same files: it removed the standalone sheet's positioner wrapper (INV7 — the dialog is now the panel's positioner, a grid), moved the handle pill to ::before, renders the edge tint only while presented, added BottomSheetEscapeContext (Popover can turn Escape off for its sheet) and gated the gesture listeners on isOpen. Composed, not papered over: the stack's recede (stackPositionerStyles) now applies to the standalone dialog itself, the panel's positioner — panel and grabber shrink and lift as one, and ::backdrop is not a descendant so the one visible scrim is unaffected; switcher steps keep their own positioner as before. BottomSheetEscapeContext and this PR's BottomSheetHostContext nest around the content in both the standalone sheet and a switcher step. The gesture hook's isOpen gate and this PR's isOpenRequested compose (one gates listeners, the other settles a release's dismissal). A Popover-presented sheet is a normal standalone modal sheet, so it registers in the stack like any other. Records: main's element-jobs text kept, with one clause that a covered sheet's recede is the dialog's transform; the layer-runtime Current gaps keeps main's Popover presentation-hold sentence beside this PR's. Stacking suite: "a Popover presented as a BottomSheet, opened from a sheet, is one level of the stack: it covers the sheet, Escape reaches only it, and focus returns into the sheet" (A covered/depth 1 and left to the browser's modal blocking since it contains the Popover's dialog; Filters on top, modal; Escape closes only Filters; focus back on the Filter control); #8229's own 952-line PopoverPresentation suite passes unchanged; Chromium: the Popover sheet story opens one modal sheet with no stack mark, its panel a direct child of the dialog flush with the bottom edge, the dialog untransformed, Escape closes it; the stacking story's covered dialog carries scale(0.96) + lift with its panel at 96% of the viewport width while the top dialog is untransformed, and the covered sheet keeps the one full-opacity backdrop (the top one clear)
PR1 promised one stack across every copy of the package, but a copy with a different stack-state shape (version skew) could not join, and the record did not say what happens then. Option (a). The stack protocol version is now part of the document-global key (Symbol.for('astryx.bottomSheetStack.v<N>')); a copy on another protocol finds no state under its own key and keeps a stack of its own, ordering only the sheets it renders — two independent stacks, neither reading the other's state. The shape check under the key remains as a guard against a key collision. PR1 in component:BottomSheet now scopes the promise to copies that share the stack protocol and states the fallback. Stacking suite: "the document-global key names the protocol version"; "two copies on different protocol versions keep two independent stacks: each orders only the sheets its copy renders (PR1)" — a state parked under the previous version's key sees nothing of this copy's registrations while a fresh same-protocol copy joins; "a state found under this version key with another shape (a key collision) is not shared"
Listener lifetime (architecture:react-component-runtime INV5): the capture-phase press listeners and the deferral close listener were installed with a document's first sheet and never removed, and the recorded press target was held strongly despite the comment. The press listeners are installed with a document's first registered sheet and removed when its last sheet leaves the registry (and by the test reset); the next sheet to register installs them again. The deferral close listener is installed with a document's first deferral and removed with the last promotion, alongside the mutation observer. The press target is a WeakRef, dereferenced at read time. Stacking suite: "the press listeners are installed once per document with its first sheet, removed when its last sheet leaves, and installed again by the next (INV5)" — two sheets add one set of three listeners; the top sheet leaving removes none; the last sheet unmounting removes all three; the next sheet adds three more (fails without the removal); "a recorded press holds its target weakly" (the record is a WeakRef whose deref() is the pressed control)
The header claimed nothing the reconcile reads is per copy, but focusToKeep and the deferred-non-modal table were module-level. Both moved into StackState (focusToKeep, deferredTopLayer with its watchers), the protocol version bumped (shape change), and the header states the scope: everything the stack reads and writes across commits is in the shared state for copies on the same protocol version; the one module-level value is the engine probe's answer, a browser fact every copy measures alike. The test reset releases the test document's deferral watchers before replacing the table. Stacking suite: "everything the stack reads across commits is in the shared state, nothing per copy" — the state carries every per-document table (shownOrder, listenedDocuments, lastPress, focusToKeep, deferredTopLayer), and a non-modal sheet this copy deferred beneath a Dialog is promoted by a fresh same-protocol copy's promotion pass reading the shared deferrals
Rebased over main's #8009 (modal-aware useAnnounce, ModalAnnounceProvider, hooks/modalAnnounce.ts), which wraps every standalone sheet's and switcher step's content in ModalAnnounceProvider. Composed: each standalone sheet keeps main's ModalAnnounceProvider (isOpen && hasScrim) and each switcher step its own (isPresented && hasScrim), outside LayerDepthProvider, with #8229's BottomSheetEscapeContext and this PR's BottomSheetHostContext nested inside, unchanged in meaning. Routing under the stack needed no fix: main's route reads exposure off open/:modal/inert/aria-hidden, which are exactly the marks the stack writes — a covered sheet is inert and aria-hidden (not exposed, so its words are delivered beyond it to the exposed top sheet), a same-update replace leaves the leaving sheet open-but-unexposed in the same update it writes to it, so its provider hands the words to the entering sheet, and a covered sheet that contains the top sheet (exposed, not on top) has its words cleared, as FR14 rules for a covered modal. New suite BottomSheetStacking.announce.test.tsx (5): top sheet's words land in the top sheet only; a covered sheet's words reach the exposed top sheet, never the covered sheet or the page; the covered sheet hears its own again once exposed; a same-update replace hands words announced from the leaving sheet to the entering one; a containing covered sheet's words are cleared while the top sheet hears its own. main's useAnnounce.modal.test.tsx passes unchanged. Chromium (Orders drill-in story, with "Announce from Orders"/"Announce from order" controls): words from the top sheet are written in its own region only; words from the covered (inert) sheet are delivered beyond it to the top sheet's region, not the covered sheet's, not the page's
Two story descriptions still described the shared dialog. Both rewritten for per-step dialogs; two new deprecated-switcher stories show the flow stacked over and under a standalone sheet. Chromium checks on the two new stories
Size. The review asks for a split into five parts. Waived by the owner — "i don't mind large PR. i can bypass [the review size] limit" (quoted in full under Scope); the Changesets stay separate so the release notes do. —

Tests

  • Default / unchanged legacy path: every existing Core suite in BottomSheet, Dialog, AlertDialog, Selector, DropdownMenu, ContextMenu, DateInput, DateTimeInput, TimeInput, Tokenizer, Layer, Layout, CommandPalette, MoreMenu, Popover, Lightbox, MobileNav, Toast and Lab MobileTokenizer passes: on this base, the BottomSheet, Layer, Toast, Dialog, Lab and CLI template folders pass (121 files / 1,750 tests; BottomSheet alone 7 files / 342 tests incl. main's 29 per-step switcher tests unchanged); the two new suites are 70 stacking + 47 replace = 117 tests, and 8 tests were added to existing suites (BottomSheet.test's non-modal focus tests, the Layer invariants popover stub, the Toast viewport re-entry tests); every existing test passes. Three existing tests changed with the non-modal hosting the owner asked for: BottomSheet.test "opens non-modally" now expects the manual popover (and a new test covers the show() fallback), R11 counts showPopover instead of show, and the Layer invariants suite stubs showPopover the way the Drawer suite does. Every BottomSheetSwitcher test passes unedited. R1 and R12 tests assert a lone sheet carries no stack attribute, no transform, scrim 1, one showModal, focus on its panel; every BottomSheetSwitcher test passes unedited.
  • Stacking: BottomSheetStacking.test.tsx, 70 tests grouped R1–R14 (R14, new: a switcher flow stacked over a standalone sheet and under one — one scrim, recede, blocking, Escape to the top level, focus return into the lower level, and a step switch while covered keeping the covering sheet on top): siblings, separate components, nested in content, inside a switcher step, switcher as a level, out-of-tree-order opens, same-commit deep link under flushSync before any microtask (states, depth, transforms, scrim, top-layer order, focus), recede matrix to depth 3, Dialog over a sheet, Escape/scrim/swipe/cancel each popping one with purpose honoured and reaching the top sheet past a covered nested one, one scrim through push/pop and the handoff (pin recorded at write time), closing a covered sheet (nested children leave with it and come back with it, their state never written; siblings stay; switcher step left; focus stays on top), focus (into, back to opener, panel fallback, deep link, last sheet), top-layer order with a Dialog between sheets, StrictMode, a never-committing transition, Suspense hide/reveal, Activity, SSR+hydration, non-modal, one registry per page (two copies share; other shape keeps its own; two copies across an iframe and the main document keep both sheets shown) and per document.
  • Replace: BottomSheetReplace.test.tsx, 47 tests: what is and is not a replace (push, pop, different updates, covered close, nested, switcher flow, a lone sheet), the handoff (retained in place, inert, hidden; fade after entering; alignment both completion orders; shorter retained; scrim kept then handed over with the 0s pin; one level up a stack), focus and dismissal (into the new sheet and kept when the replaced dialog closes; back to the flow's opener; Escape to the replacer during the handoff and to the returning sheet during a reversal; dismissal and a third step mid-handoff drop the retained sheet; purpose on the replacer), unrelated owners (the verification's case; the same swap from inside; the press read over focus both ways; steps wrapped in components; a code-driven step change; a stale press not vetoing a later replace), reversals (Back during Continue, Continue during Back, a double reversal — each with the steps written in flow order and in reverse order), non-modal (manual popover, replace with no stacking, reversal re-show, no pairing with a modal sheet, show() fallback still replacing, the flow's last step returning focus to its opener).

Real browser

Chromium (Playwright 1.61, Chrome Headless Shell 149) at 390×844 with touch against the static Storybooks of main (BottomSheetSwitcher stories) and this branch (BottomSheetReplace stories, which mirror them one to one: same content, labels, heights and steps). Per switch, every animation frame: each panel's computed transform and opacity, every dialog's ::backdrop opacity (combined into the page dim), elementFromPoint on each panel, inert / aria-hidden, document.activeElement, the native showModal/show/close log, mid-handoff and settled screenshots with a pixel diff. Timings are relative to the click; the two builds can sit one frame apart purely from where the click fell in the frame, so deltas are read with a ±1 frame (≈17 ms) tolerance. "Back-during-handoff" is Back pressed 150 ms into 1→2.

Chromium, default motion

Story Switch Entering start / end Δ (ms) Align offset main / branch (px) Fade start / end Δ (ms) Per-frame max Δ (±1 frame): entering translateY px / retained translateY px / retained opacity Page dim main / branch Pixel diff mid / settled (px) Focus after Retained inert & never on top
hug-content 1to2 +2 / +2 156 / 156 +2 / +2 0.2 / 0.1 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 2to3 -2 / -1 0 / 0 -1 / -1 0.2 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 3to2 -1 / -17 56 / 56 -17 / -17 0.2 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 2to1 -1 / -1 0 / 0 -1 / +0 0.5 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content back-during-handoff +0 / +0 302 / 302 -1 / -1 34.0 / 0.2 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 2684 / 1693 same same
capped 1to2 +0 / -1 0 / 0 +0 / -1 0.3 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 2to3 +0 / -16 0 / 0 -16 / -16 0.4 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 3to2 +0 / -17 0 / 0 -17 / -16 0.7 / 0.0 / 0.002 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 2to1 +0 / -17 0 / 0 +0 / +1 0.4 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped back-during-handoff +0 / +0 571.3 / 571.3 +1 / +1 0.0 / 0.3 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 1646 / 1646 same same
no-scrim 1to2 -1 / -17 156 / 156 -18 / -17 0.3 / 0.1 / 0.001 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to3 +0 / +16 0 / 0 +16 / +16 0.3 / 0.0 / 0.001 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Where should we notify you?] same
no-scrim 3to2 +0 / +17 56 / 56 +17 / +16 0.3 / 0.1 / 0.001 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to1 +0 / -16 0 / 0 -16 / -16 0.3 / 0.0 / 0.001 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Set up notifications] same
no-scrim back-during-handoff +0 / +0 302 / 302 +0 / +0 34.0 / 0.5 / 0.001 0..0 / 0..0 (max per-frame Δ 0.000) 8432 / 1693 differs: body (page) vs panel[Set up notifications] same
narrow-viewport 1to2 +1 / +0 156 / 156 +0 / +0 0.2 / 0.1 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 2to3 +0 / -17 0 / 0 -17 / -16 0.3 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 3to2 +0 / +0 56 / 56 -1 / +0 0.2 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 2to1 +0 / +0 0 / 0 +0 / +0 0.3 / 0.0 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport back-during-handoff +0 / +0 302 / 302 +1 / +1 34.1 / 0.2 / 0.001 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 20099 / 1693 same same

Chromium, prefers-reduced-motion: reduce (every motion completes in one frame on both builds; the dim must still hold at 0.5)

Story Switch Entering start / end Δ (ms) Align offset main / branch (px) Per-frame max Δ (±1 frame): entering translateY px / retained translateY px / retained opacity Page dim main / branch Pixel diff mid / settled (px) Focus after
hug-content 1to2 -1 / -1 156 / 156 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content 2to3 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content 3to2 -1 / -1 56 / 56 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content 2to1 +1 / +1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
hug-content back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 1to2 +2 / +2 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 2to3 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 3to2 -1 / -1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped 2to1 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
capped back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
no-scrim 1to2 +3 / +3 156 / 156 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 1660 / 1660 differs: body (page) vs panel[How often?]
no-scrim 2to3 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 164 / 164 differs: body (page) vs panel[Where should we notify you?]
no-scrim 3to2 +1 / +1 56 / 56 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 1660 / 1660 differs: body (page) vs panel[How often?]
no-scrim 2to1 +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 340 / 340 differs: body (page) vs panel[Set up notifications]
no-scrim back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0..0 / 0..0 (max per-frame Δ 0.000) 340 / 340 differs: body (page) vs panel[Set up notifications]
narrow-viewport 1to2 +2 / +2 156 / 156 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport 2to3 +1 / +1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport 3to2 -1 / -1 56 / 56 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport 2to1 -1 / -1 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same
narrow-viewport back-during-handoff +0 / +0 0 / 0 0.0 / 0.0 / 0.000 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same

WebKit (Playwright's WebKit 26.5 in its Linux container, same harness — WebKit, not Safari on iOS hardware; spec:AST-013 FR11 does not count it as an iOS Safari claim). Headless WebKit's first frames after a click arrive 50–150 ms apart and vary between runs, so per-frame motion cannot be compared there (the earlier main-vs-main noise run shows deltas of the same size as main-vs-branch). What WebKit does establish for the top-layer changes (spec:AST-027): the non-modal sheet is hosted as a manual popover (:popover-open, :modal false) and replaces with the same handoff (settled screenshots 0 px on every no-scrim switch); the page dim constant at 0.5 on every modal switch, settled screenshots identical (0 px) on every pair; the toast viewport re-enters above a non-modal sheet (toast raised before the sheet opens and from the sheet while open both hit-test to the toast; tooltip and popover opened from the sheet hit themselves); focus return on the lone no-scrim sheet's Escape → the opener on both builds (WebKit's close() after show() did restore focus on main, unlike Chromium), and the no-scrim flow's final close → the flow's opener on both; the eight stacking stories identical to the stacking-only head except the three stories whose description text changed.

Safari (real Safari 27.0.1 on macOS 27.0.1 through safaridriver's W3C WebDriver endpoint, run 2 on the revision of this PR with the non-modal focus fix, run 3 on the revision that added the "Close all" return (the last code change before this description; the harness lives in its own pull request, chore/bottom-sheet-safari-harness, so the runs can be repeated — it ships nothing and was split out of this PR) — 11 of 11 drivable checks pass, 1 not driven; macOS Safari, not iOS hardware, so it informs but does not satisfy an iOS Safari claim under spec:AST-013 FR11; viewport 390×792 in a 390×844 window). The script (bottom-sheet-safari-check.mjs in the harness PR, no dependencies, run by a person on a Mac — Linux CI cannot) drives the same stories at 390×844, samples the page dim and open dialogs per animation frame inside one executeAsync call, and hit-tests toasts and the top sheet; checks safaridriver cannot drive reliably (hover for tooltips/popovers) are marked not-driven, never faked. Three runs: run 1 on an earlier revision of this PR found the gap (focus on body after a no-scrim switch, so the flow's final close did not return to the opener); run 2 confirmed the fix — 10 of 10 drivable checks; run 3 adds "Close all" — 11 of 11 drivable checks pass, 1 not driven. The main column is run 1's reference (that deployment only has the switcher and lone NoScrim stories; the rest are recorded as skipped):

Check main (Safari 27.0.1) this PR (Safari 27.0.1)
Non-modal hosting (lone NoScrim): :popover-open, not :modal show() host (open, not :modal); focus inside pass — :popover-open, not :modal; focus inside
Focus return: lone no-scrim sheet, Escape → opener fail — body pass — "Open sheet"
Replace handoff (modal): dim through 1→2 and a fast Back, open dialogs, focus into the entering step pass — dim 0.5..0.5 / 0.5..0.5; one dialog; focus in the entering step pass — dim 0.5..0.5 / 0.5..0.5; two dialogs through the handoff, one settled; focus in the entering step's panel
Focus return: modal flow, final close → opener pass — "Set up notifications" pass — "Set up notifications"
Replace handoff (no-scrim): dim 0 throughout, focus into the entering step dim 0; focus body after the switch pass — dim 0..0 / 0..0; focus in the entering step's panel (run 1: body, the gap)
Focus return: no-scrim flow, final close → opener pass — "Set up notifications" pass — "Set up notifications" (run 1: fail, body)
Toast raised before / while a non-modal sheet is open hit-tests to the toast skipped (story absent) pass — above / above, above
Tooltip / popover opened from the sheet above it not driven not driven (Chromium + WebKit cover it)
Covered sheets inert + aria-hidden; top sheet hit-tested skipped (story absent) pass — Orders inert + aria-hidden="true"; Order 1042 hit-tests itself
Escape closes only the top sheet skipped (story absent) pass — 2 → 1; focus back on the revealed sheet's control
Deep link (depth three): the top sheet is the one hit-tested skipped (story absent) pass — 3 presented; Edit delivery note on top and hit-tested
"Close all" from depth 3 → focus on the page opener (added for run 3) skipped (story absent) pass — 3 open → 0; focus "Open orders"
Story Switch Time-aligned (≤10 ms) max Δ: entering px / retained px / opacity (frames compared) Page dim main / branch Pixel diff mid / settled (px) Focus after Retained inert & never on top
hug-content 1to2 0.0 / 0.0 / 0.035 (66) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 726 / 0 same same
hug-content 2to3 30.3 / 0.0 / 0.223 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 3to2 13.3 / 23.8 / 0.545 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content 2to1 16.6 / 0.0 / 0.012 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
hug-content back-during-handoff 87.1 / 11.7 / 0.639 (94) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 41430 / 0 same same
capped 1to2 22.8 / 0.0 / 0.008 (81) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 3075 / 0 same same
capped 2to3 40.5 / 0.0 / 0.012 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 3to2 7.7 / 0.0 / 0.007 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped 2to1 13.1 / 0.0 / 0.024 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
capped back-during-handoff 0.0 / 15.4 / 0.310 (89) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
no-scrim 1to2 8.2 / 40.5 / 0.323 (85) 0..0 / 0..0 (max per-frame Δ 0.000) 9173 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to3 2.9 / 0.0 / 0.009 (88) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Where should we notify you?] same
no-scrim 3to2 14.4 / 23.8 / 0.323 (86) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[How often?] same
no-scrim 2to1 7.9 / 0.0 / 0.017 (88) 0..0 / 0..0 (max per-frame Δ 0.000) 0 / 0 differs: body (page) vs panel[Set up notifications] same
no-scrim back-during-handoff 1.1 / 8.1 / 0.072 (92) 0..0 / 0..0 (max per-frame Δ 0.000) 5530 / 0 same same
narrow-viewport 1to2 12.0 / 39.9 / 0.331 (85) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 31449 / 0 same same
narrow-viewport 2to3 13.4 / 0.0 / 0.017 (87) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 3to2 14.6 / 23.2 / 0.323 (86) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport 2to1 13.8 / 0.0 / 0.012 (88) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 0 / 0 same same
narrow-viewport back-during-handoff 14.0 / 8.1 / 0.116 (92) 0.5..0.5 / 0.5..0.5 (max per-frame Δ 0.000) 13352 / 0 same same

Dismissal on the active step (step 2), after reopening with focus on the opener (Chromium; reduced motion and WebKit agree):

Story Action main: open after; focus after branch: open after; focus after Native calls (branch)
hug-content dismiss-escape none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
hug-content dismiss-scrim none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
hug-content dismiss-swipe none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
hug-content escape-during-handoff none; body (page) none; body (page) showModal(Notification frequency), close(Set up notifications), close(Notification frequency)
capped dismiss-escape none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
capped dismiss-scrim none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
capped dismiss-swipe none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
capped escape-during-handoff none; body (page) none; body (page) showModal(Notification frequency), close(Set up notifications), close(Notification frequency)
no-scrim dismiss-escape none; body (page) none; body (page)
no-scrim dismiss-scrim Set up notifications; button"Set up notifications" on page Set up notifications; panel[Set up notifications]
no-scrim dismiss-swipe none; button"Set up notifications" on page none; body (page)
no-scrim escape-during-handoff none; body (page) none; body (page)
narrow-viewport dismiss-escape none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
narrow-viewport dismiss-scrim none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
narrow-viewport dismiss-swipe none; button"Set up notifications" on page none; button"Set up notifications" on page close(Notification frequency)
Story First entrance scrim fade (main) (branch) Open-settled px diff Dismissal-settled px diffs Console errors main / branch Overflow
hug-content 0 → 0.422 (100 ms) → 0.5, 10 intermediate frames 0 → 0.423 (100 ms) → 0.5, 10 0 0 / 0 / 0 0 / 0 390=390 / 390=390
capped 0 → 0.423 (100 ms) → 0.5, 10 intermediate frames 0 → 0.422 (100 ms) → 0.5, 10 0 0 / 0 / 0 0 / 0 390=390 / 390=390
no-scrim 0 → 0 (100 ms) → 0, 0 intermediate frames 0 → 0 (100 ms) → 0, 0 0 0 / 0 / 0 0 / 0 390=390 / 390=390

The stacking stories, no regression. The eight Core/BottomSheetStacking stories driven identically on an earlier stacking-only revision of this PR and on this branch, and again on this branch with and without the replace (open, drill in up to three levels, Escape pop with the dim sampled per frame, "Close all" where offered, and the owner-closes-under-others scrim handoff on the out-of-tree-order story): every state summary, screenshot (0 px), call log and dim range identical, except the handoff the fix below changed: the owner-closes-under-others page dim was 0.5 → 0.736 → 0.5 on that earlier revision (the new owner's backdrop appeared over the old one's 410 ms fade) and is 0.5 constant now, with and without reduced motion. Re-run on the final head against the stacking-only head: every story identical (0 px, same dims, same calls) except the two stories rewritten from a switcher to standalone steps, whose only pixel difference is their new description paragraph above the sheets (y 80–191); their sheets, pops, dims and focus are identical. Also measured on the stacking stories: recede matrices (matrix(0.96,0,0,0.96,0,-8) / matrix(0.92,0,0,0.92,0,-16)), ::backdrop opacity 1,0,0 at depth 3, top-only hit testing, Escape / scrim tap / CDP touch swipe each popping exactly one, native showModal order for the deep link, out-of-tree-order with the later-opened sheet on top, focus return into the revealed sheet, Dialog interop both ways, no overflow, no console errors.

Accessibility classification (spec:AST-009 FR8)

What changes for assistive technology, and the evidence layer that proves each claim:

Change Claimed outcome Trigger under FR1–FR6 Evidence offered
A covered modal sheet that contains no presented sheet is inert + aria-hidden="true"; a covered sheet that contains the top sheet is left alone (the top sheet's showModal() already makes everything outside it inert); a replaced sheet is inert + aria-hidden from its first retained frame Covered and replaced sheets are not reachable or exposed; the top (or entering) sheet is the only exposed dialog None. The claim ends at browser exposure (FR3: no role, name or description changes; nothing is claimed about what is announced). inert is native DOM (R1–R4, replace suite: attributes per state); accessibility tree (Chromium ariaSnapshot on the stacking, replace mid-handoff and settled, and lone non-modal states: only the top/entering dialog is in the tree); real browser (hit-testing and focusability of covered sheets, Chromium and WebKit)
Focus: into the top sheet on open and into the entering sheet on a replace; back into the revealed sheet on a pop (opener if it is there, else the panel); back to the flow's first opener on close; a non-modal sheet's panel on open and its opener on close while focus was inside; finalFocusRef unconditional Focus destination, order and return None (FR2: destination, order and return do not trigger; no claim depends on how AT announces the destination, and the focused elements' names are unchanged) Unit (R8, replace focus suite, non-modal focus tests); real browser (Chromium and WebKit keyboard-open and close runs above; prefers-reduced-motion runs)
Non-modal hosting as popover="manual" Same <dialog> with the same aria-label, no aria-modal, same role; only the native host changes None (FR3: the computed role and name are unchanged — the tree shows the same dialog "Nearby places" node) Accessibility tree (above); DOM
Toast viewport re-entry above a non-modal sheet The toast region stays visible and operable; the viewport element, its role="region" and its live announcements are unchanged (a re-entry toggles the popover's display, it does not re-render toasts) None (FR4 would apply if announcement content or timing changed; the useAnnounce path and toast DOM are untouched, and the existing Dialog move already re-shows the viewport the same way) Unit (modalHost suite); real browser hit-tests
Deprecation of BottomSheetSwitcher Declarations and docs only; a stacked switcher flow's new inert/aria-hidden and focus behaviour is the stacking row above None —
Known divergence (FR6) No recorded AT/browser divergence in the repository covers inert on a covered <dialog>, manual-popover <dialog> hosting, or sheet focus return — —

No real-AT receipt is required under FR1–FR6, so no stable-release block is recorded (FR17); the change uses proportionate evidence (FR7). What this does not prove, per the evidence table: what a screen reader announces when focus moves into the entering sheet or back to the opener, and virtual-cursor entry; neither is claimed by this PR. If the owning records later make one of those a claim (e.g. "the new step's label is announced on a replace"), that becomes an FR2/FR3 trigger with a VoiceOver-on-iOS row, and the stacking and replace stories are the fixtures for it.

Remaining deltas, with root causes

  1. No-scrim focus. The switcher's no-scrim path loses focus to body on every step and on close (in Chromium the exiting shell goes inert, which blurs the panel before dialog.close(), so the non-modal close() finds nothing to restore — main never returned focus for a non-modal sheet, lone or in a flow). The standalone sheet lands focus on the new step's panel (tabindex="-1", no ring) and, when the flow closes while the person is still in it, returns it to the flow's opener (Escape, Cancel, swipe and the last step's Done all measured: main → body, branch → the opener). Settled screenshots are identical (0 px) on every no-scrim switch; the focus columns are the only difference, and they are the switcher's gaps.
  2. Rapid reversal travel start (hug-content / narrow-viewport / no-scrim, Back at 150 ms): the returning sheet begins its travel back one to two frames later than the switcher's step (≤ 34 px at peak velocity for one or two frames); the retained panel (Δ ≤ 0.4 px), the fade (±1 frame) and the dim (constant 0.5) are identical. Root cause: the reversal re-shows the returning dialog (close() + showModal(), two forced layouts) and runs one extra synchronous render in the same task. Not the drop-order bug the review found (that one cost ~400 ms and is fixed; the capped flow, which has no alignment travel, now measures 0.0 px); the residual is the re-show itself.
  3. Text anti-aliasing after a reversal. The settled screenshot after a reversal differs by ~1.7k px that are all text edges of the returned panel (geometry, transform, opacity and every computed style are identical on both builds, verified by probe); Chromium re-rasterises the re-shown top-layer dialog's text with a different anti-aliasing. Not present after a plain switch (0 px).
  4. Structural, not visual: two open dialogs through a handoff (one at rest) and two native calls per switch where the switcher has one dialog and no calls.

Reconciliation with the per-step switcher dialogs on main

main now presents each BottomSheetSwitcher step in its own native dialog (component:BottomSheetSwitcher DEC-1): the switcher coordinates which step dialog is presented, retained or closed, hands its one scrim between them (takeOverScrim, presentWithClearScrim, presentStepModally), and resolves its ref to the host dialog. The stack's "a whole switcher is one level" therefore became "the active step's dialog is the flow's level":

  • Registration. The switcher registers one entry for the flow, keyed once for its lifetime, whose getElement is the host dialog (hostDialogRef), so a handoff re-points the level without re-registering and the flow keeps its rank. A getTreeAnchor on the registration names the flow wrapper — the one element that contains every step dialog — so a same-commit tie against another sheet is broken the same way whichever step is active (the step dialogs are siblings in the DOM; the wrapper precedes and contains them all).
  • Handoff order. The step hosts present their own dialogs, as on main. When the host dialog changes, the switcher calls notifyBottomSheetHostChanged(key): the registry records the new dialog as the most recently shown and, when a sheet belongs above the flow, hides and re-presents it in the same commit, before paint — so switching the step of a covered flow (only code can) keeps the covering sheet on top. The registry's own show for the level presents the host dialog only to restore order; it uses the switcher's blur-first present and the step's scrim rule, and bumps the switcher's presentation stamp so isCoveredInTopLayer stays right.
  • Scrim coexistence. The flow's one scrim is multiplied by the stack's: flowOwnsStackScrim = !isStacked || ownsScrim. A flow stacked above another sheet keeps every step backdrop clear (through its own handoffs too); a flow beneath owns the stack's scrim and hands it between its steps as on main. A change of the stack's owner lands on the flow's owner dialog with the two-frame 0s pin (writeScrimOpacity); the switcher's intra-flow takeOverScrim is unchanged. Gesture thinning from a step is ignored while a lower sheet owns the scrim.
  • Blocking and recede. The stack view reaches the step hosts through the switcher context: a covered flow's step dialogs carry inert, aria-hidden, data-sheet-stack / -depth, and the recede transform, in addition to the switcher's own retained-step inertness.
  • Dismissal and focus. The switcher's dismissal registration answers isPresent: false while covered. When a flow closes over a lower sheet, focus returns into that sheet (to the opener when it is there, else the panel); at depth > 0 the flow's close moves nothing; in a "Close all" the flow reports its close to the registry and the registry returns focus once. The step dialogs blur before presenting (as on main), so their native close moves nothing.
  • Replace pairing already excluded the switcher (no replaceable on its registration); a step is never paired with a standalone sheet.
  • Nested sheets in a step. A standalone sheet rendered in a step's content stacks above the flow and leaves with the step through BottomSheetHostContext, now set inside the step's LayerDepthProvider.

layer-runtime.md is edited by this PR: the shipped passages (see Current specification). Sheets keep the shared useScrollLock, as Dialog does.

Writing rubric results

Three current records are edited; what changes, exactly:

  • component:BottomSheet — deleted: every present-tense "Implementation gap" note the record pull request placed on the rows this PR implements (FR5–FR15, AR3–AR5 and their verification rows). Changed: PR1's wording (the one-stack promise is scoped to package copies that share the stack protocol, with the fallback stated: a copy on another protocol version keeps its own stack); DEC-2's one sentence that cites PR1, aligned to that scope; one clause in main's element-jobs passage — "a covered sheet's recede (FR6) is this element's transform" — after feat(Popover): presentation — a BottomSheet on a phone when asked, as iOS presents a popover #8229 made the dialog the panel's positioner. Added: this PR's suites to verified_by.

  • component:BottomSheetSwitcher — deleted: the "Implementation gap" notes on FR10, the lifecycle statement, DEP-0015's state row and the FR10 / DEP-0015 verification rows, and the @deprecated/consumer-docs parts of the Intent gap note; the MobilePowerSearch sentence stays (its own change, feat(lab): MobilePowerSearch's steps are standalone sheets that replace one another #8476, migrates it). Added: suites to verified_by. No requirement text changes.

  • architecture:layer-runtime — added: the shipped passages (covered and replaced sheets standing down from dismissal while the shared stack stays the one dismissal registry; toast outlets per spec:AST-003 FR18 following the sheet stack's order, the switcher flow's active step, the step-change case and the later-Dialog rule; the non-modal top-layer entry and the Drawer exclusion), each worded so that the registry orders and mounts nothing (INV14), and one sentence in Current gaps naming the sheet stack's mounting as part of BottomSheet's existing entry. Deleted: the scrim switcher from the list of modals toasts stay behind. main's INV14/INV15 sentences, trigger list and Popover presentation-hold sentence are kept; approved_by/approved_at keep main's values until an

  • R1 narration: the added layer-runtime sentences are present-tense statements about the shipped runtime; the deleted notes described main, and nothing replaces them.

  • R2 research: none in the records; measurements stay in this description.

  • R3 evidence naming a change: none; verified_by names test files.

  • R4 rejected alternatives: none added.

  • R5 change references: none added (searched # + digits, /pull/, /issues/ in the three records).

  • R6 open questions: none added or ruled.

Scope

  • One capability is added (stacked bottom sheets; the replace is the same capability applied to a same-update switch, and the deprecation follows from it).
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Why one pull request

The six Changesets name six release notes, not six separable changes; the line count is mostly tests. Of the 12,455 changed lines against main (+11,790 / −665), 5,971 (48%) are test files — BottomSheetStacking.test.tsx 3,184, BottomSheetReplace.test.tsx 1,526, ToastViewport.modalHost.test.tsx 502, BottomSheet.test.tsx 473, BottomSheetStacking.announce.test.tsx 246 — and a further 2,064 are Storybook stories that the browser evidence drives; the records, docs and Changesets are 441. What remains is the stack and the parts that only exist because of it:

  • The replace handoff needs the registry. A replace is recognised by the registry's batch (one sheet closes, exactly one opens, in the same commit, from inside the leaving sheet — bottomSheetStack.ts sealBatch/batchPair) and performed by its top-layer reconcile (the leaving sheet stays presented, inert, through its fade while the entering one is shown). Without the registry there is no batch to read and no reconcile to hand off in; bottom-sheet-replace-handoff cannot ship before bottom-sheet-open-order-stacking.
  • The no-scrim host and the toast-outlet ordering exist because stacked sheets share one top layer. Before this change, a non-modal sheet was a show() dialog in page stacking order and the toast outlet was whatever dialog registered last. Once sheets stack in one top layer, a non-modal sheet has to be hosted there too (the popover="manual" host and its deferred entry beneath an open Dialog) or it would be painted under the stack, and the toast outlet has to follow the stack's order (Layer/modalOutlet.ts: outlets grouped and raised with the sheet stack, a switcher step's re-supply placed beneath the top sheet, the viewport's re-entry after a re-show) or toasts raised from the top sheet land in a covered, inert dialog — the FR13 bug this review found and this PR fixes. bottom-sheet-switcher-toast-outlet is that ordering applied to a switcher flow as one level; it has no meaning without the levels.
  • Reduced motion covers the recede and replace motions the stack introduces. design:reduced-motion DR1–DR3 are applied in BottomSheetPanel to the entrance and exit, which exist on main, and to the covered sheet's recede and the replace cross-fade, which do not; bottom-sheet-reduced-motion-cross-fade shipping alone would describe motions a release without the stack never performs, and the stack shipping alone would introduce two motions that ignore the rule.
  • The deprecation's replacement guidance only works once replace exists. bottom-sheet-switcher-deprecated and cli-bottom-sheet-templates-standalone-steps tell callers to write each step as its own BottomSheet driven by one state value, "closing the top sheet and opening the next in the same update" — that sentence is the replace handoff. Deprecating the switcher in a release where that pattern opens two unrelated dialogs with two scrims would be a deprecation whose named replacement does not work — spec:AST-017 FR7 makes a replacement-first deprecation a [feat] that names the replacement and the migration — and component:BottomSheetSwitcher DEP-0015 reads deprecated with this change, so the JSDoc @deprecated, the doc.mjs guidance and the templates must land with the behaviour they point at.
  • The Lab migration is feat(lab): MobilePowerSearch's steps are standalone sheets that replace one another #8476, stacked on this head and reviewed separately: the one part that can ship apart does.

The owner's waiver, verbatim as recorded under Scope: "ok do we need the C -> C+ -> deprecate stack? it feels not the best way to stack the change. i don't mind large PR. i can bypass [the review size] limit". The Safari harness, the one piece with no shared dependency, was split out into chore/bottom-sheet-safari-harness before that waiver.

  • One pull request, by the owner's decision. The automated review asks for a split into the non-modal hosting, the stacking registry, the replace handoff, the deprecation and the Safari harness. The Safari harness — a person-run evidence script that ships nothing — is split out into chore/bottom-sheet-safari-harness; for the rest the owner waived the split, verbatim: "ok do we need the C -> C+ -> deprecate stack? it feels not the best way to stack the change. i don't mind large PR. i can bypass [the review size] limit". The parts share one registry, one set of records and one migration story; the four Core Changesets and the CLI Changeset stay separate so the release notes do.

Testing

pnpm exec vitest run packages/core/src/{BottomSheet,Layer,Toast,Dialog}/ packages/lab/src/ packages/cli/api/template packages/cli/authoring/doctypes/template
  + packages/cli/test/experimental-isolation.test.mjs
  Full `packages/core/src` + Lab + CLI templates: 592 files · 14,978 tests — 14,977 pass; the one failure, `postinstall.test.mjs` "still nudges when no folder up to the repository root ran init", fails identically on pristine `origin/main` on the same machine (test and script byte-identical to `main`, untouched here): environmental, not this PR's. BottomSheet, Popover (incl. #8229's presentation suite), Layer, Toast and Lab suites: 102 files / 1,666 tests pass.      (BottomSheet alone: 7 files, 342 tests; BottomSheetSwitcher.test 29/29 as on main; ToastViewport.modalHost 15 incl. main's sheet-outlet tests; experimental-isolation 34/34 with regenerated goldens)
pnpm -F @astryxdesign/core typecheck · pnpm typecheck                    ✓ 0 errors
ASTRYX_STRICT_LINT=1 eslint <every changed source, story and template>   0 errors (3 pre-existing warnings, identical on main)
prettier --check <every changed file> · git diff --check                 ✓
pnpm check:repo (visual-owner, sync, palette refs, data tokens, token docs, knowledge, package boundaries, changesets, demo media, executable bits, CLI structure, use-client, portable scripts, i18n runtime + catalog, CLDR weekdays, fixtures, setup contract)   ✓
node scripts/release/changeset-coverage.mjs pr --base <record base> --head HEAD --released v0.7.4   ✓ (four Changesets for @astryxdesign/core — stacking, replace handoff, switcher deprecation, switcher toast outlet fix — one for @astryxdesign/cli)
pnpm build                                                               ✓
VERCEL_ENV=preview VERCEL_GIT_COMMIT_SHA=$(git rev-parse HEAD) node apps/docsite/scripts/build-previews.mjs
  Exported 783 Sandbox HTML routes plus /404/; prerendered 783 pages and 73 embed documents; verified 784 HTML routes against the Next export (the sandbox route oracle and the CLI official-package list unchanged)
pnpm -F @astryxdesign/docsite build                                      ✓ (433 static pages)
pnpm storybook:build                                                     ✓
Chromium 390×844 (touch), static Storybook of this head, per-frame sampling: 110 of 110 checks pass (rerun on this head) (below; rerun on this head — the three main-vs-branch open-focus comparisons were run on the previous head against a static build of `main`, and the code they cover did not change)

Real-browser checks on this head (Chromium, Playwright 1.61, Chrome Headless Shell 149, 390×844 touch, static Storybook, per-frame ::backdrop sampling composited into the page dim): 110 of 110 pass (rerun on this head). Drag dismissals under reduced motion on a fine pointer (desktop profile, pointer: coarse false): a finger swipe driven through CDP touch events paints 10 intermediate opacity frames, holds its transform after the release frame and closes 169 ms after the fade starts; a mouse drag on the same handle closes with no intermediate opacity frame. Reduced motion (reducedMotion: 'reduce', iPhone profile for pointer: coarse and a desktop profile for a fine pointer): see the design:reduced-motion row above. Interstitial (new story): Submit by press makes Form → Processing a replace; the code-advanced Processing → Done 300 ms later is a replace too; closing Done returns focus to the control that opened the flow. Reduced motion (reducedMotion: 'reduce' emulated): the covered sheet is in its receded end state within one frame of the push and the dim is constant; on a replace the entering step is at its resting transform from its first painted frame, the retained step paints no intermediate opacity or transform values (alignment and fade each land in their end state the frame they apply), the retained dialog is closed at ~116 ms and only the new step remains, dim constant. Per-engine lone-open focus (engines-focus.txt): Chromium 149 — panel for modal, non-modal and leading-button cases; Firefox 151 — the data-autofocus field (modal), the first control Search (non-modal), the first button View (leading button); bare-dialog probe (engines-native-focusing-steps.txt): Chromium show()→container, showPopover()→first control, showModal()→container; Firefox → first control on all three. WebKit could not be launched on the node. Accessibility tree (AR3; AST-020, AST-009 FR3): from the browser's exposed tree (Playwright aria snapshot), a lone sheet exposes exactly one dialog with its name; with two and with three sheets open only the top sheet is exposed, the covered sheets are hidden; during a replace handoff the replaced sheet is hidden and the entering step is the one exposed dialog, with its name, and after the handoff likewise. A no-scrim sheet opened from a modal sheet and declared outside it is shown by show() (not a popover) while the modal is open and a press at its button does not reach it; once the modal has gone it is a popover in the top layer, pressable above the page's fixed z-index: 10 tab bar that it overlaps; after a Swap (modal closes, hint opens in one update) the hint is already in the top layer while the modal sheet is still :modal and inert mid-exit, and ends pressable above the tab bar. A step entering by a replace with an autofocused TextInput ends with that field focused, modal and non-modal alike; the same step opened alone has the panel focused. A toast raised from a deprecated switcher's step renders in the active step and is pressable — in a lone flow, in a flow stacked over a standalone sheet, and after Continue in the entering step rather than the retained one. Sheet → Dialog → sheet → close the inner sheet: a toast raised from the Dialog renders inside the Dialog, not inert, is what the pointer hits, and dismissing it leaves the Dialog open. A modal sheet with a data-autofocus field opens with the panel focused, on this head and on a static build of main alike. After Back pressed during a Continue handoff, and after a sheet reopened during its exit, a toast raised from the reopened sheet renders inside it, not inert, and is what the pointer hits. Two closes and one open from inside the top sheet (written confirmation-first) are a pop, a pop and a push: neither closing sheet is marked replaced, the new sheet is a fresh top, the dim stays at 0.5, and closing it does not return to the first sheet's opener. Lone no-scrim open from the keyboard: focus lands on the panel, on this head and on a static build of main alike; with a data-autofocus field in the sheet, the panel is still what is focused and the field is not, on both builds (the released show() behaviour, kept). A switcher flow stacked over a standalone sheet leaves when that sheet is closed from code (no dialog left open) and returns on its step when the sheet reopens. Orders drill-in to depth 3: one visible backdrop, covered sheets inert + aria-hidden with matrix(0.96…−8) / matrix(0.92…−16), dim 0.5 constant over 107 frames of pushes, Escape pops one (dim 0.5 constant), focus returns to the control inside the revealed sheet, Close all from depth 3 → focus on "Open orders". Deep link depth three: innermost on top in the first settled frame and hit-testable. Replace (hug, capped): retained step inert + aria-hidden through the handoff, dim 0.5 constant through Continue and Back, focus in the entering panel, Escape closes the flow and returns focus to the trigger. No-scrim: popover="manual", z-index: auto, dim 0, replace keeps one dialog with focus in the entering step, Escape returns focus to the trigger. Non-modal over a live page: the toast overlaps the sheet and is what the pointer hits (paints above it); focus stays in the sheet. Deprecated switcher stacked over a standalone sheet (new story): Settings covered, receded, owns the one scrim (backdrop 1), blocked by the step's modality (its "Set up notifications" control is not hit-testable; it is not marked inert, because it contains the flow's dialog — AR3); both step backdrops clear through the flow's own Continue handoff; dim 0.5 constant over 92 frames; Escape closes the flow only and focus returns to the opener inside Settings; dim 0.5 constant through the close. Deprecated switcher stacked under a standalone sheet (new story): the step covered, inert, receded, owns the scrim; Help on top with a clear backdrop; dim 0.5 constant through the push and the pop; Escape closes Help only and focus returns to the Help button in the step. Deprecated switcher alone: no stack state, one backdrop, dim constant through its own handoff, Escape returns focus to the trigger. Harness and results: chromium-cplus/check.mjs, results.json, screenshots.

Earlier revisions' evidence (Chromium parity against the switcher's handoff with and without reduced motion, Playwright WebKit, three real-Safari runs through the harness now in chore/bottom-sheet-safari-harness) is below and stands for the standalone paths, which this revision does not change; the switcher-level paths above are the ones the per-step reconciliation touched.

Prior art

@vercel

vercel Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 12:09am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 16, 2026
@astracat-bot

astracat-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 27, 2026, 12:36 AM UTC

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

@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 waits on a maintainer decision: no current spec admits stacked bottom sheets yet, so a maintainer needs to settle AST-044 (this model, #6728 or #7503) and record the chosen model in the BottomSheet and BottomSheetSwitcher specs. If this model is chosen, three things still need changes:

  • With a sheet rendered inside another sheet's content and a later sheet opened outside it, Escape closes the covered nested sheet and the top sheet declines a platform close request, which contradicts the new Stacking docs.
  • The new Effect in BottomSheet.tsx that closes nested sheets calls their onOpenChange(false) on mount and again on every rerender, so a host that opens after its child wipes the child's open state.
  • At about 3,380 changed lines, please split the change into reviewable pieces once the model is chosen.

[Automated review]

Comment on lines +391 to +394
if (isOpenProp && !isHostOpen) {
onOpenChange(false);
}
}, [isHostOpen, isOpenProp, onOpenChange]);

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 Effect calls the consumer's onOpenChange(false) in reaction to state, with onOpenChange as a dependency. An inline handler that does not close at once gets it again on every rerender, StrictMode fires it twice, and a host that has not opened yet resets a nested sheet that should appear with it. isOpen = isOpenProp && isHostOpen already keeps the dialog out of the top layer; either drop the callback or fire it once per host open → closed transition (react-component-runtime INV2/INV3).

anatomy,
description:
'A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows.',
"A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows that replace one step with the next, and open a second sheet over the first when a step should stay visible beneath it.\n\nStacking. Every sheet keeps its own isOpen. Open a sheet while another scrim sheet is open and it stacks on top: the sheet beneath stays mounted with its scroll position and inputs, recedes behind the new one (the nearest covered sheet shrinks to 0.96 and lifts 8px, deeper ones to 0.92 and 16px), and is blocked from pointer, keyboard, and assistive technology by the top sheet's modal dialog. The sheets can be siblings, live in separate components, sit inside another sheet's content, or sit inside a BottomSheetSwitcher step; a whole switcher is one level, and switching its step in place does not change its position. Nothing moves in the DOM: each sheet's dialog stays where it is written, and no wrapper component is needed.\n\nOrder is open order. The top sheet is the one presented most recently that is still open; a sheet that closes and reopens comes back on top. When several sheets open in one update (a deep link into a flow), there is no open order between them, so tree order decides: a sheet below the sheets rendered in its content, siblings in the order they are written. That order is right in the first painted frame.\n\nOne scrim. Only the bottom sheet's backdrop dims the page; the backdrops of the sheets above it are transparent, so a stack never darkens per level. The dim holds steady while sheets push and pop, and if the bottom sheet closes while sheets remain above it, the next sheet's backdrop takes over in the same frame.\n\nDismissal. Escape, a tap on the scrim, a downward swipe, and a platform close request (Android back, a close watcher) reach only the top sheet, through that sheet's own onOpenChange, and that sheet's purpose applies: required blocks all of them, form blocks the scrim and the swipe. Nothing closes a whole stack at once; set every sheet closed in one update when a flow is done and they leave together.\n\nClosing a covered sheet from code. Sheets rendered inside the closing sheet's content are part of it: they are presented only while it is open, and when it closes their onOpenChange(false) is called and they leave with it. Sheets above it that are not in its content stay open and keep their order; the closing sheet slides out from beneath them, and if it owned the scrim, the sheet above takes it over.\n\nFocus. Opening a sheet moves focus into it (a data-autofocus descendant first). Closing the top sheet returns focus into the sheet it reveals: to the control that opened it when that control is still there, otherwise to the revealed sheet's panel. When the last sheet closes, the standalone rules apply: finalFocusRef, else the element that opened the sheet, with a focus ring only after keyboard input.\n\nCaveats. A Dialog, AlertDialog, or Popover opened from a sheet paints above the sheet and does not make it recede; a sheet opened from inside that Dialog paints above the Dialog and stacks over the sheet beneath. Sheets with hasScrim={false} stay out of the stack entirely. A sheet React hides (a suspended Suspense boundary, Activity mode=\"hidden\") leaves the stack and closes its dialog; when it is shown again it is presented again, on top. Only sheets rendered in the same document stack; a sheet inside an iframe is its own stack.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Escape … and a platform close request … reach only the top sheet" does not hold when a sheet rendered inside another sheet's content is followed by a sheet opened outside it. The shared layer stack ranks React depth first, so Escape closes the covered nested sheet and the top sheet declines cancel. Either align the dismissal stack with the sheet stack or narrow this sentence to sibling and fully nested stacks.

expect(openDialogs()).toEqual([]);
});

it('a sheet in a closed sheet’s content is not presented until that sheet opens', () => {

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 title says the inner sheet is "not presented until that sheet opens", but the assertion pins onInnerChange(false), which resets the consumer's state so the inner sheet never appears when the host does open. Worth a test that opens the host one update later and expects the inner sheet to present.

@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 waits on a maintainer decision: no current spec admits stacked bottom sheets, the same-update replace, or deprecating BottomSheetSwitcher, so a maintainer needs to settle AST-044 (this model, #6728 or #7503) and record the chosen model in the BottomSheet and BottomSheetSwitcher specs. If this model is chosen, these still need changes:

  • Pressing Continue during a Back handoff keeps the old step on screen for about 400 ms before it fades (about 45 ms the other way round), because setBottomSheetOpen drops the returning sheet before its reopen arrives (bottomSheetStack.ts:885).
  • The deprecation leaves hasScrim={false} flows without a replacement, and the BottomSheet docs, the CLI templates, the switcher stories and Lab MobileTokenizer still use the switcher (AST-017 FR28, FR29b and FR29c).
  • The Effect at BottomSheet.tsx:439 calls onOpenChange(false) without naming the external system it synchronizes (react-component-runtime INV3 and INV2).
  • At 5,694 changed lines, please land the registry, the replace and the deprecation as separate pull requests once the model is chosen.

[Automated review]

// A replacing sheet that closes drops the sheet it was replacing at once,
// the way a switcher hides a retained step when the flow is left.
if (entry.replacing != null) {
finishReplace(entry.replacing);

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 drops the replaced sheet before the returning sheet's open flip arrives in the same commit. With steps written in flow order, pressing Continue during a Back handoff closes the returning step's dialog and clears its replace, so the isEnteringInPlace branch above (line 873) never runs. In Chromium the old step then starts fading at 398 ms instead of 45 ms; the switcher fades at 32 to 35 ms in both directions. Deciding the batch's pairing before dropping the sheet (for example in detectReplace) would fix it, together with a mirrored-reversal test.

* Closing the top sheet and opening the next in the same update runs the
* same replace handoff, with no `sheetId` and no switcher. The switcher keeps
* working as released until a future minor release removes it; a
* `hasScrim={false}` flow is the one case it still covers alone, because

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-017 FR28 requires a deprecation patch to ship "the working replacement while old usage remains equivalent". This line concedes that hasScrim={false} flows have no replacement, so those callers are deprecated with nowhere to migrate. Either limit the deprecation to modal flows, or hold it until non-modal sheets can replace.

@@ -194,6 +199,11 @@ export const docs = {
description:
"Don't make the sheet content overly long. Consider breaking it into steps and using Bottom Sheet Switcher.",

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 line still recommends the switcher that this PR deprecates, and so does line 424 in docsDense. AST-017 FR29b asks maintained docs, templates and stories to teach only the replacement. The CLI block templates, BottomSheetSwitcher.stories.tsx and Lab MobileTokenizer (FR29c) need the same migration.

const wasHostOpen = wasHostOpenRef.current;
wasHostOpenRef.current = isHostOpen;
if (wasHostOpen && !isHostOpen && isOpenPropRef.current) {
onOpenChangeRef.current(false);

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 Effect fires the consumer's onOpenChange(false) in reaction to host state, and its comment names no external system (react-component-runtime INV3). INV2 also keeps onOpenChange out of Effects that react to the resulting state. The once-per-transition guard fixes the duplicate calls reported earlier, but the ownership question remains: the isOpenProp && isHostOpen gate already keeps the dialog out of the top layer.

@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 waits on a maintainer decision: no current spec admits stacked bottom sheets, the same-update replace, top-layer popover hosting for every non-modal sheet, or deprecating BottomSheetSwitcher in a patch, so a maintainer needs to settle AST-044 (this model, #6728 or #7503) and record the chosen model in the BottomSheet and BottomSheetSwitcher specs. If this model is chosen, these still need changes:

  • Renaming the two Bottom Sheet flow templates moves their published ShadCN routes without aliases, which is why registry-contract and the docsite build fail; keep the old identities with registry.slug or registry.aliases and refresh the route lock.
  • Opening a lone hasScrim={false} sheet from the keyboard now focuses its content group with a visible outline instead of the panel, although the Changesets say a sheet on its own behaves as before.
  • The presentation latch useLayoutEffect in BottomSheet.tsx only sets state and names no external system (react-component-runtime INV3).
  • At 6,744 changed lines, please land the registry, the replace and the deprecation as separate pull requests once the model is chosen.

[Automated review]

exampleFor: 'BottomSheetSwitcher',
name: 'Bottom Sheet Switcher — Review flow',
displayName: 'Bottom Sheet Switcher — Review flow',
exampleFor: 'BottomSheet',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Moving exampleFor (and the rename on line 8) changes this template's ShadCN identity from example-bottom-sheet-switcher-review-flow at examples/bottom-sheet-switcher/review-flow to example-bottom-sheet-review-flow at examples/bottom-sheet/review-flow. Neither is aliased or in internal/shadcn-registry/routes.lock.json, which is what fails registry-contract and the docsite build. registry: {aliases: ['bottom-sheet-switcher/review-flow']} keeps the old route (as CollapsibleDividedAccordion.doc.mjs does); the reviewed lock then needs a refresh with UPDATE_SHADCN_ROUTE_LOCK=1.

name: 'Bottom Sheet Switcher',
displayName: 'Bottom Sheet Switcher',
alsoExampleFor: ['BottomSheet'],
name: 'Bottom Sheet — Multi-step flow',

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 rename turns the published showcase-bottom-sheet-switcher-default (showcases/bottom-sheet-switcher/default) into showcase-bottom-sheet-switcher-bottom-sheet-multi-step-flow. Adding registry: {slug: 'default'} restores the locked identity exactly while keeping the new display name.

Comment on lines +440 to +445
useLayoutEffect(() => {
if (isOpen) {
// eslint-disable-next-line @eslint-react/set-state-in-effect -- latches controlled open state for exit animation
setIsPresented(true);
}
}, [isOpen]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Split out of the Effect that called showModal(), this useLayoutEffect now only sets state, and its lint reason names no external system (react-component-runtime INV3). Deriving the latch during render, with the previous-value pattern used for settledIsOpen above, would remove the Effect.

Comment on lines +612 to +618
export function showSheetHostNonModal(dialog: HTMLDialogElement): void {
if (typeof dialog.showPopover === 'function') {
dialog.showPopover();
} else {
dialog.show();
}
}

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 Chromium, showPopover() on the NoScrim story's dialog focuses the sheet's content group (role="group", tabIndex 0), where show() focused the panel, so a keyboard-opened lone non-modal sheet now draws a 2px focus outline. The Changesets say a sheet on its own behaves as before; applying the sheet's own open-focus rule after showPopover() would keep that true.

@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 waits on an engineering owner: stacking, the same-update replace, the switcher as a stack level and the BottomSheetSwitcher deprecation implement component:BottomSheet DEC-2, FR5–FR15, AR3–AR5 and PR1–PR2 and component:BottomSheetSwitcher DEC-3, DEP-0015 and FR10, which exist only in the open record PR #8010, plus three architecture:layer-runtime passages this pull request adds. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; the decision should also settle whether AST-017 FR29c lets Lab's MobilePowerSearch keep the switcher after the deprecation patch and whether this behaviour may ship as a patch under AST-017 FR25. Whatever is decided, two changes remain:

  • A step advanced by code within a second of a press in the previous step (an interstitial "Processing" step, say) is not treated as a replace, so the flow closes with focus on the page instead of its opener; please let a press inside a sheet that is no longer open fall back to focus in actorFor, with a test.
  • At 10,486 authored lines, please land the no-scrim top-layer hosting, the switcher toast-outlet fix, the stacking registry, the replace handoff and the deprecation with its migrations as separate pull requests.

[Automated review]

Comment on lines +538 to +547
function actorFor(doc: Document): Element | null {
const press = state.lastPress.get(doc);
if (
press != null &&
press.target.isConnected &&
Date.now() - press.at <= PRESS_ACTOR_WINDOW_MS
) {
return press.target;
}
return doc.activeElement;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A press stays the actor for 1,000 ms even when its target sits in a sheet that has already been replaced and is inert. In a flow whose middle step is advanced by code shortly after Submit (an interstitial "Processing" step), the press in the first step makes detectReplace reject Processing → Done although focus is inside Processing: in a jsdom probe with the replace suite's stubs Done opens as a push (["Processing:exiting:1","Done:top:0"] at 300 ms, a replace at 1,500 ms), and in Chromium closing the flow leaves focus on body instead of the opener. DEP-0015 step 5 and the deprecation Changeset promise the handoff for "code acting while focus is in the step". Treating a press inside an inert or no-longer-open sheet as stale (falling back to the focused element), plus a test for a step advanced inside the window, would keep migrated flows' focus return.

@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 waits on an engineering-owner decision: the stacking, same-update replace and BottomSheetSwitcher deprecation implement component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, and this PR adds three new architecture:layer-runtime passages, so none of that behaviour has approved spec authority yet. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), and the decision should also settle the patch release tier and the deprecation's missing equivalents for an Escape-only onCancel veto and dialog-level props. Whatever that decision, two changes remain needed: split the 10,870-line change (the no-scrim top-layer host, the switcher toast fix and the reduced-motion fix can each land on their own), and qualify the two Changesets that say a lone modal sheet "behaves exactly as before", since the reduced-motion Changeset changes it.

[Automated review]

'@astryxdesign/core': patch
---

[feat] `BottomSheet` stacks by open order. A modal sheet opened while another modal sheet is open now stacks on top of it instead of presenting as a second, unrelated dialog: the covered sheet stays mounted with its state, recedes behind the top sheet (0.96 / −8px at depth 1, 0.92 / −16px from depth 2), and is blocked from pointer, keyboard, and assistive technology; only the bottom sheet's backdrop dims the page, so a stack no longer darkens per level; and Escape, a scrim tap, a swipe, and a platform close request pop only the top sheet, through that sheet's own `onOpenChange` and honouring its `purpose`. The top sheet is the most recently opened one still open, wherever the sheets are written — siblings, separate components, inside another sheet's content, or inside a `BottomSheetSwitcher` step; a whole switcher is one level, and switching its step keeps its place. When several sheets open in one update (a deep link), tree order decides and the first painted frame is already right: a sheet nested in another sheet's content no longer opens beneath its parent. Closing the top sheet returns focus into the sheet it reveals; closing every sheet of a stack in one update returns focus once, to the bottom sheet's `finalFocusRef` or opener, after the last dialog has closed (each sheet's own return used to be refused while the others were still modal). A sheet rendered inside a closing sheet's content now leaves with it instead of staying open invisibly, and comes back with the host if its own `isOpen` is still true (its state is never written for it); sheets above it that are not in its content stay open. Sheets with `hasScrim={false}` never stack and are never stacked on. No prop, type, or export changes; a modal sheet on its own behaves exactly as before.

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 ends "a modal sheet on its own behaves exactly as before", but .changeset/bottom-sheet-reduced-motion-cross-fade.md changes that sheet's entrance and exit under prefers-reduced-motion: reduce (it now fades in from opacity 0 on a coarse pointer). Qualify it, for example "apart from the reduced-motion fix", so the release notes agree.

'@astryxdesign/core': patch
---

[feat] `BottomSheet` replaces a sheet that closes in the same update another opens. When the top sheet of a stack closes and exactly one other sheet opens in the same update (one state value naming the open step does this), the new sheet replaces it instead of stacking on it: it rises above the replaced sheet, a taller replaced sheet moves down to meet its top edge at the same time, the replaced sheet fades only after both motions finish and is inert and hidden from assistive technology from the start, the scrim holds steady with no dip, focus moves into the new sheet, and closing the flow later returns focus to the element that opened its first sheet. This is the handoff `BottomSheetSwitcher` runs between its steps, now available to standalone sheets; a multi-step flow can be written as one `BottomSheet` per step with no switcher. A covered sheet closing while a sheet opens, several sheets changing at once, or a close and an open in different updates keep the ordinary push and pop motion, and existing stacking behaviour is unchanged. A replace is recognised only when the move was made from inside the closing sheet: the control last pressed (or, with no recent press, the focused element) was in it, as a step's own Continue or Back button is; a close and an open driven from a control outside it, by two unrelated owners, stay a pop and a push with their own focus return. Non-modal sheets (`hasScrim={false}`) replace each other the same way: they are now hosted in the browser top layer as manual popovers, with no scrim, focus trap or scroll lock as before, so a non-modal step orders above the one it replaces, and the toast viewport re-enters the top layer above a non-modal sheet as it does above a modal. The non-modal sheet's page-level `z-index` is gone with the move (AST-027); below the Popover API support floor the sheet falls back to `show()` in page stacking order, usable but with no promise over later page content (the reduced fallback AST-003 describes). They still never stack. A non-modal sheet now also returns focus to its opener when it closes while the person is still in it (a modal sheet always did); focus the person took elsewhere on the live page stays there. The private stack registry now keeps one state per page (shared by every copy of the package on it, guarded by shape) and one stack per document. No prop, type, or export changes; a modal sheet on its own behaves exactly as before. A non-modal sheet on its own changes in three ways with its new host: it is in the top layer (a manual popover) instead of page stacking order, its page-level `z-index` is gone, and it returns focus to its opener on close while focus is still inside it; its scrim-less, scroll-free, page-interactive contract is unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same as the stacking Changeset: "a modal sheet on its own behaves exactly as before" contradicts the reduced-motion Changeset released alongside it. Qualify the sentence.

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages added here, so none of it has approved spec authority on main yet. Any engineering owner listed in .github/ENGOWNERS, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request, and the decision should also settle the patch release tier under AST-017 FR25 and whether Lab's MobilePowerSearch may keep the switcher under FR29c. Whatever is decided, three changes remain:

  • Under reduced motion a mouse or pen drag dismissal cross-fades on a fine pointer, where the reduced-motion rule allows that only for a touch drag's release; set isDismissedByDrag only for a touch pointer, and clear it where a drag starts rather than in the reopen effect (useSheetGestures.ts, BottomSheetPanel.tsx).
  • Split the 10,965-line change: the no-scrim top-layer host, the switcher toast outlet and the reduced-motion fix can each land on their own, ahead of the stack, the replace and the deprecation.
  • The deprecation Changeset, the switcher's @deprecated JSDoc, its docs and its story say a lone switcher flow behaves as released apart from the toast fix, but the reduced-motion fix changes its steps too.

[Automated review]

'@astryxdesign/core': patch
---

[feat] `BottomSheetSwitcher` and the `sheetId` prop of `BottomSheet` are deprecated. The replacement is already in this release: write each step of a multi-step flow as its own standalone `BottomSheet` with `isOpen`, driven by one state value that names the open step; closing the top sheet and opening the next in the same update runs the same replace handoff the switcher ran (entering step above, a taller retained step aligns down and fades after both motions, one steady scrim, focus into the new step and back to the flow's opener on close). Migration, per flow: (1) replace the `activeSheet` / `onActiveSheetChange` pair with one `step` state value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"` (the guard keeps a step that is being replaced from clearing the step the person just chose); (3) remove the `BottomSheetSwitcher` wrapper, moving its `hasScrim` onto every step; (4) keep the step buttons as they were (`setStep('next')`). The handoff runs when the move is made from inside the open step (a step's own Continue or Back, or code while focus is in it); a step changed from a control outside the flow's sheets — a page control beside a `hasScrim={false}` flow, or code after focus has left the step — closes and opens with the sheets' own push and pop motion, where the switcher handed off on any `activeSheet` change. The switcher's other props map as follows: `onCancel` becomes a guarded `onOpenChange` on each step (one that ignores `false` while the flow must stay open), which keeps the step's dialog role — a standalone sheet reports Escape, the platform close, the scrim tap and the swipe all as `onOpenChange(false)` with no reason, so the guard holds the step open against all of them while it applies, where `onCancel` vetoed only the cancel request; a veto of Escape alone has no equivalent, and the only per-path lever is `purpose` (`form` keeps Escape and blocks the scrim tap and the swipe; `required` blocks all three and exposes the step as an `alertdialog`). A consumer `aria-label` / `aria-labelledby` becomes each step's `label`, which names its dialog. `ref`, `id`, `data-testid`, `className`, `style`, `xstyle` and the other dialog props have no dialog-level equivalent — a standalone sheet's `ref` and base props reach its panel `<div>` and its dialog is implementation — so a test or an anchor that needs the dialog queries the panel's `closest('dialog')`, and dialog-level styling is dropped. Flows that keep using the switcher keep working: a switcher flow used on its own behaves as released — with one fix, released alongside: a toast raised from a step now renders above the flow and can be pressed — and when it is stacked with other sheets the stacking behaviour of this release applies to it as one level (it recedes and is blocked when covered, shares the one scrim, stands down from dismissal while covered, and returns focus by the stacking rules). Its declarations and docs are marked `@deprecated` and its docs, templates and stories show only the replacement, until a future minor release removes it. Non-modal flows (`hasScrim={false}`) migrate the same way: non-modal sheets replace each other too.

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 says a lone switcher flow "behaves as released — with one fix", the toast outlet, but .changeset/bottom-sheet-reduced-motion-cross-fade.md also changes "a BottomSheetSwitcher step's" entrance, exit and handoff under reduced motion (switcher steps render the shared BottomSheetPanel). Please name the reduced-motion fix here too, as the stacking and replace Changesets already do, and in the matching @deprecated JSDoc, BottomSheetSwitcher.doc.mjs and the switcher story description.

// fades out in place on every pointer. DR3 covers the rest.
const isCoarseCrossFade = useMediaQuery(REDUCED_MOTION_COARSE_QUERY);
const isCrossFade =
isCoarseCrossFade || (isReducedMotion && isDismissedByDrag);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isDismissedByDrag is set for any primary button-0 drag (useSheetGestures.ts), so a mouse or pen drag dismissal cross-fades here for 200 ms on a fine pointer. design:reduced-motion gives the cross-fade only to "a transition a component starts when a touch drag is released", and DR3 asks every other fine-pointer transition to complete immediately. Setting the flag only when the releasing pointer's pointerType is touch (with a mouse case in the test) would match the record and this PR's own Changeset wording.

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, plus three architecture:layer-runtime passages added here, so none of it has approved spec authority yet. Any engineering owner listed in .github/ENGOWNERS, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request; the decision should also settle the patch release tier under AST-017 FR25 and whether Lab's MobilePowerSearch may keep the switcher under FR29c. Two changes remain whatever is decided:

  • Split the 10,993 authored lines into separately landable pull requests: the reduced-motion fix, the switcher toast outlet and non-modal top-layer hosting can each land ahead of the stack, the replace and the deprecation.
  • Give the new setIsDismissedByTouchDrag(false) reset in useSheetGestures.ts a reason that names what it synchronizes (react-component-runtime INV3), or derive it during render.

[Automated review]

// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
setIsTraveling(false);
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
setIsDismissedByTouchDrag(false);

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 new reset sits in the controlled-reopen effect with the reason "resets gesture state on controlled reopen", which names no external system or measurement (react-component-runtime INV3). The flag is new in this PR and is read only while closing; resetting it during render when isOpen turns true (as StandaloneBottomSheet does for isPresented) would avoid the effect-owned state.

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher requirements that exist only in the open record PR #8010, plus three architecture:layer-runtime passages added here, so none of it has approved spec authority yet. Any engineering owner listed in .github/ENGOWNERS, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request; the same decision should settle the patch release tier under AST-017 FR25 and whether Lab's MobilePowerSearch may keep the switcher under FR29c. Two changes remain whatever is decided:

  • Split the 11,036 authored lines into separately landable pull requests: the reduced-motion fix, the switcher toast outlet and the no-scrim top-layer host can each land ahead of the stack, the replace and the deprecation.
  • After a touch swipe the owner refuses with a guarded onOpenChange, a later close cross-fades for 200 ms under reduced motion on a fine pointer, where design:reduced-motion DR3 asks for an immediate change; clear the touch-drag flag in useSheetGestures.ts whenever the sheet is still open after the release, and add a test for that path.

[Automated review]

Comment on lines +484 to +492
const [touchDragDismissal, setTouchDragDismissal] = useState(false);
const [wasOpen, setWasOpen] = useState(isOpen);
if (isOpen !== wasOpen) {
setWasOpen(isOpen);
if (isOpen && touchDragDismissal) {
setTouchDragDismissal(false);
}
}
const isDismissedByTouchDrag = touchDragDismissal && !isOpen;

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 flag is cleared only when isOpen flips from false to true. A touch swipe the app refuses (a guarded onOpenChange, the documented replacement for the switcher's onCancel) leaves isOpen true, so touchDragDismissal stays set. The next close from a control, Escape or code then makes isDismissedByTouchDrag true and cross-fades for 200 ms on a fine pointer under reduced motion, where design:reduced-motion DR3 asks for an immediate change. Clearing it whenever the sheet is still open after the release (if (isOpen && touchDragDismissal) setTouchDragDismissal(false), without the wasOpen guard) keeps only a close that lands with the release. A test for a refused swipe followed by a control close would pin it.

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, and no current spec on main admits them yet. An engineering owner listed in .github/ENGOWNERS, including the author, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the change as a patch under spec:AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Two changes are needed whatever is decided:

  • Land the hasScrim={false} top-layer host, the switcher toast outlet and the reduced-motion fix as their own pull requests, since the change is about 11,100 authored lines.
  • A hasScrim={false} sheet opened while a Dialog outside it is open stays in show() beneath page content once that Dialog unmounts, because an unmounted dialog fires no close; promote deferred sheets without relying on close, with a test for that path.

[Automated review]

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, so no current spec on main admits them yet. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the change as a patch under AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Whatever is decided, three changes are needed:

  • Split the change (about 11,100 authored lines): the hasScrim={false} top-layer host, the switcher toast outlet and the reduced-motion fix can each land on their own pull request.
  • A hasScrim={false} sheet opened while an outside Dialog is open stays in show() beneath page content once that Dialog unmounts, because an unmounted dialog fires no close; promote deferred sheets without relying on close, and add a test that unmounts the Dialog.
  • When one sheet of a “Close all” unmounts before its exit ends, focus never returns to the opener, because the unregister path never completes the group; complete it there directly, with a test.

[Automated review]

Comment on lines +882 to +888
doc.addEventListener(
'close',
() => {
promoteDeferredNonModal(doc);
},
true,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A deferred hasScrim={false} sheet is promoted only on a document close event or the next sheet-stack update. An open <dialog> removed from the document fires no close (modalOutlet.ts says the same), so when the blocking Dialog unmounts while open — {isEditing && <Dialog isOpen …>} closed by its own button — the sheet stays in show() with no z-index, and a fixed page bar paints over it and takes its presses. In Chromium the press at the centre of "Got it" reaches the page’s z-index: 10 tab bar on this head and the button on the base. Re-check deferred sheets when a blocking modal leaves the document without a close, and add a test that unmounts an open Dialog over a deferred sheet.

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, so no current spec on main admits them yet. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the change as a patch under AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Whatever is decided, these changes are needed:

  • Split the change (about 11,300 authored lines): the hasScrim={false} top-layer host, the switcher toast outlet and the reduced-motion fix can each land on their own pull request.
  • A hasScrim={false} sheet opened while a MobileNav or Lightbox is open stays in show() beneath page content once that modal unmounts, because the removal check only recognises dialogs with aria-modal="true"; re-check waiting sheets on any dialog removal and add a test that unmounts an open MobileNav.
  • When one sheet of a "Close all" unmounts before its exit ends, focus never returns to the opener because the unregister path never completes the group; complete it there, with a test.
  • The "unmounts while open" and "stacked over a standalone sheet" stories put their controls in the page behind a modal, where nobody can press them; move those controls into the modal's content.

[Automated review]

// `aria-modal` is the mark to read here.
if (
dialogs.some(
dialog => dialog.open && dialog.getAttribute('aria-modal') === 'true',

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 only promotes a deferred sheet when the removed dialog carries aria-modal="true", but hasOpenModalOutside defers under any :modal dialog. MobileNav and Lightbox present with showModal() and set no aria-modal, so a hasScrim={false} sheet opened while one of them is open stays in show() under page content after it unmounts (Chromium: the press at "Got it" lands on a z-index: 10 tab bar). Since promoteDeferredNonModal re-checks hasOpenModalOutside anyway, re-checking on the removal of any dialog would cover them; a test that unmounts an open MobileNav over a deferred sheet would pin it.

// Keep the membership alive for the host's own report: the entry
// is gone from `entries`, so key the report by the group directly.
state.closingTogether.delete(entry);
pendingHostReports.set(key, closingGroup);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a standalone sheet unmounted mid-exit the dialog is still open here, so the group is parked for a host report that only handleMotionComplete sends, and an unmounted host never reaches it: the group's pending set never empties and focus never returns (jsdom probe: "Close all" with the upper sheet unmounted mid-exit leaves focus on body). The comment above says this case is reported here; removing the entry from pending and calling completeClosingGroup after entry.hide() would match it.

<HStack gap={2}>
<Button label="Edit" onClick={() => setIsEditing(true)} />
<Button
label="Show hint"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Show hint" and "Discard" sit in the page behind a modal Dialog, where the browser makes them inert, so a person cannot walk through this story's scenario; moving both into the Dialog's content (as the Settings story does) keeps it reproducible by hand.

@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 waits on an engineering-owner decision: stacking, the same-update replace, the switcher as a stack level and the BottomSheetSwitcher deprecation implement component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, and this pull request adds three architecture:layer-runtime passages, so no current spec on main admits them yet. Any engineering owner listed in .github/ENGOWNERS, which includes the author, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles leaving Lab's MobilePowerSearch on the switcher under AST-017 FR29c and shipping the change as a patch under FR25. Whatever is decided, these changes are needed:

  • Split the change (about 11,400 authored lines): the hasScrim={false} top-layer host, the toast-hosting changes and the reduced-motion fix can each land on their own pull request.
  • A hasScrim={false} sheet waiting under an outside modal is never moved into the top layer when the modal still blocking it opened after it started waiting and then unmounts while open; record those blockers when the re-check keeps the sheet waiting (bottomSheetStack.ts, around line 948) and add a test that swaps one open Dialog for another and unmounts the second.
  • The "Stacked over a standalone sheet" story keeps "Close settings" in the page behind the modal Settings sheet, where nobody can press it; move it into the sheet's content.

[Automated review]

deferred.delete(dialog);
continue;
}
if (hasOpenModalOutside(dialog)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When this re-check keeps the sheet waiting because another modal is now open, that modal is never added to entry.blockers (blockers are recorded only in deferTopLayerEntry, lines 896–899). If one conditionally rendered Dialog is replaced by another in one update and the second later unmounts while open, its removal is not a recognised blocker and the sheet stays in show() with z-index: auto after every modal is gone (a jsdom probe at this head: showPopover() is never called). Recording openModalsOutside(dialog) here before continue (or re-checking on any dialog removal), with a test for that sequence, would keep spec:AST-027 FR8.

{/* Closes the host from the page while the flow is open: the flow
leaves with its host and returns with it, its step unchanged. */}
<Button
label="Close settings"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Close settings" sits in the page behind the modal Settings sheet, so the browser makes it inert exactly while the scenario it exists for (closing the host while the flow is open) is on screen. Moving it into the Settings sheet's content, as the stacking story now does with its Dialog controls, keeps the story reproducible by hand.

@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 waits on an engineering-owner decision: the stacking, same-update replace, switcher-as-a-stack-level and BottomSheetSwitcher deprecation behaviour implements component:BottomSheet and component:BottomSheetSwitcher rows that exist only in the open record PR #8010, plus three new architecture:layer-runtime passages, and no current spec on main admits them yet. An engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request, which also settles shipping the whole change as a patch under spec:AST-017 FR25 and leaving Lab's MobilePowerSearch on the switcher under FR29c. Whatever is decided, these changes are needed:

  • Land the hasScrim={false} top-layer host, the toast-outlet changes and the reduced-motion rewrite as their own pull requests: at about 11,400 authored lines this one is too large to land as a unit, and those three parts already have current authority on their own.
  • Keep focus in a sheet opened over a covered switcher flow when code changes the flow's step: today the field the person is typing in is blurred and focus lands on the sheet's panel (bottomSheetStack.ts:726).

[Automated review]

@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 waits on one engineering-owner decision. The three architecture:layer-runtime passages this PR adds are not approved for this head, and they must be read beside main's new INV14, which gives top-layer mounting to the shared layer runtime. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request; the stacking, replace, no-scrim hosting and deprecation themselves match the component:BottomSheet and component:BottomSheetSwitcher records that landed with #8010. Whatever is decided, three changes are needed:

  • Split the roughly 11,400 authored lines along the seams the Changesets already draw: the reduced-motion rule, the switcher toast outlet and the no-scrim host first, then stacking with the replace, then the deprecation.
  • Keep focus in the field the person is typing in when code switches the step of a switcher flow that a sheet covers (today it lands on the covering sheet's panel), with a test whose showModal stub moves focus.
  • Retarget the pull request to main and rebase, because its base is the record branch that already landed, so CI has not run and both component records conflict with main.

[Automated review]

}
}
for (let index = kept; index < presentable.length; index += 1) {
presentable[index].show();

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 re-show runs the sheet's showModal(), whose focusing steps move focus to its panel, and focus('open') below then leaves it there. When code switches the step of a switcher flow that this sheet covers (notifyBottomSheetHostChanged), a person typing in the top sheet loses focus and the next keystrokes. In Chromium, typing "abc" in the field and then letting the flow move from step one to step two leaves focus on the panel, and "de" never reaches the field. Keeping the active element across the re-show, as closeDialogKeepingFocus does for the hide, and focusing only a sheet that newly opened would fix it. A stacking test whose showModal stub moves focus would pin it.

@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 waits on an engineering-owner decision about the three architecture:layer-runtime passages this pull request adds, including how they read beside main's INV14, which gives top-layer mounting and the backdrop tint to the shared layer runtime. Any engineering owner listed in .github/ENGOWNERS, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request. The stacking, replace, no-scrim hosting and deprecation themselves match the component:BottomSheet and component:BottomSheetSwitcher records on main. Whatever is decided, these need changes:

  • When code changes the step of a switcher flow that a standalone sheet covers, Layer/modalOutlet.ts pushes the re-supplied outlet above the top sheet, so toasts render in the covered, inert step (component:BottomSheet FR13). Keep the group's top as the latest outlet, and add a modal-host test for that sequence.
  • At about 11,700 authored lines, please split along the Changeset seams (reduced motion, the switcher toast outlet, the no-scrim host, the deprecation, the layer-runtime edit), or document the exception.
  • Retarget to main and rebase. The base branch already landed as #8010, so after the rebase the record edits apply cleanly and CI runs.

[Automated review]

Comment on lines +560 to +566
useModalOutlet(
hostDialogRef,
isModal && isFlowOpen,
stackView.isTop || !stackView.isStacked,
'bottom-sheet',
hostSheet,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When code changes the step of a flow that a standalone sheet covers, this re-supplies the outlet for the new step's dialog, and addOutlet in Layer/modalOutlet.ts pushes it to the end even though isLatest is false here; the covering sheet's raise effect does not run again. The covered step's dialog (inert, aria-hidden) then hosts the toast viewport, so a toast raised from the top sheet renders inside it and, in Chromium, a press on it reaches the top sheet's control beneath, against component:BottomSheet FR13. Inserting a re-supplied, not-latest outlet beneath its group's top (or re-raising the group's latest), with a modal-host test for this sequence, would keep toasts in the top sheet.

@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.

This pull request can't be approved yet: it adds text to established records (three architecture:layer-runtime passages on dismissal stand-down, toast-outlet order and toasts above a no-scrim sheet; a Current-gaps sentence placing BottomSheet's own top-layer mounting inside the INV14 gap; and an element-jobs sentence in component:BottomSheet), so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author, records that decision by commenting /approve-spec <full head SHA> on this pull request. The stacking, replace, no-scrim hosting and deprecation themselves match the current component:BottomSheet and component:BottomSheetSwitcher records, and three changes are needed whatever the decision:

  • Let a package copy with a different stack-state shape join the one stack per document, or have an engineering owner scope PR1 to copies that share the shape (bottomSheetStack.ts:359).
  • Remove the document press and close listeners when a document's last sheet leaves, or name that lifetime in component:BottomSheet with a single-installation test (architecture:react-component-runtime INV5).
  • Split the roughly 11,900 authored lines along the seams the Changesets already draw.

[Automated review]

Comment on lines +503 to +516
function listenForPresses(doc: Document): void {
if (state.listenedDocuments.has(doc)) {
return;
}
state.listenedDocuments.add(doc);
const record = (event: Event) => {
if (event.target instanceof Element) {
state.lastPress.set(doc, {target: event.target, at: Date.now()});
}
};
doc.addEventListener('pointerdown', record, true);
doc.addEventListener('click', record, true);
doc.addEventListener('keydown', record, true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

listenForPresses installs these three capture-phase listeners with a document's first sheet, and nothing removes them; deferTopLayerEntry (:910-916) does the same for close. The recorded target is held strongly by the WeakMap value (the comment at :500-501 says weakly), so an element removed by its own click stays reachable until the next press. architecture:react-component-runtime INV5 lets a document-owned singleton persist across consumer gaps only when current authority names that lifetime and a test proves one shared installation. Removing the listeners when the document's last sheet leaves the registry, and keeping the target behind a WeakRef, would meet it.

@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.

This pull request can't be approved yet: it changes established records, so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author, records the decision by commenting /approve-spec <full head SHA>; it needs to settle the new component:BottomSheet PR1 wording that lets package copies on different stack protocol versions keep separate stacks (and whether DEC-2, which still says one stack across package copies, should match), the three architecture:layer-runtime passages with their INV14-gap sentence, and the element-jobs sentence. The stacking, replace, no-scrim hosting, toast outlets, reduced motion and deprecation otherwise match the current component records, and the listener lifetime is fixed; two changes are needed whatever the decision:

  • Split the roughly 12,000 authored lines along the seams the Changesets already draw (no-scrim host, toast outlets, reduced motion, deprecation), or document the exception.
  • Make .changeset/bottom-sheet-replace-handoff.md and the bottomSheetStack.ts header say the shared state is shared only by copies on the same stack protocol version.

[Automated review]

Comment on lines +241 to +244
document-global key the copies meet under; a copy on another protocol
version keeps a stack of its own, ordering only the sheets it renders, so
two copies of different versions on one page are two independent stacks
and neither reads the other's state. No public API reaches the ordering.

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 narrows the current PR1 ("Sheets rendered by every copy of the package loaded on a page … form one stack per document"): copies on different stack protocol versions would keep two stacks. DEC-2 in this record still says "The ordering is one per document across package copies and React roots (PR1)". An engineering owner needs to accept the new scope for this head (/approve-spec <full head SHA>), and DEC-2 should say the same thing as PR1 either way.

'@astryxdesign/core': patch
---

[feat] `BottomSheet` replaces a sheet that closes in the same update another opens. When the top sheet of a stack closes and exactly one other sheet opens in the same update (one state value naming the open step does this), the new sheet replaces it instead of stacking on it: it rises above the replaced sheet, a taller replaced sheet moves down to meet its top edge at the same time, the replaced sheet fades only after both motions finish and is inert and hidden from assistive technology from the start, the scrim holds steady with no dip, focus moves into the new sheet, and closing the flow later returns focus to the element that opened its first sheet. This is the handoff `BottomSheetSwitcher` runs between its steps, now available to standalone sheets; a multi-step flow can be written as one `BottomSheet` per step with no switcher. A covered sheet closing while a sheet opens, several sheets changing at once, or a close and an open in different updates keep the ordinary push and pop motion, and existing stacking behaviour is unchanged. A replace is recognised only when the move was made from inside the closing sheet: the control last pressed (or, with no recent press, the focused element) was in it, as a step's own Continue or Back button is; a close and an open driven from a control outside it, by two unrelated owners, stay a pop and a push with their own focus return. Non-modal sheets (`hasScrim={false}`) replace each other the same way: they are now hosted in the browser top layer as manual popovers, with no scrim, focus trap or scroll lock as before, so a non-modal step orders above the one it replaces, and the toast viewport re-enters the top layer above a non-modal sheet as it does above a modal. The non-modal sheet's page-level `z-index` is gone with the move (AST-027); below the Popover API support floor the sheet falls back to `show()` in page stacking order, usable but with no promise over later page content (the reduced fallback AST-003 describes). They still never stack. A non-modal sheet now also returns focus to its opener when it closes while the person is still in it (a modal sheet always did); focus the person took elsewhere on the live page stays there. The private stack registry now keeps one state per page (shared by every copy of the package on it, guarded by shape) and one stack per document. No prop, type, or export changes; a modal sheet on its own behaves exactly as before (apart from the reduced-motion fix released alongside). A non-modal sheet on its own changes in three ways with its new host: it is in the top layer (a manual popover) instead of page stacking order, its page-level `z-index` is gone, and it returns focus to its opener on close while focus is still inside it; its scrim-less, scroll-free, page-interactive contract is unchanged.

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 note says the registry keeps "one state per page (shared by every copy of the package on it, guarded by shape)", but the state key now carries the protocol version (bottomSheetStack.ts:342-346), so a copy on another version keeps its own stack. Suggest matching whatever PR1 ends up saying, for example "shared by every copy on the same stack protocol version".

@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.

This pull request can't be approved yet: it changes established records — component:BottomSheet PR1 and DEC-2 now let package copies on different stack protocol versions keep separate stacks, and it adds an element-jobs sentence and three architecture:layer-runtime passages — so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author if they are one, records that decision by commenting /approve-spec <full head SHA> on this pull request. Independently of that decision, please split the 12,066 authored lines into separately reviewable pull requests (for example reduced motion and the no-scrim host, then the stack with the replace, then the deprecation), update the bottomSheetStack.ts header comment that still says every package copy shares one state, and correct the description's statement that the component records change by deletion only.

[Automated review]

* The state lives on `globalThis` so that two copies of this module on one
* page share one stack (see StackState); everything the reconcile reads,
* including each document's shown order and which documents already have
* the press listener, is in that shared state and none of it is per copy.

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 header still says every copy of the module shares one stack and that nothing the reconcile reads is per copy. On this head the state key carries the protocol version (L344–L347), and focusToKeep (L800) and deferredTopLayer (L937) are module-level, so they are per copy. Please state the version scope here and either move both maps into StackState or name them as per copy.

@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.

This pull request can't be approved yet: it changes established records — component:BottomSheet PR1 and DEC-2 now let package copies on different stack protocol versions keep separate stacks, and it adds an element-jobs clause and architecture:layer-runtime passages — so an engineering owner has to accept that text for this exact head. Any engineering owner listed in .github/ENGOWNERS, including the author if they are one, records that decision by commenting /approve-spec <full head SHA> on this pull request. Independently of that decision, please split the 12,143 authored lines into separately reviewable pull requests (for example reduced motion and the no-scrim host, then the stack with the replace, then the deprecation), or document the size exception with decomposition or necessity evidence.

[Automated review]

@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 1797865

…te switch, and BottomSheetSwitcher is deprecated

Modal bottom sheets form one stack per document, ordered by open order
(ties within one commit by tree order), with one visible scrim, covered
sheets receded and blocked, the native top layer reconciled before paint,
and dismissal reaching only the top sheet. A sheet rendered in a closing
sheet's content leaves with it and comes back with it while its own
isOpen is still true; its state is never written for it. A whole
BottomSheetSwitcher flow is one level of the stack.

When the top sheet closes and exactly one other sheet opens in the same
update, from a control inside the closing sheet, the opening sheet
replaces it with the switcher's handoff: it enters above the retained
sheet, a taller retained sheet aligns down to meet it, the retained sheet
fades after both motions and keeps the scrim until the replacing sheet
takes it over with no transition, and focus moves into the entering
sheet. Unrelated owners acting in one update keep their own push and pop
motion and their own focus return.

Non-modal sheets (hasScrim={false}) are hosted in the top layer as manual
popovers (show() below the Popover API floor), with no scrim, scroll lock
or focus trap as before; they never stack, but they replace each other
the same way, return focus to their opener on close while focus is still
inside, and the toast viewport re-enters the top layer above them.

The private registry keeps one state per page (shared by every copy of
the package, guarded by shape) and one stack per document; closes driven
by the registry never move focus; a change of scrim owner lands with a 0s
inline pin for two frames; closing every sheet of a stack in one update
returns focus once, to the bottom sheet's target, after the last dialog
has closed.

A BottomSheetSwitcher flow, whose steps each own a native dialog, is one
level of the stack: the active step's dialog is the level, a tree anchor
keeps same-commit ties stable across handoffs, the registry is told when
a handoff changes the dialog so the sheets above the flow stay above it,
the flow's scrim is multiplied by the stack's, and covered step dialogs
carry the stack state.

BottomSheetSwitcher and BottomSheet's sheetId are deprecated in favour of
one standalone sheet per step driven by one state value; a flow used on
its own behaves as released, and a flow stacked with other sheets is one
level of the stack, until a future minor removes them. The CLI block
templates, the Lab MobileTokenizer and the stories teach the replacement.

A modal standalone sheet is a toast outlet only while it is the open sheet
on top; a covered sheet hands the outlet to the sheet above it and takes
it back when that sheet closes.

Implements component:BottomSheet DEC-2 / FR5–FR12 / AR3–AR4,
component:BottomSheetSwitcher FR10 and DEP-0015, and the
architecture:layer-runtime toast re-entry; deletes their implementation
gap notes and adds the stacking and replace suites to verified_by.
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 20ed66a

@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.

This pull request needs changes before it can be approved. The stacked-sheet behaviour implements the current component:BottomSheet and component:BottomSheetSwitcher requirements, and this head's record edits carry an engineering owner's exact-head /approve-spec, so authority is settled; the remaining problem is size: 12,413 authored lines bundle six separately releasable Changesets. Please split it into separately reviewable pull requests (for example the registry with stacking and the replace, the no-scrim popover host, toast-outlet ordering, the reduced-motion fix, and the deprecation with its docs, templates and Lab migration), or document the size exception with evidence that these parts cannot ship apart. No other defect turned up in the review.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — 20ed66a1 Deployed Oct 27, 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant