Skip to content

spec(core): BottomSheet stacks by open order and replaces on a same-update switch; BottomSheetSwitcher is deprecated - #8010

Merged
imdreamrunner merged 1 commit into
mainfrom
spec/bottom-sheet-open-order-stacking
Oct 26, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
spec/bottom-sheet-open-order-stacking

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Exact decision needed

How two or more bottom sheets presented in one document relate to one another, and what happens to BottomSheetSwitcher once standalone sheets can do a flow's handoff on their own. Three claims, none settled by a current record today:

  1. Stacking. A modal BottomSheet opened while another is open stacks on it: ordered by open order, one visible scrim, the covered sheet receded and blocked, dismissal reaching only the top sheet, a sheet nested in another's content leaving and returning with its host, focus returned into the revealed sheet on a pop and once to the first opener on a close of the whole stack.
  2. Replace. When the top sheet closes in the same update that one other standalone sheet opens, from a control inside the closing sheet, the opening sheet replaces it with the switcher's handoff motion — so a multi-step flow is one standalone sheet per step and one state value. Non-modal sheets (hasScrim={false}) are hosted in the top layer as manual popovers, never stack, and replace each other the same way.
  3. The switcher's lifecycle. BottomSheetSwitcher keeps working, deprecated: supported and maintained, every requirement of its record in force, docs and templates pointing to standalone step sheets, removal only in a future declared minor.

The architecture:layer-runtime facts the model depends on (the sheet stack standing down from the shared dismissal stack, which sheet is the latest toast outlet, and the toast re-entry above a non-modal sheet) are recorded here as component:BottomSheet rows with gap notes — layer-runtime describes the runtime shipped on main, by its own scope sentence, so it is not edited by this record; the implementation adds those sentences to it when it ships them, as a records change in the implementation pull request (the precedent the per-step switcher change set).

Existing authority searched

  • component:BottomSheet (current): FR1–FR4, AR1–AR2, DEC-1, OQ1. Owns the native dialog every sheet renders and standalone presentation; says nothing about two open sheets, and its Public concepts section says no public concept is introduced. Nothing settles order, scrim ownership, or focus between sheets.
  • component:BottomSheetSwitcher (current): FR1–FR9, AV1–AV2, ORD1–ORD2, PR1–PR2, AR1–AR3, DEC-1 (each step owns its native dialog), DEC-2 (the host dialog's identity is implementation), OQ1–OQ3. Owns a flow's own handoff. Its ownership boundary said an ordered visible sheet stack is "proposed separately by open BottomSheetStack work and not part of this component" — a pointer to a proposal, not a claim.
  • architecture:layer-runtime (current): owns the modal outlet registry and the toast viewport's promotion into an open Dialog; silent on a non-modal top-layer surface that is not an outlet.
  • spec:AST-027 (current) FR8 asks for top-layer hosting of non-modal surfaces and lists the sheets' show() + page-level z-index path as a conformance gap; FR4 forbids a new page band. spec:AST-017 (current) FR26 (lifecycle states), FR28 (a patch prepares a break with a deprecation; DEP-* records describe and never gate), FR29/FR29a/FR29b (no runtime behavior; declarations and docs carry the route). spec:AST-002 DEC-5 requires current authority for behavior changes. family:overlay-dismissal FR2–FR3 own Escape order.
  • component:Selector DEP-0011 / CLN-0020 is the DEP record shape followed here; component:Drawer DEC-4 and the Tokenizer DEC-3 amendment are the shape for amending a current component record in place with present-tense "Implementation gap" notes.
  • Open record work: the AST-044 system-spec draft (model A: an ordered path on the switcher) is not on main. No other open record pull request touches these three files.

Canonical owner. The existing current component records are amended rather than a new AST-044 record created. The facts are component-local: which sheet is on top, which backdrop paints, where focus goes, and the switcher's lifecycle are component:BottomSheet and component:BottomSheetSwitcher claims, and architecture:knowledge-contracts INV2 puts aggregate observable component behavior in the component record. A system record would exist only to compare three candidate models, which is research (R2) and a pull-request verdict (INV10), not a durable fact boundary. The one shared-system fact (toast re-entry) goes to the architecture record that already owns the outlet registry.

Decision

Owner direction, recorded here so the record itself can stay in the present tense:

"C+ animation is better. we will likely go with C+ if we pick C"
"C+ shall still support <Switcher> for now. we will only remove Switcher for future minor release"
"<Switcher> stay as deprecated"

The owner (imdreamrunner) approves this record change; the /approve-spec is posted at the final head. The two records keep the approval lines main carries today — component:BottomSheet approved_by: cixzhang, approved_at: 2026-10-24 (from #8304) and component:BottomSheetSwitcher approved_by: imdreamrunner, approved_at: 2026-10-24 (from #8272) — until /approve-spec rewrites them; the new DEC-2 decider line reads imdreamrunner, 2026-10-23, the Switcher's DEC-3 and DEP-0015 lines imdreamrunner, 2026-10-20 and imdreamrunner, 2026-10-23.

component:BottomSheet — DEC-2, FR5–FR12, AR3–AR4, PR1–PR2

  • FR5 Open order. Modal sheets in one document form one stack ordered by when they opened; sheets opening in one update are ordered by tree order (a sheet before the sheets in its content, siblings in source order), and the first painted frame shows that order. Only sheets of one document stack.
  • FR6 One scrim, covered sheets recede. A covered sheet stays mounted, recedes, and is blocked; when it contains no presented sheet it is also inert and aria-hidden. Exactly one backdrop is visible — the bottom open sheet's — and a change of scrim owner lands with no transition.
  • FR7 Dismissal reaches the top only. Escape, scrim tap, swipe, and platform close reach only the top sheet through its own onOpenChange and purpose; covered and replaced sheets stand down from family:overlay-dismissal while covered.
  • FR8 Nested sheets leave and return with their host. A sheet in another sheet's content is presented only while its host is open and is presented again, on top, if its isOpen is still true when the host reopens. Its isOpen is never written by the host.
  • FR9 Focus. A pop returns focus into the revealed sheet (to the closing sheet's opener if it is inside the revealed sheet, else the revealed panel; a finalFocusRef outside the revealed sheet yields to its panel the same way). The last sheet keeps the standalone rule (finalFocusRef, else opener). Closing every sheet in one update returns focus once, to the bottom sheet's target, after the last dialog has closed. A close driven by the stack never moves focus.
  • FR10 Replace. Exactly one standalone sheet closes and one opens in the same update, both of one document and one modality; the closing sheet was the top sheet with the opening sheet set aside; neither dialog contains the other; the last pressed control (or, with no fresh press, the element focused when the update began) is inside the closing dialog or none → the opener replaces: enters above, a taller retained sheet aligns down, the retained sheet fades after both motions, is inert/aria-hidden from the first frame, the one scrim stays with the retained sheet until it leaves and the entering backdrop takes over with no transition, focus moves into the entering sheet, and the entering sheet adopts the retained sheet's focus-return target. Anything failing a condition is a push and a pop with their own focus return.
  • FR10's "top sheet" is defined per group: the document's modal stack, or, for non-modal sheets, the one open non-modal sheet — non-modal sheets never stack, so the single open one is always its own top (FR11 says the same), which is what makes two non-modal sheets replaceable under FR10.
  • FR11 names the host of a non-modal sheet shown while a modal it is not inside is open: show() beneath that modal with no page-level z-index (the band is gone on both paths; AST-027 FR4), a modal on its way out counting as gone; once no such modal is open the sheet enters the top layer as a manual popover with the toast viewport re-entering above it (FR14), so page content never paints over an interactive non-modal sheet (AST-027 FR8) — no AST-027 gap remains. Rendered inside the modal's content it enters the top layer at once. The FR10–FR11 verification row carries these states (while open, once closed with positioned page content, opened in the update that closes a modal sheet, inside the modal) and its failure list now carries the paints-above / stays-beneath signals before the gap note, so they read as failure signals rather than facts about main.
  • Lone-open focus is engine-aware, not "the panel": measured on the same markup — Chromium's show()/showModal() land on the panel (first focusable area incl. a tabindex="-1" container), Firefox 151's on the first focusable control (a data-autofocus field only when it comes first); WebKit unmeasured (could not be launched on the Linux node). The Compatibility class, AR4, FR11 and the FR10–FR11 failure list now say "wherever the engine's native focusing steps land" with those results, keep it as released behaviour, and FR11 says the manual-popover host lands where that engine's show() would have. The FR10–FR11 verification row names a per-engine lone-open probe (Chromium and Firefox at least) against bare show()/showModal().
  • Compatibility names the reduced-motion exception for a lone sheet: its entrance and exit follow FR15 under the preference where the released motion ran shortened transforms — visual only, no state, event or focus outcome changes — the way the Switcher record states its handoff's exception.
  • DEP-0015 covers BottomSheet's consumer doc: the Switcher record's Intent gap note (which describes main) and the DEP-0015 verification row name component:BottomSheet's consumer doc beside the switcher's own docs, templates and stories; the switcher's Public concepts table marks every concept (sheetId included) deprecated (DEP-0015), the Stability spelling component:TabList and component:List use for their deprecated concepts.
  • Merged with main's minimal-DOM change (spec: an open layer may render up to 5 more elements under touch, with no new anatomy (AST-017 DEC-29) #8439): the record keeps main's element-jobs passage (architecture:component-style-authoring INV7, spec:AST-017 FR60/DEC-29) and its "Element jobs" verification row beside PR1–PR2 and this PR's verification rows.
  • Merged with main's AST-099 change (spec(AST-099): one shared on-screen keyboard module owns measurement and keyboard riding #8304): the record keeps main's approval lines (cixzhang, 2026-10-24), its spec:AST-099 entry and ownership line, alongside this PR's system-spec additions.
  • The Reduced-motion representative state names the touch-drag-release exception on a fine pointer too.
  • FR15 carries DR2's drag-release clause (a dismissal the release of a touch drag started fades out in place on every pointer; everything else on a fine pointer is immediate, DR3), with the swipe state in the verification row; the implementation PR implements it. The Switcher's FR1–FR4/FR8 verification row has main's reduced-motion failure signal back ("under reduced motion, a handoff that moves a sheet, or on a coarse pointer does not cross-fade"), and its Handoff state says the retained step paints the one scrim only when the flow owns it — transparent while a lower sheet owns the stack's scrim (FR10, BottomSheet FR6).
  • FR10's scrim clause reconciled with FR6/PR2: the scrim owner is the bottom open sheet; when the retained sheet is that owner its backdrop keeps the one visible scrim until it leaves, and when the replace runs above a covered sheet that sheet stays the owner and the retained sheet's backdrop is transparent throughout (it only fades). The Replacing state and the Replaced-sheet design row say the same, and the FR10–FR11 verification states include "a replace above a covered sheet". The implementation already behaves this way.
  • design:reduced-motion landed on main (spec(design): under reduced motion, touch cross-fades and other pointers change at once #8272) during review, and the Switcher record there now says a handoff follows it (DR2 cross-fade on touch, DR3 at once otherwise) with a gap note. This record's FR15 defers to that design record, keyed on its queries — no transform motion (DR1): the covered sheet appears in its receded place at once, the entering sheet in its final place, no alignment travel; cross-fade on a coarse pointer (DR2), immediate otherwise (DR3) — and names the switcher's steps as sharing the sheet panel's motion; its gap note describes main only (shortened transforms, no cross-fade). The Switcher record keeps main's FR3 text and gap note, and FR10 says a flow's steps move as FR15 says. The implementation PR implements DR2/DR3 (maintainer decision, owner can redirect), so both gap notes close with it.
  • Measurement status moved out of the record (architecture:knowledge-contracts INV10): the FR10–FR11 verification row names the per-engine lone-open focus probe in Chromium, Firefox and WebKit and the recording of focus/blur events on the first control (none); the status — Chromium and Firefox measured, WebKit unmeasured because Playwright's headless WebKit does not launch on the Linux evidence host — is stated here, not in the row. FR11 also says focus arrives where show() would have put it without passing through any control (no control receives a focus or a blur on a lone open), and the failure list names a transient focus or blur on a control.
  • AR5 is scoped to what the stack adds (spec:AST-086 FR14 delivers a message pending in a closing sheet through the sheet revealed beneath it): "the stack adds no announcement of its own to a push or a pop", with content-owned messages delivered by their owners' rules; the verification check reads "the stack renders no live region of its own". FR12 says a modal switcher flow, matching the switcher's FR10; FR15's basis is DEC-2 alone (spec:AST-012 dropped from system_specs); FR11 and AR4 no longer say "unmeasured here" — the verification row carries WebKit's status instead.
  • Reduced motion and assistive technology (new FR15, AR5): 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 frame, the retained step's alignment and fade each land instantly, the handoff's steps still run in the released order so it settles within a few frames with no intermediate values painted, the dim stays constant (measured in Chromium with reduced motion emulated — the retained dialog closes at ~116 ms); AR5 — a push or pop adds no announcement beyond the focus move, a replace is perceived as focus entering the new step's data-autofocus field or panel with its dialog name announced, the retained step hidden from the first frame (cites spec:AST-020). Representative state "Reduced motion" added; the stacking verification row names a reduced-motion emulated run and the no-live-region / focus-entry checks. The implementation already behaves this way (every motion collapses to 0.01s under the query; no live region), so no code change follows.
  • AR3 evidence (AST-020, AST-009 FR3): the stacking verification row now names a real-browser accessibility-tree check — a lone sheet, stacks of two and three, and a replace handoff expose exactly one dialog, the top or entering sheet, with its name, while covered and replaced sheets are hidden — with the matching failure signals; the implementation's Chromium harness carries it.
  • FR14 wording: the verification row and FR14's gap note say "the toast viewport's re-entry above a non-modal sheet"; nothing refers to an entry announce.
  • INV18, the re-show bullet: the design-rule bullet no longer prescribes the repair step; it states the guarantee — the stack order holds in the first painted frame (FR5) and no dialog below the first divergence is presented again, so a sheet whose place is already right fires no native close.
  • Records state observable guarantees, not the private module (architecture:knowledge-contracts INV18): PR1 now reads "Sheets rendered by every copy of the package loaded on a page, and by every React root on it, form one stack per document … No public API reaches the ordering"; FR14 and AR4 no longer say "registry" (FR14: when a non-modal sheet enters the top layer the toast viewport re-enters above it; AR4: a replacement's focus is placed after the native focusing steps); DEC-2's rationale says the same in its own words. The Ownership bullet reads "the stack only tells it which sheet can take a close request (FR7)" instead of "which sheets are on screen".
  • FR11 Non-modal hosting. hasScrim={false} is a manual popover in the top layer, no backdrop, no scroll lock, no focus trap, no page-level z-index; show() in page order as the reduced fallback without the Popover API. Never stacks or pairs with a modal sheet; two non-modal sheets replace under FR10. Focus: a lone open takes focus only through data-autofocus; a replace takes focus; on close finalFocusRef is focused wherever focus is, and otherwise the element focused when the sheet was shown is focused again only while focus is still inside the sheet — the native dialog's own return rule, which the released sheet already has (finalFocusRef after every exit from the script, the opener from the native close()). Focus on open is stated truthfully: a sheet opened on its own, modal or not, is focused where the native host's focusing steps land — the panel — and a data-autofocus descendant is not focused over it. Verified in Chromium on a static build of main and on the implementation head with two probe stories (a hasAutoFocus TextInput in a no-scrim sheet and in a modal sheet): the panel is focused, the field is not, identically on both builds for both modalities. The one deliberate exception, stated in AR4 with its reason, is a sheet entering as a replacement (FR10): the registry places focus itself because the native host may have left it in the replaced sheet, and there a data-autofocus descendant is focused first. The FR10–FR11 verification row names both as real-browser failure signals; Compatibility lists the kept panel focus for the modal and the non-modal sheet. The row's gap note says only the host changes; the FR10–FR11 verification row gains a real-browser failure signal for it, and Compatibility lists the open-focus behaviour among the kept contract.
  • FR12 A switcher flow is one level. The active step's dialog is the flow's level and keeps the flow's rank across its own handoffs; it recedes and is blocked when covered, yields its backdrop to the stack's owner, stands down from dismissal while covered, returns focus by FR9. A standalone sheet in a step's content stacks above the flow and leaves with the step. A step is never paired under FR10.
  • AR3 covered/replaced sheets inert + aria-hidden; a covered sheet containing the top sheet is left alone. AR4 focus follows the person (open, replace, pop, last close, close-all). PR1 one private registry per page shared by every package copy, one stack per document, no public API. PR2 one visible backdrop whatever the depth.
  • Precedence: open order → tree order; native top-layer order is derived from stack order, not trusted (re-shown in stack order before paint on divergence); FR10's conditions tested in order, first failure → push and pop.
  • Design rows for the covered and replaced sheet; verification-map rows for the stacking and replace suites (unit in jsdom; real-browser per-frame sampling of transforms, backdrop opacity, hit-testing, and focus in Chromium, WebKit, and real Safari).
  • Release tier (owner decision): the records cite spec:AST-017 FR25 for a patch; whether this ships as a patch under FR25 or as a declared minor under FR31 is the owner's call — the argument for each is laid out in the implementation pull request's "Release tier" paragraph, and the records' Compatibility sections state the author's reading.
  • Compatibility: no public prop, type, default, or export changes. The [feat] tag comes from spec:AST-017 FR7 (a compatible capability); patch admissibility from FR25 as additive (no released contract described two presented sheets, so the stack gives behavior to a state no contract covered) and implementation-equivalent for a sheet on its own (a modal sheet on its own behaves as released; a non-modal sheet on its own keeps its documented contract — no scrim, no scroll lock, page interactive, dismissal by purpose, finalFocusRef focused after every close, the element focused at show time focused again while focus is inside — while its host changes from show() + page-level z-index to the top layer with none). Two modal sheets open at once stack instead of presenting as unrelated dialogs that each dim the page. system_specs cites spec:AST-017/FR7 and /FR25; FR28 is cited only where it applies, the switcher's deprecation path.

component:BottomSheetSwitcher — stays current, deprecated; DEC-3, DEP-0015, FR10

  • Authority stays current. The record stays current and every existing requirement stays in force: DEC-1/DEC-2 per-step dialogs, FR1–FR9, AV1–AV2, ORD1–ORD2, PR1–PR2, AR1–AR3, the theming anatomy. No row is archived, loosened, or marked as no longer guaranteed. The per-step dialogs are what the stack registers: the active step's dialog is the flow's level (FR12 above, FR10 here).
  • Lifecycle is deprecated (spec:AST-017 FR26), with the gap notes a not-yet-shipped state needs: the Intent's lifecycle statement, the Compatibility lifecycle bullet, DEP-0015's state and downstream rows and its verification row each carry a present-tense "Implementation gap" note (on main no declaration carries @deprecated, the switcher docs recommend the switcher, the CLI block templates and Lab's MobileTokenizer use it). The downstream row records the author's reading of spec:AST-017 FR29c, citing the clauses, not a ruling. Corrected facts: @astryxdesign/lab is marked private and is published to npm, but only under the @canary dist-tag, never as a stable latest release (private: true is npm's guard against an accidental stable publish; publishConfig.canaryOnly). Re-checked against AST-017 with those facts: AST-017 itself calls Lab "the private canary-only Lab package" (FR27, and its Related-authorities section), lists private packages among its non-goals, puts them outside the published compatibility promise (FR2), and says "canary-only surfaces … carry no stable compatibility promise" (Browser/Support). So the reading — FR29c's "maintained Astryx packages" does not reach Lab, and Lab's migration is optional under it — still holds with the accurate description, and the row now states it that way. The maintained published surfaces (CLI block templates, stories) migrate in the deprecation patch; MobileTokenizer migrates with the patch; MobilePowerSearch keeps the switcher until its own change migrates it, and the switcher keeps working for it. The row says plainly that whether AST-017 is read that way is its owner's to settle.

Open owner decision — FR29c and Lab's MobilePowerSearch. The automated review reads FR29c as covering Lab's use of the deprecated switcher. Two ways to close it:

  1. Owner exception as recorded: Lab is private and outside AST-017's compatibility promise, so FR29c does not require its migration in the deprecation patch; MobilePowerSearch migrates in its own follow-up (two open Lab changes are editing that file now). The row stands as written; the implementation PR does not touch MobilePowerSearch.
  2. Migrate MobilePowerSearch before the deprecation lands: FR29c is read as reaching Lab; MobilePowerSearch moves to standalone step sheets in the implementation PR (after the open Lab changes land, or coordinated with them), the row's MobilePowerSearch clause and the Intent's gap note are removed, and MobileTokenizer and MobilePowerSearch migrate together.

Neither is decided here. The implementation PR currently follows option 1 because the owner asked that the Lab flow be left alone while the open Lab changes are in flight; the owner's ruling picks the option. The state stays deprecated rather than proposed because the owner's direction is the deprecation itself; the notes record that it is not yet realized. The Intent and Compatibility sections carry the lifecycle statement; DEC-3 records the decision; DEP-0015 records surface (BottomSheetSwitcher, BottomSheetSwitcherProps, BottomSheet's sheetId), old contract, replacement (standalone BottomSheets driven by one step value, replaced in place under component:BottomSheet FR10), direct authority, warning (@deprecated on the declarations; docs, templates, stories, release notes name the replacement; no runtime warning, FR29), an ordered four-step migration, no codemod, downstream (maintained Astryx surfaces use the replacement), state deprecated, and target plan: removal only in a future declared minor through a [breaking] Changeset (FR28/FR31). The record cites only current authority: the state row names the deprecation patch spec:AST-017 FR28 requires before a removal, projected through declarations and docs (FR29a, FR29b); the open decision row leaves which declared minor removes the surface, and whether a renamed compatibility export precedes the removal ([breaking] either way, FR28/FR31).

  • Compatibility. Released defaults are preserved and existing switcher flows behave as released. A flow presented while other sheets are open is one stack level under component:BottomSheet DEC-2 — behavior of the stack, not a change to the switcher's own contract.
  • The handoff condition is stated where the replacement is promised, with one wording. component:BottomSheet FR10 defines its actor and "none": the control last pressed, or with no fresh press the element focused when the update began, is inside the closing sheet's dialog or is none (no fresh press, and nothing or only the document body focused, as when code changes the step while focus has fallen to the body). DEP-0015's replacement and migration rows (step 5) and DEC-3 use the same terms: a step changed by a control outside the flow's sheets — a page control beside a non-modal flow, or code acting while focus is on a page control — is a close and an open with the sheets' own motion; code acting while focus is in the step or on the body keeps the handoff. FR10 is not widened: the actor condition is what tells a step flow from two unrelated sheets toggled by one page control.
  • DEP-0015's migration maps every switcher prop (step 6): onCancel → a guarded onOpenChange on each step (ignores false while the flow must stay open), which keeps the step's dialog role. Stated plainly: 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 the switcher's 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, blocks scrim tap and swipe; required blocks all three and exposes an alertdialog); there is no per-event veto; a consumer aria-label/aria-labelledby → each step's label; 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 anchor that needs the dialog queries the panel's closest('dialog'), and dialog-level styling is dropped.
  • FR10 (new) records the flow as a level: the active step's dialog is the level and keeps the flow's place across handoffs; covered → recede, blocked, transparent backdrop, dismissal stands down; closing over a lower sheet returns focus into it; a standalone sheet in a step's content stacks above the flow; a step is never paired in a replace. A "Stacked" representative state and verification rows for FR10 and DEP-0015 are added. The ownership boundary's "proposed separately by open BottomSheetStack work" pointer is replaced by the owner (component:BottomSheet DEC-2).
  • The switcher's own non-modal flow wrapper keeps its spec:AST-027 gap; the deprecation leaves it to be closed by removal rather than by re-hosting.

architecture:layer-runtime

Not edited. The record opens with "This record describes the layer runtime shipped on current main", so it cannot carry rules main does not meet. The three facts that were there in earlier revisions are now component:BottomSheet rows, where the sheet behaviour is owned: FR7 (a covered or replaced sheet reports itself absent to the shared family:overlay-dismissal stack, so the shared stack stays one registry), FR13 (every open scrim sheet supplies a toast outlet per spec:AST-003 FR18, a covered sheet included, and a modal BottomSheetSwitcher flow supplies its active step's dialog, following each handoff — the retained step, inert and fading, never hosts; among sheets the latest outlet follows the stack's order; a sheet is raised only among sheet outlets, never above a Dialog supplied later; a stale native close from a re-show does not take an outlet away; its gap note adds that on main a switcher flow supplies no outlet, so a toast raised from a step lands behind the flow), and FR14 (a non-modal sheet announces its top-layer entry and the toast viewport re-enters above it). Each carries a gap note (PR2 now reads: one visible backdrop — the scrim owner's, the bottom open sheet's under FR6 — and every other backdrop in the stack transparent, not removed, consistent with Switcher FR10); a new verification row (FR13–FR14) names the toast-hosting suite and the real-browser hit-tests, with states for a modal flow alone, through a handoff and its close, a flow over a sheet and a sheet over a flow, and a non-modal flow. component:BottomSheetSwitcher FR10 says the flow's host dialog is an outlet like a standalone scrim sheet's, and its gap note says that on main a flow supplies none (no step calls into the outlet registry; layer-runtime lists a scrim switcher among the modals toasts stay behind), so a toast from a step lands behind the flow. The Switcher's Compatibility statement qualifies "a flow on its own behaves as released" with that one fix. The implementation pull request adds the corresponding sentences to layer-runtime as the shipped behaviour, as the per-step switcher change did, with that record's approval metadata left at main's values until an engineering owner's /approve-spec is recorded there.

Implementation gap notes

Every requirement main does not meet carries a present-tense "Implementation gap" note, which the implementation pull request deletes: component:BottomSheet FR5, FR6, FR7, FR8, FR9, FR10, FR11 (host only: the released focus return already matches the row), FR12, AR3, AR4, and the two verification rows; component:BottomSheetSwitcher's Intent lifecycle statement, Compatibility lifecycle bullet, DEP-0015 state and downstream rows, FR10, and the FR10 and DEP-0015 verification rows; the new FR13 and FR14 and their verification row (the toast-outlet order and the non-modal re-entry, moved here from layer-runtime). One note outlives the implementation: MobilePowerSearch still using the switcher (Intent and downstream), until its own change lands.

Rejected alternatives

Each is one Rejected: line inside the decision that rejects it.

  • A — an ordered list of steps on the switcher (activeSheets), the model the AST-044 draft and its implementation describe. Changes the switcher's public contract, requires every level of a flow to live under one switcher, and cannot order a sheet opened from elsewhere (a Selector's sheet from inside a step, a second widget). The owner's direction is C+, which deprecates the switcher instead.
  • B — a <BottomSheetStack> provider or shared dialog that composes levels. A React context cannot reach a second root, a second copy of the package, or a widget mounted outside it; module-level singletons already own document-wide facts in this repo (Layer/layerStack.ts, Layer/modalOutlet.ts). The only scoping a provider adds is a "same level" boundary, which FR10's actor condition supplies without a wrapper. The owner also preferred C+'s motion.
  • A stacked/replaces opt-in prop: a lone sheet is unaffected and one state value already says what a flow means.
  • A page-level z-index on a non-modal sheet to order a replace: spec:AST-027 forbids a new band.
  • For the switcher: keeping it as the one supported flow API (two implementations of one handoff); removing it in the same release (a patch cannot carry [breaking]); a development-mode render warning (FR29 projects a component deprecation through declarations and docs).

Claim scope and non-goals

The records govern the order, scrim ownership, blocking, dismissal routing, nested-sheet lifecycle, replace conditions and handoff, focus return, non-modal hosting, toast re-entry, the switcher's participation as a level, and the switcher's lifecycle. They do not decide the recede geometry's exact values or the handoff curve (allowed variation under theme motion tokens), any new public prop (none is introduced), the switcher's open questions OQ1–OQ3, whether Dialog/Drawer/Popover opened from a sheet stack (they keep their own hosting and are not levels), the release that removes the switcher, or the rename-versus-remove choice for that minor.

Evidence and implementation relationship

Writing rubric results

  • R1 narration: the new Intent sentences, Compatibility bullets, Ownership bullets, FR5–FR12, AR3–AR4, PR1–PR2, the representative states, the precedence bullets, DEC-2, the switcher's lifecycle statement, DEP-0015, FR10, DEC-3, and the two layer-runtime sentences are present-tense statements about the system; the gap notes say what the shipped sheet does. One pre-existing pointer to a proposal ("proposed separately by open BottomSheetStack work") is removed and replaced by the owning record. The owner's direction and the candidate comparison live in this description, not in the records.
  • R2 research in the record: none; the three-candidate comparison, the parity measurements, and the Safari runs stay in the pull request descriptions.
  • R3 evidence naming a change: none; the new verification rows name the evidence layer (jsdom unit suites, real-browser per-frame sampling, declaration and doc inspection) and the failure signal.
  • R4 rejected alternatives over one line or outside a decision: none; DEC-2 carries four one-line Rejected: entries and DEC-3 three.
  • R5 pull request or issue references remaining: none added (searched # + digits, /pull/, /issues/ in the three records); the superseded pull requests and the deprecation-procedure pull request are named only in this description.
  • R6 ruled open questions still listing options: none; DEP-0015's open decision (rename or remove in the future minor) is genuinely unanswered and names both options, as the DEP shape asks; BottomSheet OQ1 and the switcher's OQ1–OQ3 are untouched.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations (recede geometry, handoff curve, and motion completion order are allowed variation; the registry is named as a private, per-page, no-public-API fact in PR1).
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract: the native dialog and top layer are named because family:overlay-dismissal, spec:AST-027, and the toast viewport depend on them; the manual-popover host is named because spec:AST-027 FR8 requires top-layer hosting and the page-level band it replaces is a recorded gap.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision — how sheets relate to one another, and the switcher's lifecycle that follows from it — not a rescue for a separable tagalong.
  • The canonical knowledge owner, affected current claims, and overlapping open work are named; no ownership collision (the AST-044 draft is superseded rather than competing).
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

  • pnpm check:knowledge — "Knowledge templates and records are aligned." (on every head)
  • pnpm check:sync — clean.
  • Prettier — all three records formatted.
  • git diff --check — clean.
  • The pre-commit check:repo chain (knowledge, sync, package boundaries, changesets, i18n, fixtures, setup contract, …) passed on the commit.

@vercel

vercel Bot commented Oct 23, 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 26, 2026 12:35am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 23, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 23, 2026
@astracat-bot

astracat-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Oct 26, 2026, 1:00 AM UTC

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current change has no visual or preview-producing scope. Visual checks are not applicable; preview links are not shown.

The exact-head preview is not available yet.


Generated by PR Enrichment workflow | View current CI run

@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 spec change is clearly scoped, but approval waits on an engineering owner's decision: the head adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently, four record fixes are needed:

  • DEP-0015 and DEC-3 promise the switcher's handoff for every flow, but FR10 falls back to push-and-pop motion when a control outside the closing sheet acts; state that condition in the replacement and migration, or widen FR10.
  • DEP-0015's deprecated state and the lifecycle text describe @deprecated declarations and migrated docs and templates that main does not have yet; add implementation-gap notes, or record the state as proposed as other unshipped DEP records do.
  • FR11's gap note says a non-modal sheet returns no focus, but main returns focus to finalFocusRef on every close; correct the note and list the narrowing under Compatibility.
  • The [feat] patch class cites AST-017 FR28, which governs deprecation paths; FR7 and FR25 are the clauses that decide the tag and patch admissibility.

[Automated review]

| package | `@astryxdesign/core` |
| surface | `BottomSheetSwitcher`, `BottomSheetSwitcherProps`, and the `sheetId` prop of `BottomSheet` |
| old contract | A multi-step flow is a `BottomSheetSwitcher` whose `activeSheet` names one nested `BottomSheet` by `sheetId`; stepping runs the handoff of FR2–FR3 and FR8 |
| replacement | One standalone `BottomSheet` per step with `isOpen={step === id}`, driven by one state value; closing the open step and opening the next in the same update runs the same handoff (`component:BottomSheet` FR10), for modal and `hasScrim={false}` flows alike |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR10 keeps "the ordinary push and pop motion" when "a control outside the closing sheet" acts, so this replacement does not run the same handoff for a flow stepped from the page (a non-modal flow leaves the page interactive) or programmatically after focus left the sheet. The switcher hands off on any activeSheet change. Please state the condition here, in the migration and in DEC-3, or widen FR10.

| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | Maintained Astryx surfaces (CLI block templates, stories, Lab) use the replacement; the switcher's own stories remain as its labelled reference |
| transition | no prior transition |
| state | `deprecated`: supported, documented with its replacement, unchanged at runtime; this is the documentation step of the component deprecation procedure |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On main no declaration carries @deprecated, BottomSheetSwitcher.doc.mjs still recommends the switcher, and the CLI block templates and Lab's MobilePowerSearch and MobileTokenizer use it. Every other unmet row gets an implementation gap note; this state (and the lifecycle text and the DEP-0015 verification row) needs one too, or proposed until the implementation lands, as Selector DEP-0011 and Banner DEP-0007 do.

| FR8 | A sheet rendered in another sheet's content is presented only while its host is open: it leaves the top layer with its host and is presented again, on top, if its own `isOpen` is still true when the host reopens. Its `isOpen` is never written by the host. Implementation gap: on current `main` a nested sheet stays open in the top layer with no visible box when its host closes from code. | DEC-2 |
| FR9 | Closing the top sheet returns focus into the sheet it reveals: to the element that opened the closing sheet when that element is inside the revealed sheet, else to the revealed sheet's panel; an explicit `finalFocusRef` outside the revealed sheet yields to its panel in the same way. The last sheet of a stack keeps the standalone rule: `finalFocusRef`, else the opener. 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. A close driven by the stack itself never moves focus. Implementation gap: on current `main` closing several sheets in one update leaves focus on `body`. | DEC-2 |
| FR10 | When exactly one standalone sheet closes and exactly one opens in the same update, both of one document and one modality, the closing sheet was the top sheet of its stack with the opening sheet set aside, neither dialog contains the other, and the control last pressed (or, with no fresh press, the element focused when the update began) is inside the closing sheet's dialog or is none, the opening sheet replaces the closing one: it enters above the retained sheet; a retained sheet whose top edge is above the entering sheet's resting top edge moves down to meet it at the same time; the retained sheet fades only after both motions complete; it is `inert` and `aria-hidden` from the first frame and takes no dismissal request; the one scrim stays with the retained sheet until it leaves, when the entering sheet's backdrop takes over with no transition; focus moves into the entering sheet; and the entering sheet adopts the retained sheet's focus-return target, so closing the flow returns focus to its first opener. A close and an open that fail any condition — different updates, a covered sheet closing, several sheets changing, a sheet closing with its host, or a control outside the closing sheet acting — keep the ordinary push and pop motion and their own focus return. Implementation gap: on current `main` a same-update close and open is a pop beneath a push. | DEC-2 |
| FR11 | A non-modal sheet (`hasScrim={false}`) is presented in the browser's top layer as a manual popover with no backdrop, no scroll lock, and no focus trap, and carries no page-level `z-index`; where the Popover API is absent it is presented with `show()` in page stacking order as a reduced fallback. Non-modal sheets never stack, recede, or own a scrim, and never pair with a modal sheet, but two non-modal sheets replace each other under FR10 with no backdrop. On a lone open the sheet takes focus only through a `data-autofocus` descendant; when it is replacing the sheet the person was in, it takes focus; when it closes while focus is inside it, focus returns to its `finalFocusRef`, else its opener. Implementation gap: on current `main` a non-modal sheet is presented with `show()` and a page-level `z-index: 1000`, and returns no focus on close. | DEC-2; `spec:AST-027/FR8`; `architecture:layer-runtime` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"returns no focus on close" is not what main does: BottomSheet.tsx moves focus to finalFocusRef after every exit, modal or not, and captures an opener only for modal sheets. FR11's "while focus is inside" rule narrows that released behavior, so the Compatibility bullet should list it.

page-level band remains on that path.
- `architecture:layer-runtime` owns the toast viewport's re-entry above a
non-modal sheet, through the same private registry Dialog outlets use.
- `spec:AST-017` FR28 classes stacking, the replace handoff, and the non-modal

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 governs when a deprecation path is required; the [feat] tag comes from FR7 and patch admissibility from FR25 (additive, implementation-equivalent, FR3 default, FR5 victim-free restoration, or FR53 accessibility fix). Please cite those and say which FR25 category covers the changed two-sheet behavior; system_specs lists FR28 too.

@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 spec change is clearly scoped, but approval waits on an engineering owner's decision: it adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12, AR3–AR4 and PR1–PR2; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. 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. Independently, three record fixes are needed:

  • The layer-runtime implementation gap note says a toast raised while a non-modal sheet is open paints beneath it on main, but there the sheet uses show() at z-index: 1000 while the toast viewport is a top-layer popover, so toasts already paint above it; the ordering problem appears only once FR11 moves the sheet into the top layer.
  • FR11 and AR4 say a lone non-modal sheet takes no focus on open and note only the host as unmet, but in Chromium the released sheet's native show() moves focus to its panel (main's jsdom test stubs show()), so the rows need that gap, a real-browser failure signal and a Compatibility line.
  • FR10 also replaces when the actor "is none", while DEP-0015 and DEC-3 say code after focus has left the step always gets push and pop, so the three places should use one wording.

[Automated review]

Comment thread docs/architecture/layer-runtime.md Outdated
Comment on lines +330 to +332
rendered with `isTopLayer={false}` stays in place. Implementation gap: on
current `main` the registry carries only Dialog outlets, and a toast raised
while a non-modal sheet is open paints beneath it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On main a non-modal sheet is not a top-layer surface: BottomSheet.tsx presents it with show() under zIndex: 1000, while ToastViewport defaults to popover="manual" and showPopover(), so toasts already paint above it. The ordering problem appears once FR11 moves the sheet into the top layer. Could the gap note say that the registry carries only Dialog outlets and a non-modal sheet is not yet a top-layer surface?

| FR8 | A sheet rendered in another sheet's content is presented only while its host is open: it leaves the top layer with its host and is presented again, on top, if its own `isOpen` is still true when the host reopens. Its `isOpen` is never written by the host. Implementation gap: on current `main` a nested sheet stays open in the top layer with no visible box when its host closes from code. | DEC-2 |
| FR9 | Closing the top sheet returns focus into the sheet it reveals: to the element that opened the closing sheet when that element is inside the revealed sheet, else to the revealed sheet's panel; an explicit `finalFocusRef` outside the revealed sheet yields to its panel in the same way. The last sheet of a stack keeps the standalone rule: `finalFocusRef`, else the opener. 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. A close driven by the stack itself never moves focus. Implementation gap: on current `main` closing several sheets in one update leaves focus on `body`. | DEC-2 |
| FR10 | When exactly one standalone sheet closes and exactly one opens in the same update, both of one document and one modality, the closing sheet was the top sheet of its stack with the opening sheet set aside, neither dialog contains the other, and the control last pressed (or, with no fresh press, the element focused when the update began) is inside the closing sheet's dialog or is none, the opening sheet replaces the closing one: it enters above the retained sheet; a retained sheet whose top edge is above the entering sheet's resting top edge moves down to meet it at the same time; the retained sheet fades only after both motions complete; it is `inert` and `aria-hidden` from the first frame and takes no dismissal request; the one scrim stays with the retained sheet until it leaves, when the entering sheet's backdrop takes over with no transition; focus moves into the entering sheet; and the entering sheet adopts the retained sheet's focus-return target, so closing the flow returns focus to its first opener. A close and an open that fail any condition — different updates, a covered sheet closing, several sheets changing, a sheet closing with its host, or a control outside the closing sheet acting — keep the ordinary push and pop motion and their own focus return. Implementation gap: on current `main` a same-update close and open is a pop beneath a push. | DEC-2 |
| FR11 | A non-modal sheet (`hasScrim={false}`) is presented in the browser's top layer as a manual popover with no backdrop, no scroll lock, and no focus trap, and carries no page-level `z-index`; where the Popover API is absent it is presented with `show()` in page stacking order as a reduced fallback. Non-modal sheets never stack, recede, or own a scrim, and never pair with a modal sheet, but two non-modal sheets replace each other under FR10 with no backdrop. On a lone open the sheet takes focus only through a `data-autofocus` descendant; when it is replacing the sheet the person was in, it takes focus. On close an explicit `finalFocusRef` is focused wherever focus is, and otherwise the element focused when the sheet was shown is focused again only while focus is still inside the sheet — the native dialog's own return rule, kept. Implementation gap: on current `main` a non-modal sheet is presented with `show()` and a page-level `z-index: 1000`; its focus return (`finalFocusRef` after every exit, the native return to the element focused at `show()` while focus is inside) already matches this row. | DEC-2; `spec:AST-027/FR8`; `architecture:layer-runtime` |

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 the released non-modal sheet takes focus on open: the native show() runs the dialog focusing steps and moves focus to the panel (tabindex="-1"), and focusPanel does not undo it; the jsdom test that asserts no focus move stubs show(). So "takes focus only through a data-autofocus descendant" is unmet on main too. Please add that to this gap note and AR4's, add a real-browser failure signal to the FR10–FR11 verification row, and list the open-focus change in Compatibility.

| package | `@astryxdesign/core` |
| surface | `BottomSheetSwitcher`, `BottomSheetSwitcherProps`, and the `sheetId` prop of `BottomSheet` |
| old contract | A multi-step flow is a `BottomSheetSwitcher` whose `activeSheet` names one nested `BottomSheet` by `sheetId`; stepping runs the handoff of FR2–FR3 and FR8 |
| replacement | One standalone `BottomSheet` per step with `isOpen={step === id}`, driven by one state value; closing the open step and opening the next in the same update, from a control inside the open step (or from code while focus is in it), runs the same handoff (`component:BottomSheet` FR10), for modal and `hasScrim={false}` flows alike. A step changed from a control outside the flow — a page control beside a non-modal flow, or code after focus has left the step — is a close and an open with their own push and pop motion, where the switcher ran its handoff on any `activeSheet` change |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR10 also replaces when the actor "is none" (no fresh press and nothing focused). Here, in migration step (5) and in DEC-3, "code after focus has left the step" always gets push and pop; in a modal flow that focus can only fall to the body. Could the three places use one wording, or FR10 define "none"?

@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 spec change is clearly scoped, but approval waits on an engineering owner's decision: it adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12, AR3–AR4 and PR1–PR2; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. 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 earlier record fixes are in; two more are needed either way:

  • FR11 and AR4 send a lone non-modal sheet's open focus to a data-autofocus descendant and FR11's gap note says main already does, but on main the native show() focuses the panel first and focusPanel then leaves focus there (the jsdom tests stub show()), so the gap notes, AR4's wording and the FR10–FR11 verification row need that case.
  • DEP-0015's migration covers activeSheet, onActiveSheetChange, sheetId and hasScrim but not the switcher's onCancel, its ref to the hosting dialog or its dialog props, which a standalone sheet does not offer or forwards to its panel; please map each one or say it has no equivalent.

[Automated review]

| FR8 | A sheet rendered in another sheet's content is presented only while its host is open: it leaves the top layer with its host and is presented again, on top, if its own `isOpen` is still true when the host reopens. Its `isOpen` is never written by the host. Implementation gap: on current `main` a nested sheet stays open in the top layer with no visible box when its host closes from code. | DEC-2 |
| FR9 | Closing the top sheet returns focus into the sheet it reveals: to the element that opened the closing sheet when that element is inside the revealed sheet, else to the revealed sheet's panel; an explicit `finalFocusRef` outside the revealed sheet yields to its panel in the same way. The last sheet of a stack keeps the standalone rule: `finalFocusRef`, else the opener. 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. A close driven by the stack itself never moves focus. Implementation gap: on current `main` closing several sheets in one update leaves focus on `body`. | DEC-2 |
| FR10 | When exactly one standalone sheet closes and exactly one opens in the same update, both of one document and one modality, the closing sheet was the top sheet of its stack with the opening sheet set aside, neither dialog contains the other, and the actor — the control last pressed, or, with no fresh press, the element focused when the update began — is inside the closing sheet's dialog or is none (no fresh press, and nothing or only the document body focused, as when code changes the step while focus has fallen to the body), the opening sheet replaces the closing one: it enters above the retained sheet; a retained sheet whose top edge is above the entering sheet's resting top edge moves down to meet it at the same time; the retained sheet fades only after both motions complete; it is `inert` and `aria-hidden` from the first frame and takes no dismissal request; the one scrim stays with the retained sheet until it leaves, when the entering sheet's backdrop takes over with no transition; focus moves into the entering sheet; and the entering sheet adopts the retained sheet's focus-return target, so closing the flow returns focus to its first opener. A close and an open that fail any condition — different updates, a covered sheet closing, several sheets changing, a sheet closing with its host, or a control outside the closing sheet acting — keep the ordinary push and pop motion and their own focus return. Implementation gap: on current `main` a same-update close and open is a pop beneath a push. | DEC-2 |
| FR11 | A non-modal sheet (`hasScrim={false}`) is presented in the browser's top layer as a manual popover with no backdrop, no scroll lock, and no focus trap, and carries no page-level `z-index`; where the Popover API is absent it is presented with `show()` in page stacking order as a reduced fallback. Non-modal sheets never stack, recede, or own a scrim, and never pair with a modal sheet, but two non-modal sheets replace each other under FR10 with no backdrop. On a lone open, focus moves to the panel when the browser's own focusing steps move it into the sheet (as the native `show()` does) and to a `data-autofocus` descendant when one is present; when the sheet is replacing the sheet the person was in, it takes focus. On close an explicit `finalFocusRef` is focused wherever focus is, and otherwise the element focused when the sheet was shown is focused again only while focus is still inside the sheet — the native dialog's own return rule, kept. Implementation gap: on current `main` a non-modal sheet is presented with `show()` and a page-level `z-index: 1000`; its focus on open (the native focusing steps land on the panel) and its focus return (`finalFocusRef` after every exit, the native return to the element focused at `show()` while focus is inside) already match this row. | DEC-2; `spec:AST-027/FR8`; `architecture:layer-runtime` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On main a non-modal sheet with an <input data-autofocus> opens with the panel focused, not the field: the native show() focuses the panel (tabindex="-1") first, and focusPanel returns early because focus is already inside the panel. The jsdom test that expects the field stubs show(). So "already match this row" is not true for the data-autofocus clause; please add it to this gap note and AR4's, add a real-browser failure for it to the FR10–FR11 row, and list the change in Compatibility (or drop the clause).

| replacement | One standalone `BottomSheet` per step with `isOpen={step === id}`, driven by one state value; closing the open step and opening the next in the same update, from a control inside the open step (or from code while focus is in it), runs the same handoff (`component:BottomSheet` FR10), for modal and `hasScrim={false}` flows alike. A step changed by a control outside the flow's sheets — a page control beside a non-modal flow, or code acting while focus is on a page control — is a close and an open with their own push and pop motion, where the switcher ran its handoff on any `activeSheet` change; code acting while focus is in the step or on the document body still runs the handoff (`component:BottomSheet` FR10's actor is inside the step, or none) |
| direct authority | DEC-3 here; `component:BottomSheet` DEC-2 and FR10 own the replacement's behavior. Owners: `cixzhang`, `imdreamrunner` |
| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff |

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 migration maps activeSheet, onActiveSheetChange, sheetId and hasScrim, but BottomSheetSwitcherProps also has onCancel (whose preventDefault() keeps the flow open), a ref to the hosting <dialog>, id/data-testid and dialog props on every sheet dialog, and a consumer aria-label that outranks the step label. A standalone BottomSheet has no onCancel and sends its ref and props to the panel <div>. Could a step say where each goes, or that it has no equivalent?

@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 spec change is clearly scoped, but approval waits on an engineering owner’s decision: it adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12, AR3–AR4 and PR1–PR2; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. 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 earlier record fixes are in; these are still needed either way:

  • AR4 says a lone modal sheet focuses a data-autofocus descendant first, but on main showModal() focuses the panel and focusPanel leaves focus there (the jsdom test stubs showModal()), while Compatibility says a lone modal sheet behaves as released; add the gap note, a real-browser failure signal and a Compatibility line, or state the released panel focus.
  • DEP-0015 step (6) says form keeps Escape from closing a step, but form allows Escape and only required blocks it.
  • DEP-0015 keeps Lab’s MobilePowerSearch on the switcher after the deprecation patch, which AST-017 FR29c does not allow for maintained packages; migrate it with MobileTokenizer or settle the exception with AST-017’s owner.
  • DEP-0015’s state row names a “component deprecation procedure” that no current record defines; drop the clause or point at AST-017 FR28.

[Automated review]

Comment on lines +216 to +218
focusing steps enter the sheet, with no `data-autofocus` preference — while
a modal sheet, and a non-modal sheet entering as a replacement, focus a
`data-autofocus` descendant first (FR11). Implementation gap: on current `main` a pop returns focus to the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On main a modal sheet with an <input data-autofocus> opens with the panel focused, not the field: showModal() focuses the panel (tabindex="-1") and focusPanel returns early because focus is already inside it (the jsdom test stubs showModal()). The Compatibility bullet says a lone modal sheet behaves as released, so this clause needs a gap note, a real-browser failure signal and a Compatibility line, or should state the released panel focus.

| replacement | One standalone `BottomSheet` per step with `isOpen={step === id}`, driven by one state value; closing the open step and opening the next in the same update, from a control inside the open step (or from code while focus is in it), runs the same handoff (`component:BottomSheet` FR10), for modal and `hasScrim={false}` flows alike. A step changed by a control outside the flow's sheets — a page control beside a non-modal flow, or code acting while focus is on a page control — is a close and an open with their own push and pop motion, where the switcher ran its handoff on any `activeSheet` change; code acting while focus is in the step or on the document body still runs the handoff (`component:BottomSheet` FR10's actor is inside the step, or none) |
| direct authority | DEC-3 here; `component:BottomSheet` DEC-2 and FR10 own the replacement's behavior. Owners: `cixzhang`, `imdreamrunner` |
| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes each step's `purpose` (`form` or `required` keeps Escape or every implicit dismissal from closing the step; there is no per-event veto) or a guarded `onOpenChange` that ignores `false` while the flow must stay open; 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Step (6) says form keeps Escape from closing the step, but form allows Escape (switcher FR6; BottomSheet.tsx closes on Escape for every purpose but required). An onCancel that vetoes Escape maps to purpose="required" or a guarded onOpenChange.

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes each step's `purpose` (`form` or `required` keeps Escape or every implicit dismissal from closing the step; there is no per-event veto) or a guarded `onOpenChange` that ignores `false` while the flow must stay open; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | Maintained Astryx surfaces use the replacement: the CLI block templates, the stories, and Lab's `MobileTokenizer`; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it (`component:BottomSheetSwitcher` keeps working for it). The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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.

AST-017 FR29c says maintained Astryx packages migrate in the deprecation patch, and this row treats Lab’s MobileTokenizer as maintained. Could MobilePowerSearch migrate in the same patch, or the exception be settled with AST-017’s owner?

| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | Maintained Astryx surfaces use the replacement: the CLI block templates, the stories, and Lab's `MobileTokenizer`; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it (`component:BottomSheetSwitcher` keeps working for it). The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the switcher |
| transition | no prior transition |
| state | `deprecated`: supported, documented with its replacement, unchanged at runtime; this is the documentation step of the component deprecation procedure. Implementation gap: on current `main` the declarations, consumer docs, CLI block templates, and Lab consumers do not yet carry or use the replacement, so the state is not yet realized |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No current record defines a component deprecation procedure or its documentation step (nor the marked alias in open decision). Could this point at AST-017 FR28 instead, or drop the clause?

@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 spec change is clearly scoped, but approval waits on an engineering owner's decision: it adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12, AR3–AR4 and PR1–PR2; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. 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 earlier record fixes are in; one more is needed either way:

  • DEP-0015 lets Lab's MobilePowerSearch keep the switcher after the deprecation patch, but AST-017 FR29c says maintained Astryx packages migrate in that patch, and the FR2 and non-goal it cites concern Lab's own surfaces rather than its use of a deprecated Core component; migrate it with MobileTokenizer, or have AST-017's owner record the exception.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes each step's `purpose` or a guarded `onOpenChange`: an `onCancel` that vetoes Escape and the platform close maps to `purpose="required"` (`form` blocks only the scrim tap and the swipe, and Escape closes every purpose but `required`), and a veto that depends on state maps to an `onOpenChange` that ignores `false` while the flow must stay open; there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is a private, unpublished package, outside `spec:AST-017`'s compatibility promise (its non-goals and FR2), so its migration is optional under FR29c: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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 row lets Lab's MobilePowerSearch keep the switcher after the deprecation patch, but AST-017 FR29c says "Maintained Astryx packages and authoring surfaces migrate in the deprecation patch", with no published-only qualifier. FR2 and the non-goal cited here concern Lab's own surfaces, not its use of a deprecated Core component, and this row already migrates Lab's MobileTokenizer in the same patch. Could MobilePowerSearch migrate in that patch too, or AST-017's owner record the exception there so this row can cite it (the Intent's gap note would follow)?

@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 spec change is clearly scoped, but approval waits on an engineering owner's decision: it adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12, AR3–AR4 and PR1–PR2; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. 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. Three record fixes are needed either way:

  • DEP-0015 still lets Lab's MobilePowerSearch keep the switcher after the deprecation patch, but AST-017 FR29c says maintained Astryx packages migrate in that patch and approving these records does not amend AST-017; migrate it with MobileTokenizer, or record the exception in AST-017 and cite it, and drop the open reading from the row.
  • The layer-runtime record now says a covered sheet reports itself absent to the dismissal stack and is not a toast outlet, but on main it stays present and registered; add gap notes for both sentences, as component:BottomSheet FR7 has.
  • DEP-0015 step (6) maps an Escape-vetoing onCancel to purpose="required" without saying that also makes the step an alertdialog; name it, or point that case at the guarded onOpenChange.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes each step's `purpose` or a guarded `onOpenChange`: an `onCancel` that vetoes Escape and the platform close maps to `purpose="required"` (`form` blocks only the scrim tap and the swipe, and Escape closes every purpose but `required`), and a veto that depends on state maps to an `onOpenChange` that ignores `false` while the flow must stay open; there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is a private, unpublished package; `spec:AST-017` lists private packages among its non-goals and FR2 puts them outside the published compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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 row keeps Lab's MobilePowerSearch on the switcher after the deprecation patch on this record's own reading of AST-017 FR29c, but FR29c says "Maintained Astryx packages and authoring surfaces migrate in the deprecation patch" with no published-only qualifier, and approving this record does not amend AST-017. Could MobilePowerSearch migrate with MobileTokenizer, or the exception be recorded in AST-017 and cited here? Either way the row should not leave the reading open.

Comment thread docs/architecture/layer-runtime.md Outdated
Comment on lines +250 to +253
requests. A BottomSheet covered by, or being replaced by, another sheet of its
stack reports itself absent to the shared stack while that lasts
(`component:BottomSheet` FR7), so the order among sheets is the sheet stack's
and the shared stack stays one registry. Other family members still use local Escape handling as listed in

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On main a covered sheet still reports itself present (isPresent is the dialog's open state in BottomSheet.tsx) and the shared stack ranks depth first; likewise every open scrim sheet stays registered as a toast outlet, so the toast paragraph's "a covered sheet is not an outlet" is unmet too. component:BottomSheet FR7 carries this gap; could this record carry gap notes for both sentences?

| replacement | One standalone `BottomSheet` per step with `isOpen={step === id}`, driven by one state value; closing the open step and opening the next in the same update, from a control inside the open step (or from code while focus is in it), runs the same handoff (`component:BottomSheet` FR10), for modal and `hasScrim={false}` flows alike. A step changed by a control outside the flow's sheets — a page control beside a non-modal flow, or code acting while focus is on a page control — is a close and an open with their own push and pop motion, where the switcher ran its handoff on any `activeSheet` change; code acting while focus is in the step or on the document body still runs the handoff (`component:BottomSheet` FR10's actor is inside the step, or none) |
| direct authority | DEC-3 here; `component:BottomSheet` DEC-2 and FR10 own the replacement's behavior. Owners: `cixzhang`, `imdreamrunner` |
| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes each step's `purpose` or a guarded `onOpenChange`: an `onCancel` that vetoes Escape and the platform close maps to `purpose="required"` (`form` blocks only the scrim tap and the swipe, and Escape closes every purpose but `required`), and a veto that depends on state maps to an `onOpenChange` that ignores `false` while the flow must stay open; there is no per-event veto; 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Step (6) maps an Escape-vetoing onCancel to purpose="required", which on a standalone sheet also sets role="alertdialog" and blocks the scrim tap and the swipe, while a switcher step with info or form keeps its dialog role. Could the step name the role change, or point an Escape-only veto at the guarded onOpenChange?

@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 spec change is clearly scoped, but approval waits on an engineering owner's decision: it adds new normative requirements to three current records (BottomSheet DEC-2 with FR5–FR12, AR3–AR4 and PR1–PR2; BottomSheetSwitcher DEC-3, DEP-0015 and FR10; two layer-runtime paragraphs), and no owner approval is recorded for this head yet. 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 covered-sheet gap notes and the alertdialog naming are in; three record fixes are still needed either way:

  • DEP-0015 still lets Lab's MobilePowerSearch keep the switcher after the deprecation patch, but AST-017 FR29c says maintained Astryx packages migrate in that patch and approving these records does not amend AST-017; migrate it with MobileTokenizer, or record the exception in AST-017 and cite it, and drop the open reading from the row.
  • The layer-runtime toast paragraph now says the viewport is above every top-layer surface shown before a toast is raised, but only modal outlets and the no-scrim sheet announce themselves, and a non-modal Drawer (showPopover()) still covers toasts on main; scope the sentence to those surfaces, or add a gap note for the others.
  • DEP-0015 step (6) says the guarded onOpenChange keeps the step's scrim-tap and swipe behaviour, but it ignores those closes too while it holds, since there is no per-event veto; please say so.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes a guarded `onOpenChange` on each step — one that ignores `false` while the flow must stay open — which keeps the step's role and its scrim-tap and swipe behavior and so is the primary mapping for a veto of Escape and the platform close, conditional or not; `purpose="required"` is the mapping only when the step should also block the scrim tap and the swipe and be exposed as an `alertdialog`, which that purpose does on a standalone sheet (`form` blocks only the scrim tap and the swipe, and Escape closes every purpose but `required`); there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is a private, unpublished package; `spec:AST-017` lists private packages among its non-goals and FR2 puts them outside the published compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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 row still keeps Lab's MobilePowerSearch on the switcher after the deprecation patch on this record's own reading of AST-017 FR29c, but FR29c says "Maintained Astryx packages and authoring surfaces migrate in the deprecation patch" with no published-only qualifier, and approving this record does not amend AST-017. Could MobilePowerSearch migrate with MobileTokenizer, or the exception be recorded in AST-017 and cited here, with the open reading dropped from the row?

| replacement | One standalone `BottomSheet` per step with `isOpen={step === id}`, driven by one state value; closing the open step and opening the next in the same update, from a control inside the open step (or from code while focus is in it), runs the same handoff (`component:BottomSheet` FR10), for modal and `hasScrim={false}` flows alike. A step changed by a control outside the flow's sheets — a page control beside a non-modal flow, or code acting while focus is on a page control — is a close and an open with their own push and pop motion, where the switcher ran its handoff on any `activeSheet` change; code acting while focus is in the step or on the document body still runs the handoff (`component:BottomSheet` FR10's actor is inside the step, or none) |
| direct authority | DEC-3 here; `component:BottomSheet` DEC-2 and FR10 own the replacement's behavior. Owners: `cixzhang`, `imdreamrunner` |
| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `onCancel` becomes a guarded `onOpenChange` on each step — one that ignores `false` while the flow must stay open — which keeps the step's role and its scrim-tap and swipe behavior and so is the primary mapping for a veto of Escape and the platform close, conditional or not; `purpose="required"` is the mapping only when the step should also block the scrim tap and the swipe and be exposed as an `alertdialog`, which that purpose does on a standalone sheet (`form` blocks only the scrim tap and the swipe, and Escape closes every purpose but `required`); there is no per-event veto; 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Step (6) says the guarded onOpenChange keeps the step's scrim-tap and swipe behaviour, but on a standalone sheet Escape, the scrim tap and the swipe all arrive as onOpenChange(false), so the guard holds the step open against all three while it applies; the switcher's onCancel vetoed only the cancel request. Could the step say so, so an Escape-only veto is not read as having an exact equivalent?

Comment thread docs/architecture/layer-runtime.md Outdated
Comment on lines +334 to +335
handoff survive. The rule is one of order, not of modality: the viewport is
above every top-layer surface that was shown before a toast is raised. A

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 rule covers every top-layer surface, but only modal outlets and the no-scrim sheet announce themselves through the registry. A non-modal Drawer is a popover="manual" surface shown with showPopover(), and on main the viewport re-enters the top layer only when the outlet changes, so a toast raised while that Drawer is open paints beneath it. Could the sentence be scoped to outlets and non-modal sheets, or gain a gap note naming the surfaces that do not announce?

@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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10 and the amended Handoff state); 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. One record fix is needed either way: DEP-0015's downstream row keeps Lab's MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says maintained Astryx packages migrate in that patch, so migrate it with MobileTokenizer or record a Lab exception in AST-017 first and cite it here.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `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 the switcher's `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`); there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is marked `private` and is published to npm only under the `@canary` dist-tag, never as a stable `latest` release; `spec:AST-017` calls it "the private canary-only Lab package" (FR27), lists private packages among its non-goals, puts them outside the published compatibility promise (FR2), and says canary-only surfaces carry no stable compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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 row keeps Lab's MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says "Maintained Astryx packages and authoring surfaces migrate in the deprecation patch." FR2 and FR27 bound what Lab promises its consumers, not Lab's own use of a deprecated Core surface, and this row migrates Lab's MobileTokenizer in the same patch. Approving this record does not amend AST-017, so either migrate MobilePowerSearch with MobileTokenizer (and drop the Intent's "is migrated by its own change" sentence), or record the Lab exception in AST-017 FR29c first and cite it here.

@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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10 and the amended Handoff state); 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. One record fix is needed either way: DEP-0015's downstream row keeps Lab's MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says maintained Astryx packages migrate in that patch, so migrate it with MobileTokenizer or record a Lab exception in AST-017 first and cite it here.

[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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10 and the amended Handoff state). An engineering owner records it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Three record fixes are needed either way:

  • DEP-0015's downstream row keeps Lab's MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says maintained Astryx packages migrate in that patch. Migrate it with MobileTokenizer, or record a Lab exception in AST-017 first and cite it here.
  • The DEP-0015 verification row checks only the switcher's own docs, but BottomSheet.doc.mjs still says to use BottomSheetSwitcher for multi-step flows. Add BottomSheet's consumer doc to the row and to the Intent's gap note.
  • The switcher's Public concepts table still lists its concepts, sheetId included, as released. Mark them deprecated to match the Intent and DEP-0015.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `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 the switcher's `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`); there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is marked `private` and is published to npm only under the `@canary` dist-tag, never as a stable `latest` release; `spec:AST-017` calls it "the private canary-only Lab package" (FR27), lists private packages among its non-goals, puts them outside the published compatibility promise (FR2), and says canary-only surfaces carry no stable compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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.

spec:AST-017 FR29c ends "Maintained Astryx packages and authoring surfaces migrate in the deprecation patch." FR2 and FR27 limit what Lab promises its own consumers, not Lab's use of a deprecated Core surface, and this row migrates Lab's MobileTokenizer in the same patch. Approving this record does not amend AST-017. Either migrate MobilePowerSearch with MobileTokenizer (and drop the Intent's "is migrated by its own change" sentence), or record a Lab exception in AST-017 FR29c first and cite it here.

| FR9 | `BottomSheetSwitcher.test.tsx`; export checks | ref across open/handoff/close/unmount, callback-ref cleanup, `id` once, DOM/ARIA/data/events, class, style, xstyle | A supported input is dropped or duplicated, an object ref is null while a sheet is mounted or skips a cleanup, or an input overrides component-owned semantics accidentally. | `audit:BottomSheetSwitcher/api` |
| Theming anatomy | `scripts/check-knowledge.mjs`; theming tests | dialog, delegated sheet panel, scrim | A non-painting host gains a target, a delegated panel loses its owner, or the scrim gap disappears without review. | `audit:BottomSheetSwitcher/theming` |
| FR10 | `component:BottomSheet`'s stacking suite (the switcher-as-a-level cases); real-browser dim and recede sampling of a flow stacked over, and under, a standalone sheet | switcher base covered by a standalone sheet, switcher on top of a standalone sheet, a sheet in a step's content, flow closing over a lower sheet | Two backdrops paint, the covered level does not recede, a covered flow takes Escape, or focus lands on `body` when the flow closes over a sheet. Implementation gap: the suite does not exist on current `main`. | `audit:BottomSheetSwitcher/behavior` |
| DEP-0015 | Declaration and consumer-doc inspection; `@deprecated` metadata on the three declarations; CLI template and story sources | the switcher's declarations, docs, templates, and stories | A declaration lacks `@deprecated`, a maintained template or story teaches the switcher, or a production runtime branch, log, or output changes with the deprecation. Implementation gap: on current `main` every declaration lacks `@deprecated` and the templates teach the switcher. | `audit:BottomSheetSwitcher/api` |

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 row checks only the switcher's own declarations, docs, templates and stories. packages/core/src/BottomSheet/BottomSheet.doc.mjs still says "use BottomSheetSwitcher for multi-step flows" (lines 175 and 327) and tells multi-step flows to configure hasScrim on the switcher (line 168), and spec:AST-017 FR29b requires maintained docs to teach only the replacement. Could the row's states and failure list, and the Intent's gap note, name BottomSheet's consumer doc too?

prop or changing a default. Where the shipped switcher does not yet meet a
requirement, the row carries a present-tense implementation gap note. The
modality, identifier, and theming questions below stay open.
prop or changing a default. Its public lifecycle state is `deprecated`

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 lifecycle here is deprecated, but the Public concepts table (lines 138–142) still gives every switcher concept, sheetId included, the Stability released. The template's Stability column holds the lifecycle state, so could those cells read deprecated (DEP-0015)?

@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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10, the deprecated Stability cells and the amended Handoff state). An engineering owner records it by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. One record fix is needed either way:

  • DEP-0015's downstream row and the Intent keep Lab's MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says maintained Astryx packages migrate in that patch: migrate it with MobileTokenizer, or record a Lab exception in AST-017 first and cite it here.

The earlier notes on BottomSheet's consumer doc and on the Public concepts Stability column are resolved at this head.

[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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR14, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10, the deprecated lifecycle, and the Stacked and amended Handoff states). 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. Either way, one record fix is needed, and one wording fix is suggested:

  • DEP-0015's downstream row and the Intent keep Lab's MobilePowerSearch on the switcher after the deprecation patch. spec:AST-017 FR29c says maintained Astryx packages migrate in that patch, so either migrate it with MobileTokenizer or record a Lab exception in AST-017 first and cite it here.
  • BottomSheet's Compatibility section says a lone modal sheet behaves as released, but FR15 changes its reduced-motion entrance and exit. State that exception, as the switcher record does for its handoff.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `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 the switcher's `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`); there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is marked `private` and is published to npm only under the `@canary` dist-tag, never as a stable `latest` release; `spec:AST-017` calls it "the private canary-only Lab package" (FR27), lists private packages among its non-goals, puts them outside the published compatibility promise (FR2), and says canary-only surfaces carry no stable compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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 row keeps MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says "Maintained Astryx packages and authoring surfaces migrate in the deprecation patch." FR2, FR26 and the non-goal it cites are about Lab's own surfaces, not about Lab using a deprecated Core one. To fix it, either:

  • migrate MobilePowerSearch with MobileTokenizer in the patch, and drop this clause and the Intent sentence at line 56; or
  • record the Lab exception in AST-017 first, then cite it here.

Comment on lines +72 to +73
implementation-equivalent for a sheet on its own: a modal sheet presented on
its own behaves as released (focus on open wherever the engine's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under reduced motion, a lone modal sheet does not behave as released: FR15 moves its entrance and exit from shortened transforms to design:reduced-motion's cross-fade or immediate change. Worth stating that exception here, as the switcher record does for its handoff: a visual-only motion change under spec:AST-017 FR3.

@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 of this spec change waits on an engineering owner’s decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10 and the deprecated lifecycle). 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. One record fix is needed either way: DEP-0015 and the switcher’s Intent keep Lab’s MobilePowerSearch on the switcher after the deprecation patch, while spec:AST-017 FR29c says maintained Astryx packages migrate in that patch, so migrate it with MobileTokenizer or record a Lab exception in AST-017 first and cite it. The description also still says all three records carry approved_by: imdreamrunner, which neither changed record does, so it is worth refreshing before an owner reads it.

[Automated review]

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `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 the switcher's `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`); there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is marked `private` and is published to npm only under the `@canary` dist-tag, never as a stable `latest` release; `spec:AST-017` calls it "the private canary-only Lab package" (FR27), lists private packages among its non-goals, puts them outside the published compatibility promise (FR2), and says canary-only surfaces carry no stable compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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 row keeps Lab’s MobilePowerSearch on the switcher after the deprecation patch, but spec:AST-017 FR29c says “Maintained Astryx packages and authoring surfaces migrate in the deprecation patch”, with no private or canary exception; the FR2, FR26 and non-goal clauses cited here cover Lab’s own surfaces, and this row migrates Lab’s MobileTokenizer anyway. Approving this record does not amend AST-017, so either migrate MobilePowerSearch with MobileTokenizer and drop the clause (and the Intent sentence at lines 56–57), or record the Lab exception in AST-017 first and cite 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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10 and the deprecated lifecycle). 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. One record fix is needed either way, because approving these records does not amend AST-017:

  • DEP-0015's downstream row and the switcher's Intent keep Lab's MobilePowerSearch on the switcher after the deprecation patch, while spec:AST-017 FR29c says maintained Astryx packages migrate in that patch, so migrate it with MobileTokenizer or record a Lab exception in AST-017 first and cite it.

[Automated review]

…pdate switch; BottomSheetSwitcher is deprecated

component:BottomSheet DEC-2 and FR5–FR12 record the stack of standalone modal
sheets per document (open order, tree-order ties, one visible scrim, covered
sheets receded and inert, dismissal to the top sheet only, nested sheets that
leave and return with their host without their state being written, focus
return on a pop and on a close of the whole stack), the same-update replace
handoff between standalone sheets, non-modal sheets hosted in the top layer as
manual popovers, and a BottomSheetSwitcher flow as one level of the stack.
AR3–AR4 record the inertness and focus contract; PR1–PR2 the one registry per
page and the one-backdrop cost; the verification map names the stacking and
replace suites.

component:BottomSheetSwitcher stays current with every requirement in force;
DEC-3 and DEP-0015 record its lifecycle as deprecated (supported, maintained,
docs point to standalone step sheets, no runtime warning, removal only in a
future declared minor), and FR10 records how a flow takes part in the stack.
Its per-step dialogs (DEC-1, DEC-2) are unchanged.

architecture:layer-runtime records the toast viewport's re-entry above a
non-modal top-layer surface and the sheet stack's standing-down from the
shared dismissal stack.

Rows current main does not meet carry present-tense implementation gap notes.

@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 of this spec change waits on an engineering owner's decision on the new requirements it adds to component:BottomSheet (DEC-2, FR5–FR15, AR3–AR5, PR1–PR2) and component:BottomSheetSwitcher (DEC-3, DEP-0015, FR10 and the deprecated lifecycle); 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. Two record fixes are needed either way, because approving these records amends neither spec:AST-017 nor design:reduced-motion:

  • DEP-0015's downstream row and the switcher's Intent keep Lab's MobilePowerSearch on the switcher after the deprecation patch, while spec:AST-017 FR29c says maintained Astryx packages migrate in that patch, so migrate it with MobileTokenizer or record a Lab exception in AST-017 first and cite it.
  • FR15, the Reduced motion state and the stacking verification row make the covered sheet jump to its receded place and back on a coarse pointer, while design:reduced-motion DR2 requires every such transition there to change opacity only, so cross-fade it within 200 ms or have a design owner record the exception there.

[Automated review]

| FR12 | A modal `BottomSheetSwitcher` flow is one level of the stack: the dialog of its active step is the flow's level, keeps the flow's rank across the flow's own handoffs, recedes and is blocked when covered, yields its backdrop to the stack's scrim owner, stands down from dismissal while covered, and returns focus by FR9. A standalone sheet rendered in a step's content stacks above the flow and leaves with the step (FR8). A switcher step is never paired under FR10. Implementation gap: on current `main` a switcher flow and a standalone sheet open together are unrelated dialogs. | DEC-2; `component:BottomSheetSwitcher/DEC-1` |
| FR13 | Every open scrim sheet supplies its dialog as a passive global outlet for app-global surfaces such as toasts (`spec:AST-003` FR18: each active native modal, a covered sheet included), and a modal `BottomSheetSwitcher` flow supplies its active step's dialog — the one its host dialog resolves to, following each handoff; the retained step's dialog, inert and fading, never hosts. Among sheets, the latest outlet — the one a global host renders into — follows the stack's order, not the order the sheets were supplied in: the top sheet is the latest, so a toast lands in the top sheet and never in a covered, inert one, and when the top sheet closes the sheet it reveals is the latest again and takes the host back. A sheet is raised only among sheet outlets: an outlet supplied later by another modal (a `Dialog` opened from a sheet) stays above every sheet until it closes. A sheet, or a flow, stops supplying as soon as it starts closing, and a native `close` event that fires while its dialog is open again (the stack re-showing it, or a reopen during its exit) does not take its outlet away. Implementation gap: on current `main` the latest outlet is the one supplied last, so a sheet re-shown or revealed by its stack is not it, and a switcher flow supplies no outlet, so a toast raised from a step lands behind the flow. | DEC-2; `spec:AST-003/FR16`, `/FR18`; `architecture:layer-runtime`; `component:BottomSheetSwitcher/FR10` |
| FR14 | When a non-modal sheet enters the top layer, a top-layer toast viewport shown before it leaves and re-enters the top layer in place, keeping focus inside it, so toasts paint above the sheet. Implementation gap: on current `main` a non-modal sheet is not a top-layer surface (`show()` under a page-level `z-index`, beneath the popover viewport), so the toast viewport's re-entry above a non-modal sheet has nothing to answer. | DEC-2; `architecture:layer-runtime` |
| FR15 | Under `prefers-reduced-motion: reduce` the recede, the return from receded, a push's entrance, a pop's exit and the replace handoff — and a `BottomSheetSwitcher` step's, which shares the sheet panel's motion — follow `design:reduced-motion`, keyed on its queries (`prefers-reduced-motion: reduce`, with `pointer: coarse` for the cross-fade): no transform motion runs (DR1) — a covered sheet appears in its receded place and size the frame the push lands and returns the same way, the entering sheet appears in its final place, the retained sheet does not align down; on a coarse pointer the entering sheet fades in and the leaving or retained sheet fades out where it is, opacity only, in 200 ms or less (DR2); a dismissal the release of a touch drag started (a swipe past the dismiss line) fades out in place the same way on every pointer (DR2's drag-release clause); on any other pointer every other change is immediate (DR3); the handoff's steps run in the released order, so the retained sheet is hidden and its dialog closed once its fade ends. The constant page dim through a push, a pop and a handoff (FR6) holds as it does with motion. Implementation gap: on current `main` there is no recede or handoff between standalone sheets, and the shipped sheet's own entrance and exit under reduced motion run their transforms shortened on every pointer and do not cross-fade on a coarse pointer. | DEC-2; `design:reduced-motion` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under reduced motion on a coarse pointer, design:reduced-motion DR2 says each DR1 transition “MUST change opacity only and MUST finish in 200 ms or less”, and its coarse-pointer verification row fails “a change with no cross-fade”. This row has the covered sheet appear “in its receded place and size the frame the push lands” and return the same way on every pointer, so on a touch screen the recede and return are one-frame jumps of scale and offset. Cross-fade the covered sheet into its receded place and back on a coarse pointer (keeping the fine-pointer change immediate), and match the Reduced motion state and the stacking verification row — or have a design owner record the exception in design:reduced-motion first.

| warning | `@deprecated` on the declarations; consumer docs, templates, stories, and release notes name the replacement. No runtime warning (`spec:AST-017` FR29) |
| migration | Ordered and testable, per flow: (1) replace `activeSheet` / `onActiveSheetChange` with one `step` value; (2) give each step `isOpen={step === 'id'}` and `onOpenChange={isOpen => !isOpen && setStep(current => (current === 'id' ? null : current))}` in place of `sheetId="id"`; (3) remove the wrapper, moving its `hasScrim` onto every step; (4) keep the step controls as they are (`setStep('next')`); (5) a flow stepped by a control outside its sheets (a page control beside a non-modal flow, or code acting while focus is on a page control) gets push and pop motion in place of the handoff — move such a control into the step, or accept the motion; code acting while focus is in the step or on the body keeps the handoff; (6) the switcher's other props: `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 the switcher's `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`); there is no per-event veto; 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 |
| codemod | None; the rewrite moves state the caller owns and is short enough to read |
| downstream | The maintained published surfaces migrate in the deprecation patch (`spec:AST-017` FR29c): the CLI block templates and the stories. `@astryxdesign/lab` is marked `private` and is published to npm only under the `@canary` dist-tag, never as a stable `latest` release; `spec:AST-017` calls it "the private canary-only Lab package" (FR27), lists private packages among its non-goals, puts them outside the published compatibility promise (FR2), and says canary-only surfaces carry no stable compatibility promise, which this record reads as FR29c's "maintained Astryx packages" not reaching Lab, so Lab's migration is optional under it: Lab's `MobileTokenizer` migrates with the patch; Lab's `MobilePowerSearch` keeps the switcher until its own change migrates it, and the switcher keeps working for it. Whether `spec:AST-017` is read that way is its owner's to settle. The switcher's own stories remain as its labelled reference. Implementation gap: on current `main` the templates and `MobileTokenizer` use the 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.

spec:AST-017 FR29c reads “Maintained Astryx packages and authoring surfaces migrate in the deprecation patch”, with no published or stable qualifier, and approving this record does not amend it. The clauses cited here (FR2, FR26, FR27, the non-goal) limit the compatibility promise of Lab's own surfaces, not Lab's use of a deprecated Core surface, and this row already treats Lab as maintained by migrating MobileTokenizer. Migrate MobilePowerSearch in the deprecation patch too, or record the Lab exception in AST-017 first and cite it here.

@imdreamrunner
imdreamrunner enabled auto-merge (squash) October 26, 2026 01:22
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 37ddc4a

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026
@imdreamrunner
imdreamrunner merged commit b88081d into main Oct 26, 2026
58 checks passed

This branch was successfully deployed

1 active deployment
Preview — 37ddc4ae Deployed Oct 26, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant