Skip to content

feat(core): BottomSheetSwitcher presents each sheet in its own native dialog - #7720

Merged
imdreamrunner merged 1 commit into
mainfrom
feat/switcher-per-sheet-dialog-native
Oct 22, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
feat/switcher-per-sheet-dialog-native

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The promotion PR #7822 is merged: component:BottomSheet and component:BottomSheetSwitcher are current on main.

Rebased over #7851 (a file picker's bubbling cancel no longer dismisses): its guard in the switcher's handleCancel now runs on every sheet dialog, so a picker dismissed inside a step leaves that step's dialog and the flow open. Its switcher test fires the step's own close request at the active sheet dialog instead of the removed shared-dialog helper, and the comments say where the bubbled cancel lands. It implements component:BottomSheetSwitcher DEC-1 and DEC-2 as recorded in #7744 and promoted there; it carries code, unit tests, builder docs, the Changesets, and the switcher's anatomy rename together with the one record key check:knowledge couples to it. It merges the two per-sheet-dialog prototypes (#7706 and #7710) with every review finding that applied fixed. The page scroll lock is the shared useScrollLock hook Dialog uses, held once by the switcher for the whole modal flow; the standalone sheet's lock is exactly as on main. The Chromium + WebKit browser spec follows in #7757, stacked on this one, so this one stays under the 2,000 authored-line threshold at 1,970.

Record edits in this PR, exactly: the anatomy rename check:knowledge couples to the builder doc (the Sheet dialogs design-relationships row and the two theming-anatomy reasons), and the deletion of the implementation-gap sentences the promotion PR recorded for the shipped shared dialog and this PR closes. Deleted from BottomSheetSwitcher.spec.md: the Owns-bullet gap ("the switcher renders one dialog of its own and hosts every child as a panel inside it; no child owns a dialog yet"); the Dialog-surface gap ("every dialog prop, ref, id, and data-testid reaches the switcher's one shared dialog"); the FR2 gap ("the switcher presents one shared dialog once per flow…"); the FR5 gap ("a script focus trap runs on top of the modal dialog's native containment; a non-modal flow is one shared dialog carrying the page-level value itself, with no flow wrapper"); the FR8 gap ("the one backdrop is the shared dialog's, so no takeover between dialogs happens"); the FR9 gap ("every forwarded input, ref, id, and data-testid reaches the one shared dialog, which never changes on a handoff"); the representative-states lead-in ("Where a state names a sheet's own dialog… FR2 and FR5 record that implementation gap"); the Modal-state gap ("a script focus trap runs on top of the native containment"); the PR1 gap ("one shared dialog is presented throughout, so a handoff has one dialog open, not two"); and the "Shared dialog" row's gap ("one such host serves the whole flow"), which the renamed Sheet dialogs row replaces. Deleted from BottomSheet.spec.md: the Owns-bullet gap ("a switcher-managed sheet renders a panel inside the switcher's one shared dialog and owns no dialog of its own") and the state-row gap ("the panel renders inside the switcher's one shared dialog"). Nothing else in either record changes; the verification-map note that the engine spec is not yet in the tree stays until #7757 adds it, and the spec:AST-027 non-modal gap stays.

Primary intent

BottomSheetSwitcher presents each step in its own native <dialog>, exactly as a standalone BottomSheet does, instead of hosting every step as a panel inside one switcher-owned dialog. The browser then supplies modality per step (inert page, native focus containment, cancel, ::backdrop), and the switcher keeps only what no single dialog can decide: which dialog is presented or retained, one scrim handed between two backdrops without a fade, focus kept in the active sheet when a retained dialog closes, and focus returned to the flow's opener.

The other templates do not fit: nothing is broken (bug-fix), no new prop or export is added (new-feature), pixels are unchanged at rest (visual-update). The Changeset is [feat] (patch): per spec:AST-017 FR7 a compatible capability change ships as [feat], and consumers do gain capability here — native modal containment per step (as Dialog), focus into the panel of a non-modal step, a consumer role reaching the dialogs, a step re-presented on top on a rapid reversal — while [fix] is for a correction of a defect and [chore] for a change consumers cannot observe, and this one is observable (the ref/id host, SSR markup, the per-dialog props).

User or maintainer impact

Builders using BottomSheetSwitcher keep the same props and defaults. What they can observe changes in three places, each documented in the prop docs and the Changeset: the switcher's ref resolves to the dialog hosting the active step and moves on a handoff (read it at use time or pass a callback ref); id/data-testid render on that dialog only, and on no element in server markup of a flow that mounts closed; the other dialog props and handlers reach every step's dialog. Keyboard users get the browser's own modal containment instead of a script trap; a non-modal step's panel takes focus on open. Maintainers get one DOM shape for standalone sheets and switcher steps, and no second switcher-only implementation of modality.

Problem and solution fit

Delta Why
Each BottomSheet renders its own <dialog> in a switcher too; the switcher renders a boxless wrapper one DOM shape, browser-supplied modality per step (component:BottomSheetSwitcher DEC-1)
The switcher presents/retains/closes step dialogs, hands one scrim between backdrops without a fade, re-presents a returning step on top, keeps focus in the active sheet when a retained dialog closes, returns focus to the opener what no single dialog can decide
Host-dialog resolution of ref/id/data-testid; lastHostSheet as state; first-commit attach FR9 / DEC-2
useScrollLock(isModal) once per flow, as on main; standalone lock untouched the shared mechanism, held once
Anatomy Shared dialog → Sheet dialogs in the builder doc and the record's coupled cells; CLI template docs no longer say "shared dialog" docs describe what renders

Comparison summary and what came from where

Two prototypes were compared: a custom coordinator (#7706: per-sheet dialogs, JS focus trap and scroll lock kept, blur-before-present) and a native-first one (#7710: a strict superset of #7706 that drops the trap for native containment, replaces the body pin with a CSS lock, and swaps the blur for closeKeepingFocus; the blur is what shipped in the end, see below). Both measured identical to main in Chromium (dim 0.5 on every frame of every switch, no dip, settled frames pixel-identical, focus panel → panel and back to the opener); #7710 also in WebKit. Neither evidence set conflicted with the other. #7710's CSS scroll lock is not taken: this PR keeps the shared scroll-lock hook that Core's other modal overlays use, held once for the whole flow.

Piece Source
Per-sheet dialog structure; the switcher renders no dialog of its own both (identical); #7710's version taken
Scrim ownership (retained dialog keeps it; entering dialog at 0; takeover with --_sheet-scrim-duration: 0s for one style flush) both; #7706's measurement that 0.01s still paints one dipped frame is why it is 0s
Re-present a still-fading sheet on reversal both
Native focus containment instead of useFocusTrap #7710 (consistent with Core Dialog, which has no JS trap)
Blur before each step's showModal() so no step dialog records an element to return focus to #7706's approach, adopted in the tenth round after #7710's closeKeepingFocus (undo the engine's return) was measured to fire a focus round-trip ~0.6s after Back; see "Focus jump on Back" below
preventScroll on the flow's focus return; backdrop click guarded to the host dialog #7710
CSS scroll lock #7710; prototyped and measured, then not taken: the owner keeps one scroll-lock mechanism across Core's modal overlays, so the switcher holds the shared useScrollLock once for the flow, as main does
Findings 1–4 and 8 new here

How each review finding was resolved

# Finding Resolution Test that would have caught it
1 assignRef called ref(null) and dropped React 19 callback-ref cleanups The host ref is attached through mergeRefs(ref)(host) and its returned cleanup runs on detach — the same path useMergedRefs uses "runs a callback ref cleanup exactly once per detach and never passes it null": a cleanup-returning ref sees [details], then one cleanup and [details, confirm] on the handoff, one more cleanup on unmount, and never a null
2 Switcher props spread onto every sheet dialog, so id/data-testid rendered once per sheet id and data-testid render only on the host dialog (the one the ref resolves to) and move with it; every other dialog prop still reaches every sheet dialog "renders the switcher id and test id once, on the host dialog, and moves them with it": exactly one [id="checkout"] closed, open, and mid-handoff (two open dialogs)
3 zIndex: 1001 on the non-modal path depended on the page-wide 1000 band The switcher renders a flow wrapper: display: contents in a modal flow (the dialogs are in the top layer); in a non-modal flow it is the isolated surface (isolation: isolate, fixed, pointer-events none) that orders the sheet dialogs locally — the interactive sheet at 1, the retained sheet at auto — the way the shared shell ordered its panels before. The wrapper carries the one 1000 the shared shell carried, which spec:AST-027 already records as the non-modal migration gap; no sheet dialog has a page-level value "orders a non-modal handoff inside the switcher-owned isolation surface, with no page-wide z-index on a sheet dialog"
4 ref.current was null while the flow was closed The ref resolves to a real dialog for the switcher's whole mounted life: the active sheet's dialog, the exiting sheet's through its exit, and while closed the dialog that hosted the flow last (or the first mounted sheet's before any open). Documented on the prop. Tier: [feat] patch, nonbreaking, as component:BottomSheetSwitcher DEC-2 records "resolves the switcher ref to the host sheet's dialog and never to null while a sheet is mounted, from the first commit on" (closed before open, open, handoff, exiting, closed after, unmount); pnpm check:changesets validates the tier
8 An effect only copied activeSheet into a ref Folded into the scrim effect, which writes the backdrop in the same pass covered by "paints one scrim through a handoff and hands it over without a fade"

Findings 5–7 were about the CSS scroll lock and are moot: the shared hook stays.

Second-round findings

Finding Resolution Test that would have caught it
Flows that mount closed: the ref was null in a consumer's mount useLayoutEffect/useEffect, and id/data-testid appeared on no element in server markup or the first commit Ref: fixed in full. The switcher's layout effect attaches the ref to the first registered sheet dialog when the host is not yet known — the children's ref callbacks have already run by then — so a parent's mount effects, layout and passive, read a real closed <dialog>, exactly as with the shared dialog. Attachment follows the resolved element, not the render, so the follow-up render that catches sheetOrder up does not detach and re-attach. id/data-testid: narrowed, with the reason. Which sheet mounts first is known only once its dialog registers during the first commit; the only render-time way to know it is to introspect children, which the repo's @astryx/no-react-introspection lint rule forbids (Children.toArray, reading child.props.sheetId), and an always-present placeholder <dialog> would hand consumers an element that is never shown. So the attributes render on the first sheet's dialog from the first commit's synchronous re-render on (before the first paint — a state update scheduled in a ref callback flushes with the commit), and server markup of a flow that mounts closed carries them on no element. The prop doc, .doc.mjs, and Changeset say so. The visible consequence is confined to renderToString output and to code that reads getElementById inside its own mount effect "resolves the switcher ref … from the first commit on" now records ref.current inside a parent's mount useLayoutEffect and useEffect — fails on the previous head (null), passes now. "renders every sheet dialog closed in server markup, with the switcher id on no element until the first commit" pins the narrowed contract: renderToString yields two closed dialogs and no id; hydrating the same tree puts the id on the first sheet's dialog exactly once
lastHostSheetRef read during render (architecture:react-component-runtime INV4) The last host is useState, adjusted during render when the presented host changes (if (presentedHostSheet != null && presentedHostSheet !== lastHostSheet) setLastHostSheet(...)), the sanctioned "state from previous renders" pattern; no ref is read during render the ref/id tests above (the resting host is rendered output: the id on a closed flow)
Reduced motion (third round): under prefers-reduced-motion: reduce the backdrop's literal transitionDuration: '0.01s' outranked the 0s takeover pin, so every handoff painted 1–2 frames with no scrim The reduced-motion branch reads the pin: transitionDuration: 'var(--_sheet-scrim-duration, 0.01s)'. Measured in Chromium with reduced motion emulated: previous head 2 dip frames to dim 0 on every switch (Continue, Back, Continue); now 0 dip frames, dim 0.5 on all 73–74 frames per switch "keeps the scrim takeover pin under prefers-reduced-motion": reads the compiled reduced-motion ::backdrop rules and asserts the duration is var(--_sheet-scrim-duration, 0.01s), not a literal — fails with the literal restored
INV4 note (third round) No render-time read of lastHostSheetRef remains: the last host has been useState since the previous round, and the ref no longer exists in the file; the line the review pointed at is the state-adjust-during-render block itself —
role forwarding (fourth round): role={purpose === 'required' ? 'alertdialog' : undefined} after the spread dropped a consumer role, which the shared dialog let through role={purpose === 'required' ? 'alertdialog' : switcherDialogProps.role}. Audit of every explicit attribute on the sheet dialog against main's shared dialog: aria-label/aria-labelledby keep consumer precedence (unchanged); aria-modal was host-owned on main too; aria-hidden/inert are new host state with no consumer value on main; onCancel/onClick/onKeyDown are composed consumer-first as on main; className/style/xstyle merge as on main. Only role had regressed "applies the switcher dialog props to each sheet dialog, with a consumer role kept unless a required step forces alertdialog" (className, style, role, aria-describedby, data-*, onToggle on all three dialogs; required still alertdialog) — fails on the previous head (role absent); "forces alertdialog on a required step and leaves the other sheet dialogs with no role when the switcher sets none"
Focus jump on Back (tenth round, real bug): the retained dialog's close() ~0.6s after Continue/Back moved focus to the element that was focused when that dialog was presented (the previous step's Continue/Back, or the opener), and closeKeepingFocus moved it back — blur/focusout/focus/focusin on the active panel and on the old control with no user action. Measured with real pointer clicks: Chromium and WebKit both run the return even though focus is in another open dialog by then (the HTML spec's "only if focus is inside the closing dialog" condition is not what either engine does today) Pre-empted: presentStepModally() blurs the focused element in the same task right before showModal(), so the dialog records nothing and its close() moves nothing; the entering step then focuses its own panel, and the switcher returns focus to the opener itself at flow end (triggerRef, as before). closeKeepingFocus is removed; every close is a plain close(). No frame paints without focus (blur and present in one task); the control is past its press (click has fired) and shows no ring (pointer focus), so nothing visible changes. Options weighed: recording a harmless element at showModal() time (this), focusing the entering step before the retained element matters (impossible: a closed dialog's content is not focusable), closing while focus is in the active dialog (already the case; both engines return anyway) "moves focus only when a step is entered or the flow ends: a retained dialog closing after its fade fires no focus event" — document-level capture of focus/blur/focusin/focusout through Continue, Back and a mid-handoff reversal, each retained close asserted to fire zero events, with the dialog stub returning focus the way the engines do — fails on the previous head (blur:Back → focus:Start flow). Browser: focus-events harness with real clicks, both engines, main vs this build: Continue 8, Back 8, reversal 16, Continue 8 events, all at click time — identical to main; the previous head read 8/16/24/16 with the extra eight ~620ms after each switch
Double backdrop on a rapid reversal (eighth round, real bug): Back while the previous step's dialog was still open re-presented it with closeKeepingFocus + showModal() and --_sheet-scrim-opacity: 0, but that dialog had closed with its backdrop at 1, so the backdrop eased 1 → 0 over --duration-medium on top of the new owner's — two backdrops lit (dim 0.75) for 8 frames in Chromium, 1 in WebKit presentWithClearScrim(dialog): pins --_sheet-scrim-duration: 0s, sets the opacity to 0, calls showModal(), flushes ::backdrop style, lifts the pin — the same shape as takeOverScrim. Reduced motion honours it because the reduced-motion duration already reads the same variable. Why the earlier harness missed it: it sampled Continue, then Back ~1,400ms later (after the handoff had finished), where the returning dialog is closed and freshly presented, so nothing eased; the new harness clicks Back during the handoff at 60, 100, 400 and 900ms and reproduces the previous head's 8 over-frames at every in-handoff timing, 0 at 900ms "re-presents a returning sheet with its backdrop cleared instantly, so the handed-over scrim is never doubled" (records --_sheet-scrim-duration/-opacity at showModal() time: 0s/0 for the returning dialog, pin lifted after; exactly one dialog at opacity 1) — fails on the previous head; per-frame re-measure: 0 over-frames on Hug content, Capped and Narrow viewport × 4 reversal timings, Chromium and WebKit, normal and reduced motion
SSR wording (seventh round): the prop doc and the JSDoc header said server markup never carries id/data-testid scoped to a flow that mounts closed, as the Changeset already had it; a flow that mounts open carries both on the active step's dialog "renders the switcher id and data-testid once, on the active sheet's dialog, in the server markup of a flow that mounts open"
Independent inconsistency check (ninth round) BottomSheetSwitcher.doc.mjs description now says ref/id/data-testid resolve to the host and the other dialog props and handlers apply to every sheet dialog (it said "dialog props resolve to the dialog hosting the active sheet"). CLI template docs BottomSheetSwitcherShowcase.doc.mjs / BottomSheetSwitcherReviewFlow.doc.mjs describe each step in its own native dialog under one scrim (they said "one shared dialog"), with a [docs] @astryxdesign/cli Changeset as coverage requires. BottomSheetEdgeTint.test.tsx's comment no longer claims the tint "must not be minted per child sheet": each presented step dialog carries its own tint, one at rest and one per open dialog through a handoff (the retained dialog's sits beneath the entering dialog) EdgeTint: "gives a modal switcher flow exactly one tint at rest, and one per presented dialog through a handoff" (two tints mid-handoff, in dialog order). cancel: the test "dismisses an info flow from a cancel on any sheet dialog, and from a backdrop click on the active sheet's dialog only" now dispatches cancel on a non-host dialog and asserts it dismisses — only the backdrop click is host-guarded
CSS-lock remnants (sixth round) BottomSheetSwitcher.doc.mjs's description said "one CSS rule locks page scroll while any sheet dialog is modal"; now "the switcher holds the shared body scroll lock once for the whole modal flow". The branch was grepped (source, JSDoc, .doc.mjs, changesets, tests) for CSS rule, :modal, sheetScrollLock, data-sheet-dialog, scrollbar-gutter, keyed off: the only remaining hit is a test-stub comment explaining what the showModal stub stands in for. The inline re-posts at BottomSheetSwitcher.tsx:336 (lastHostSheetRef) and BottomSheet.tsx:115 (reduced motion) point at the state-adjust block and, at L122, the var(--_sheet-scrim-duration, 0.01s) line (L115 is the default-duration line above it): stale —
Scroll lock (owner direction, fifth round) The CSS lock is dropped. The switcher calls useScrollLock(isModal) at flow level exactly where main does; isModal stays true through a handoff (two dialogs open) and through the exit motion, so the shared counter is taken once and given back once. The standalone sheet keeps useScrollLock(shouldPresent && hasScrim) as on main. No per-step lock anywhere "holds one page scroll lock for the whole flow: engaged continuously through a handoff, released once after the exit" (window.scrollTo — the hook's one restore call — never fires between steps; <body> pinned at -300px throughout; one call with (0, 300) after the exit); "keeps the lock through a StrictMode replay of a flow mounted open, and releases it when the flow unmounts while presented"; "keeps the lock engaged under a Core Dialog opened from a step" (shared counter: the Dialog's close gives back only its own hold); main's own BottomSheet and BottomSheetSwitcher body-pin tests pass unedited
Size (eleventh round) the gap-sentence deletions put this PR at 2,056; the two server-markup tests (mounts-open, 30 lines; mounts-closed + hydrate, 53 lines) moved to #7757 with their renderToString/hydrateRoot imports; this PR is 1,970 —
Size (tenth round) the focus fix + test put this PR at 2,022; the reduced-motion compiled-stylesheet test (53 lines) moved to #7757; this PR is 1,994 —
Size (eighth and ninth rounds) the reversal fix + test put this PR at 2,071; the unit test "keeps the lock engaged under a Core Dialog opened from a step" (79 lines with its DialogInStepFlow fixture) moved to #7757, where its browser twin lives. The ninth-round CLI doc fixes, their Changeset and the cancel assertions then put it at 2,005, so "forces alertdialog on a required step and leaves the other sheet dialogs with no role when the switcher sets none" (21 lines; the role test above it keeps the required-step assertion) and the new EdgeTint mid-handoff case moved to #7757 as well; this PR is 1,984 —
Size: 2,010 authored lines Split along the code/evidence seam: this PR carries every source change, every unit test (including each finding's test), and the anatomy rename with its coupled record key (1,858 authored lines); the stacked follow-up carries the Chromium + WebKit spec. Four inline test fixtures were consolidated into one HostFlow fixture, losing no assertion —

Before / after structure

Before (main): one switcher-owned <dialog>; steps are panels inside it. After: the switcher renders a boxless wrapper; each child renders what a standalone sheet renders.

<div style="display: contents">                                  <!-- switcher flow wrapper -->
  <dialog aria-label="Step A" inert aria-hidden="true">  <!-- retained: open until its panel fades; owns the scrim -->
    <div inert aria-hidden="true"><div class="astryx-bottom-sheet">step A</div></div>
  </dialog>
  <dialog id="…" aria-label="Step B" aria-modal="true">  <!-- host: showModal() on entry; ref, id, cancel -->
    <div><div class="astryx-bottom-sheet">step B</div></div>
  </dialog>
</div>

Behaviour deltas

Area main This PR Note
Native calls per switch; open dialogs mid-handoff 0; 1 showModal(next) at click, close(previous) ~650ms later; 2 measured, both engines
Page dimming through a switch 0.5 constant 0.5 constant, no dip, no double dim 68 samples per switch, dipFrames=0, both engines, normal and reduced motion
Page scroll lock useScrollLock(isModal) once per flow same hook, same place, same isModal; held through the two-dialog handoff with no release between steps, restored to the saved offset after the exit measured: <body> position: fixed; top: -300px throughout, scrollY 0 → 300 after exit, 0 window scroll events mid-handoff, both engines
Tab inside a modal step JS trap wraps directly native containment: one browser-UI stop per lap in Chromium, two in WebKit; never the page; mid-handoff, inside the entering dialog while the retained one is inert same as Core Dialog
Focus after a switch (modal / non-modal) panel / lost to <body> panel / panel non-modal is an improvement
Focus return on close opener opener, preventScroll
Rapid reversal (Back while the previous dialog is still open) panel z-index swap close() + showModal() of the returning sheet with its backdrop cleared under a 0s pin; dim 0.5 on every frame, no dip, no double dim, no focus jump 2 extra native calls; measured at Back = 60/100/400ms after Continue, both engines, normal and reduced motion
ref one persistent dialog the host dialog; identity changes on a handoff; never null while a sheet is mounted, from the first commit on implementation, per DEC-2
id, data-testid one dialog, in server markup too the host dialog only, moves with the ref; a flow that mounts open carries them on the active step's dialog in server markup too; a flow that mounts closed carries them from the first commit's synchronous re-render on, and its server markup carries them on no element observable change (closed-mount SSR only), documented in the prop doc, JSDoc header, and Changeset in the same words
onCancel, other dialog props and handlers one dialog attached to every sheet dialog; the browser raises cancel on the dialog it presents, the active sheet's stated this way in the prop doc, the JSDoc header, the Changeset, and the record (DEC-2, FR9, Dialog-surface row)
Accessible name / role precedence rule across children each dialog named by its own sheet; consumer aria-label still wins equivalent output
Non-modal ordering shell at 1000, panels at local 1 wrapper at 1000 (the recorded gap), dialogs at local 1/auto no new band
SSR markup one closed <dialog> with the consumer's id a wrapper and one closed <dialog> per sheet, id on none until the first commit hydration-safe (tested with hydrateRoot)

Test edits

Existing suites ran unedited against the new code: 6 failures, all assertions about the shared-dialog structure or the removed trap; no behaviour assertion was weakened.

Test (BottomSheetSwitcher.test.tsx) Edit Reason
helper getSharedDialog() renamed getActiveDialog(); added getSheetDialog, openDialogs, recordDialogCalls (whose close returns focus like a browser) the role query resolves to the active sheet's dialog (the retained one is aria-hidden)
"keeps the previous sheet stationary until the new entrance finishes, then fades it" dialog[open] count 1 → [details, confirm] during the handoff, [confirm] after; retained dialog asserted inert two dialogs are open through a handoff by design
"replaces an unfinished outgoing sheet during rapid navigation" count 1 → 2; asserts a third showModal() the returning sheet re-presents
"ignores late scrim updates from an outgoing sheet gesture" scrim variable read from the retained (owner) dialog; entering dialog asserted 0 scrim ownership moved to the retained dialog
"dismisses the flow from the one shared scrim" title wording
"keeps focus in a modal sheet that has no tabbable controls" now asserts the panel is focused, the dialog is aria-modal and was presented with showModal(), and no script prevents Tab the old keyDown Tab → false tested the JS trap's preventDefault; the behaviour moves to BottomSheetNativeModal.a11y.browser.spec.ts ("Tab stays inside the active sheet"), Chromium + WebKit

New tests in "one native dialog per sheet" (17, plus one in the switcher suite; four more sit in #7757): per-sheet structure; native call order; one lock for the flow through a handoff and the exit; the lock through a StrictMode replay and released on unmount while presented; the returning sheet re-presented with its backdrop cleared under the pin; the ref across open/handoff/close/unmount including a parent's mount effects; callback-ref cleanups; id once; server markup and hydration (in #7757); non-modal isolation; dialog-prop forwarding; one scrim with instant takeover; focus into the entering sheet with the retained dialog inert; no focus event when a retained dialog closes after Continue, Back or a reversal; cancel by the active sheet's purpose; cancel on any sheet dialog (the browser raises it on the one it presents) and the backdrop click on the host dialog only; non-modal show() per sheet; re-present on reversal; forwarded props on every dialog with role kept and required forcing alertdialog. The Chromium + WebKit spec, and the gutter-fallback handoff/StrictMode unit case, are the stacked follow-up PR.

Authority

  • component:BottomSheetSwitcher DEC-1 (per-step dialogs; the switcher's coordination duties), DEC-2 (host-dialog identity is implementation; nonbreaking), and the rewritten FR2, FR5, FR8, FR9, PR1 — spec PR spec(core): each BottomSheetSwitcher step owns its native dialog #7744.
  • family:overlay-dismissal — one registration for the flow, pointed at the host dialog; native cancel routed through shouldDismissOnCloseRequest(); Layer suites pass unedited.
  • spec:AST-027 — modal flows use showModal() hosting; the non-modal flow's isolation owner is the switcher's wrapper (FR2) with local values inside (FR4); the wrapper's 1000 remains the recorded gap, unchanged from main.
  • architecture:public-component-api INV8 — the ref target is documented on the prop.
  • architecture:component-theming-surface — the anatomy key rename keeps the none/delegatesTo dispositions unchanged; no target is added.

Writing rubric results

The record edits in BottomSheetSwitcher.spec.md are the three cells check:knowledge couples to the builder doc's anatomy, plus the deletion of the implementation-gap sentences listed at the top (twelve sentences across the two records; each described the shipped shared dialog, which this PR replaces): the Sheet dialogs design-relationships requirement cell, the Sheet dialogs theming-anatomy reason, and the Scrim theming-anatomy reason (which names "the sheet dialog that owns the scrim" instead of "the switcher-coordinated native backdrop").

  • R1 narration: the three edited cells describe the parts as they are ("Transparent per-sheet hosts; the active sheet's is the flow's interaction and accessibility boundary (DEC-1)"; "The native backdrop of the sheet dialog that owns the scrim paints it"); no history.
  • R2 research: none in the record.
  • R3 evidence: no change reference added.
  • R4 rejected alternatives: none added.
  • R5 change references: none.
  • R6 open questions: untouched (OQ1–OQ3 keep their options).

Change and evidence

Files: under packages/core/src/BottomSheet/, plus the two Core Changesets, the two CLI template docs under packages/cli/assets/templates/blocks/components/BottomSheet/ and their [docs] @astryxdesign/cli Changeset: BottomSheetSwitcher.tsx, BottomSheet.tsx, BottomSheetSwitcherContext.ts, the two .doc.mjs, BottomSheetSwitcher.test.tsx, a [feat] Changeset (patch), and the anatomy rename: Shared dialog → Sheet dialogs in BottomSheetSwitcher.doc.mjs and, because check:knowledge requires the record's anatomy to match, the same key in BottomSheetSwitcher.spec.md's design-relationships row and theming-anatomy block, with a [docs] Changeset. Builders read an anatomy that matches what the component renders from this PR on. 1,970 authored lines.

API audit. exports map identical; the .d.ts file set identical (no private module added); BottomSheet/index.d.ts and the root index.d.ts byte-identical; BottomSheet.d.ts differs only in doc comments; BottomSheetSwitcher.d.ts differs in doc comments and the destructuring pattern of the declared signature (no type change); BottomSheetSwitcherContext.d.ts changes but is not reachable from a public entry.

Browser (Chromium 149 and WebKit under Playwright 1.61, 390×844 touch, static Storybook of main and of this branch, stories hug-content / capped / no-scrim / standalone showcase). Native call log per switch as above; dim 0.5 on every frame of every switch in both directions, dipFrames=0 overFrames=0, page pixel luminance 127 mid-handoff and settled — and the same with prefers-reduced-motion: reduce emulated (0 dip frames on Continue, Back, Continue; the previous head showed 2 frames at dim 0 per switch); focus panel → panel on modal switches, opener after Escape / scrim tap / swipe; Tab pressed while both dialogs are open lands in the entering sheet's dialog (or the browser-UI stop), never the page or the retained dialog, in either engine; rapid reversal showModal, close, showModal, close with dim 0.5 throughout; a probe showModal() dialog during a handoff hit-tests on top; no horizontal overflow; no console errors or warnings; settled screenshots pixel-identical to main (36 of 36 pairs, both engines), mid-handoff frames differ by 0–7% from animation timing (the re-presented sheet on a rapid reversal is a frame further along). Re-measured on this exact build against main's, both engines: dim, focus after each switch and dismissal, Tab stops, reduced motion, and the scroll lock (<body> pinned at -300px through the handoff, scrollY 0 under wheel, 300 after the exit, 700 after a wheel then) are identical to main's readings; the one difference is the known browser-UI body stop native containment adds per Tab lap (main's JS trap wrapped directly).

Scope

Testing

pnpm exec vitest run packages/core/src/{BottomSheet,Dialog,AlertDialog,Selector,DropdownMenu,ContextMenu,DateInput,DateTimeInput,TimeInput,Tokenizer,Layer,Layout,CommandPalette,MoreMenu,Popover,Lightbox,MobileNav}/ packages/lab/src/MobileTokenizer/
  main:          Test Files 86 passed, Tests 2452 passed (+1 suite, DropdownMenuLazySheet, hits its 10s import-hook timeout under the full parallel run on this host, on main and on every branch alike; it passes alone: 2 passed)
  this branch:   Test Files 86 passed, Tests 2471 passed   (+19 over main, no new file; same unrelated hook timeout)
  main's BottomSheet.test.tsx (body-pin assertions included) against the new code: byte-identical to main, all pass
  main's BottomSheetSwitcher.test.tsx against the new code: 6 failed (all structural, listed above), rest pass
  the mount-effect ref test against the round-3 head's source: fails (null), as intended
pnpm -F @astryxdesign/core typecheck                      ✓
ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/BottomSheet/   0 errors (1 pre-existing warning in an untouched spec)
pnpm check:sync · check:knowledge · check:package-boundaries · check:changesets   ✓
node scripts/sync-exports.js --check                      ✓
git diff --check                                          ✓
pnpm exec vitest run --project node scripts/build-css.test.mjs   11 passed (Core rebuilt first)
node scripts/release/changeset-coverage.mjs pr --base origin/main --head HEAD --released v0.7.1   ✓ (newest tag v0.7.1)
pnpm storybook:build                                      ✓
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project chromium   38 passed (main's set; no browser spec in this PR)
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project webkit     30 passed

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Error Error Oct 22, 2026 6:57am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 22, 2026, 4:50 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 20, 2026
@imdreamrunner
imdreamrunner force-pushed the feat/bottom-sheet-css-scroll-lock branch from 1a42428 to a84ca11 Compare October 20, 2026 17:38
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 3729c1b to 7e6b697 Compare October 20, 2026 17:38

@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 change is on hold for a maintainer decision: one native dialog per step implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve or reject those decisions, including whether the moving ref/id and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017. Whatever that decision, these need fixing before approval:

  • A flow that mounts closed leaves the switcher ref null in consumer mount effects and renders id/data-testid on no element in server markup or the first commit, contrary to the documented "never null while a sheet is mounted".
  • restingHostSheet reads lastHostSheetRef during render, which architecture:react-component-runtime INV4 forbids; keep the last host in state.
  • The PR has 2,010 authored changed lines, over the 2,000-line scope threshold; split it, or justify the exception against an approved spec row once one exists.

[Automated review]

* Ref resolved to the native dialog hosting the flow: the active sheet's
* dialog, which changes on a handoff; the exiting sheet's through its exit;
* and, while the flow is closed, the dialog that hosted it last (or the
* first sheet's), so it is never null while a sheet is mounted.

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 promise does not hold for a flow that mounts closed. The host is picked from sheetOrder, which the sheet dialogs' ref callbacks fill only after the first commit, so a caller's mount useLayoutEffect/useEffect reads ref.current === null, and renderToString output carries neither id nor data-testid (both are present on the base). Either attach the ref and the identifying attributes to an element rendered in the first commit, or narrow this doc, the .doc.mjs and the Changeset; a mount-effect test and a renderToString test would pin whichever you choose.

// flow is closed the dialog that hosted it last (or the first mounted
// sheet's before any open), so the ref resolves to a closed dialog element
// rather than to null, as the shared dialog did.
const restingHostSheet =

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lastHostSheetRef is written in the host layout effect and read here during render to decide which dialog carries the caller's id/data-testid and the ref. architecture:react-component-runtime INV4 says a ref mutation must not be the sole source for a value later read to produce rendered output; keeping the last host in state, updated during render when the presented host changes, would satisfy it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This change is on hold for a maintainer decision: one native dialog per step implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve or reject those decisions, including whether the moving ref and id, the per-dialog props and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. Whatever that decision, one fix is needed before approval:

  • Under prefers-reduced-motion, the backdrop's literal 0.01s transition (BottomSheet.tsx:123-125) overrides the 0s scrim-takeover pin, so every handoff paints one or two frames with no scrim; reading var(--_sheet-scrim-duration, 0.01s) in that branch fixes it.

[Automated review]

transitionDuration: durationVars['--duration-medium'],
// A switcher hands its one scrim from a fading dialog to the next by
// pinning this duration for the swap; a standalone sheet never sets it.
transitionDuration: `var(--_sheet-scrim-duration, ${durationVars['--duration-medium']})`,

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 prefers-reduced-motion: reduce the branch a few lines below sets a literal transitionDuration: '0.01s', which wins over this variable, so takeOverScrim's --_sheet-scrim-duration: 0s pin has no effect there and the entering backdrop eases in from 0 after the retained dialog has closed. Sampling every frame in Chromium, Continue paints 2 frames and Back 1 frame with no backdrop at all (the base paints none). Reading the pin in that branch too fixes it:

'@media (prefers-reduced-motion: reduce)': {
  transitionDuration: 'var(--_sheet-scrim-duration, 0.01s)',
},

@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 change is on hold for a maintainer decision: one native dialog per step implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve or reject those decisions, including whether the moving ref and id, the per-dialog props and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. Whatever that decision, one fix is needed before approval:

  • A role passed to BottomSheetSwitcher no longer reaches any dialog, because role={purpose === 'required' ? 'alertdialog' : undefined} (BottomSheet.tsx:771) overrides it; forwarding the caller's value when the step is not required restores the base behavior.

[Automated review]

</BottomSheetPanel>
</div>
inert={isInactive ? true : undefined}
role={purpose === 'required' ? 'alertdialog' : undefined}

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 explicit undefined replaces a role the caller passed to BottomSheetSwitcher, which still sits in switcherDialogProps spread above, so no sheet dialog gets it: with role="alertdialog" on the switcher the base rendered it on the shared dialog, while here neither sheet dialog has a role and aria-describedby or the class still reach both. The docs in this PR say other dialog props apply to every sheet dialog. Keeping the caller's value when the step is not required fixes it:

role={purpose === 'required' ? 'alertdialog' : switcherDialogProps.role}

@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 change is on hold for a maintainer decision: giving each BottomSheetSwitcher step its own native dialog implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve those decisions as the governing spec, or reject them, and to settle whether the moving ref and id, the props repeated on every step's dialog and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. The fixes from earlier rounds are in, and nothing else needs to change before that decision.

[Automated review]

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from adb3070 to 1221b3b Compare October 21, 2026 01:19
@imdreamrunner
imdreamrunner changed the base branch from feat/bottom-sheet-css-scroll-lock to spec/bottom-sheet-per-sheet-dialog October 21, 2026 01:19

@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 change is on hold for a maintainer decision: giving each BottomSheetSwitcher step its own native dialog implements component:BottomSheetSwitcher DEC-1 and DEC-2 from draft spec PR #7744, which are not approved, while the record on main still describes one shared dialog (FR2, PR1). Approval needs a maintainer to approve those decisions as the governing spec, or reject them, and to settle whether the moving ref and id, the props repeated on every step's dialog and the switch from a script focus trap to native containment are a nonbreaking patch under spec:AST-017, given that v0.7.1 documents the ref as the one shared dialog. Whatever that decision, one fix is needed: the switcher's builder doc still says "one CSS rule locks page scroll" (BottomSheetSwitcher.doc.mjs:145), while this head locks scroll with the shared useScrollLock body pin, as the same file's hasScrim entry says.

[Automated review]

},
description:
'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. The switcher owns one shared native <dialog>: modal flows call showModal() once for one top-layer boundary and one ::backdrop across the whole flow, while no-scrim flows use a non-modal show() shell. Its ref and shared DOM props target that dialog.',
'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. Each child sheet renders its own native <dialog>, exactly like a standalone sheet: modal flows present each sheet with showModal(), no-scrim flows with show(). The browser supplies modality: the page is inert, focus stays in the top-layer dialog, and one CSS rule locks page scroll while any sheet dialog is modal, so both hold across a handoff. The switcher coordinates the rest: one scrim paints at a time, focus returns to the element that opened the flow, and its ref, id, and dialog props resolve to the dialog hosting the active sheet.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This sentence still describes the CSS scroll lock that was dropped: "one CSS rule locks page scroll while any sheet dialog is modal". At this head the switcher holds the shared useScrollLock(isModal) (BottomSheetSwitcher.tsx:387), which pins <body> with position: fixed, so builders should not expect the page offset to stay readable while a flow is open; the hasScrim entry below already says "one body scroll lock held for the whole flow". Suggested wording: "…focus stays in the top-layer dialog, and the switcher holds one body scroll lock for the whole modal flow, so both hold across a handoff."

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 1221b3b to 6a02977 Compare October 21, 2026 02:48

@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 change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which merged in #7744 as draft records with no approver, so they do not yet govern review, and a maintainer needs to make them current. The same decision needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.1 caller that follows the flow through onToggle, or through a listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] with a migration note. Nothing else needs to change before that decision.

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

This change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which merged in #7744 as draft records with no approver, so they do not yet govern review, and a maintainer needs to make them current. The same decision needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.1 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] with a migration note. Nothing else needs to change before that decision.

[Automated review]

@imdreamrunner
imdreamrunner marked this pull request as ready for review October 21, 2026 18:14
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 56c27f5 to 271bec8 Compare October 21, 2026 18:35
@imdreamrunner
imdreamrunner changed the base branch from main to spec/promote-bottom-sheet-records October 21, 2026 18:35
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 21, 2026

@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 change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which are still draft records with no approver on main; their promotion in #7822 has not merged, so a maintainer needs to make those records current before they can govern this pull request. The same decision needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.1 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] with a migration note. Nothing else needs to change before that decision.

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

This change is on hold for a maintainer decision, not for a code change. The per-step dialogs implement component:BottomSheetSwitcher DEC-1 and DEC-2, which are still draft on main: their promotion in #7822 has passed spec-owner approval but has not merged, so those records cannot govern this pull request yet. A maintainer also needs to settle the release tier: spec:AST-017 FR6 says an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.2 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open, so the [feat] patch may need to become [breaking] in a declared minor with a migration note. Nothing else needs to change before that decision.

[Automated review]

@github-actions
github-actions Bot deleted the branch spec/promote-bottom-sheet-records October 22, 2026 06:22
@imdreamrunner
imdreamrunner changed the base branch from spec/promote-bottom-sheet-records to main October 22, 2026 06:44
… dialog

Every child BottomSheet owns a native <dialog>, as a standalone sheet does;
the switcher coordinates which is presented, retained, or closed, hands the
one scrim between dialogs without a fade, keeps focus in the active sheet
when a retained dialog closes, and returns focus to the flow's opener. Focus
containment is the native modal dialog's; the page scroll lock is the shared
hook Dialog uses, held once for the whole modal flow, never per step.

The switcher's ref resolves to the host dialog from the first commit on and
is never null while a sheet is mounted; a callback ref's cleanup runs on
detach; the last host is state adjusted during render. id and data-testid
render on the host dialog only. A non-modal flow orders its sheet dialogs
inside a switcher-owned isolated wrapper instead of a new page-wide z-index
value.

The anatomy part is renamed from Shared dialog to Sheet dialogs in the
builder doc and, as check:knowledge requires, in the component record's
anatomy rows. The implementation gap sentences the per-step dialogs close
are deleted from both records.

@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 change is on hold for a maintainer decision on its release tier, not for a code change. With #7822 merged, the per-step dialogs match the now-current component:BottomSheetSwitcher DEC-1 and DEC-2, but spec:AST-017 FR1 and FR6 say an engineering ruling cannot make an incompatible released contract nonbreaking, and a v0.7.2 caller that follows the flow through onToggle, or through a close listener on the ref it read at mount, now hears the first step's dialog close while the flow is still open. A maintainer needs to settle whether this ships as the declared [feat] patch or as [breaking] in a declared minor with a migration note; nothing else needs to change before that decision.

[Automated review]

@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 991e1df

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 22, 2026

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

Recommended for approval: each BottomSheetSwitcher step now runs in its own native dialog exactly as the current component:BottomSheetSwitcher record describes (DEC-1, DEC-2), the record edits carry spec-owner approval for this revision, and the declared [feat] patch follows the record's nonbreaking classification. In Chromium the flow looks identical to before, keeps one scrim on every frame of each step change, and leaves keyboard containment to the native modal dialog. One optional follow-up for the record owner: no requirement yet says where focus goes on a non-modal handoff, which now moves into the entering step.

[Automated review]

@imdreamrunner
imdreamrunner merged commit 36515dc into main Oct 22, 2026
70 of 71 checks passed
@github-actions
github-actions Bot deleted the feat/switcher-per-sheet-dialog-native branch October 23, 2026 06:23

This branch had an error being deployed

1 failed deployment
Preview — 991e1dfd Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant