Skip to content

test(core): browser evidence for per-sheet switcher dialogs - #7757

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

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

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

#7720 is merged; this PR is based on main. It is the second half of the per-step switcher dialogs: the Chromium + WebKit evidence for what #7720 implements. #7720 is complete and green without it; it is split off so #7720 stays under the 2,000 authored-line threshold.

Because this PR adds __tests__/BottomSheetNativeModal.a11y.browser.spec.ts, it also lists that file in verified_by of component:BottomSheet and component:BottomSheetSwitcher again (the promotion PR #7822 leaves it out until the file exists).

Primary intent

Commit the cross-browser proof for a BottomSheetSwitcher flow whose every step owns a native modal dialog: __tests__/BottomSheetNativeModal.a11y.browser.spec.ts (Chromium + WebKit) — the flow's one page scroll lock (the shared useScrollLock, as Dialog) holding with two open sheet dialogs through a handoff and under a Core Dialog opened from a step, never released between steps nor when that Dialog closes, with the page's offset restored after the exit; focus inside the entering sheet's dialog as soon as both dialogs are open, and Tab stops inside it for as long as the retained dialog is open beneath it, its fade included (and never on the page after the handoff), with the retained dialog inert and aria-hidden on every step it is open; Escape and a backdrop tap dismissing an info flow with focus returned to the opener; one steady scrim through a handoff (no dip, no double dim).

User or maintainer impact

None at runtime. Maintainers get the engine evidence component:BottomSheetSwitcher FR5–FR7 and AR1–AR3 name in their verification rows.

Problem and solution fit

Delta Why
Scroll-lock test asserts the shared hook's behaviour: <body> position: fixed; top: -300px from open through the two-dialog handoff; wheel (both engines) and a CDP touch drag (Chromium) over the backdrop leave scrollY at 0 and the pin unchanged; a window scroll counter reads 0 across the handoff and across a real Core Dialog opened from the step (the new "Dialog from a step" story's Reset → "Reset frequency?" dialog, purpose="form") and closed with Keep — the pin and top: -300px survive its close (a lock released and re-acquired, or a nested holder's release unpinning the page, would restore the offset and fire one); after Escape the pin is gone, scrollY is 300, and a wheel scrolls the page again the earlier version asserted the CSS lock's root rule and "no body pin"; both claims are gone with the mechanism. The round-6 version used a raw showModal() probe, which never takes Core's shared lock, so it could not catch a nested holder's release unpinning the page. Mutant: useScrollLock's release guard reduced to originalBodyState == null (any holder's release unpins) — fails in Chromium and WebKit on position "" ≠ fixed right after the Dialog closes; the real hook passes 3 of 3 repeats in each engine
BottomSheetNativeModal.a11y.browser.spec.ts (4 tests, .a11y.browser.spec.ts so the WebKit project runs it) jsdom presents no dialog and contains no focus; the record's verification map names this file for FR5–FR7 / AR1–AR3
Tab test steps Tab every ~40ms from right after toHaveCount(2) until the retained dialog closes (~650ms), reading the open count, focus location, and the retained dialog's open/inert/aria-hidden in one page.evaluate per step so a close cannot race a retry; asserts focus is inside "Notification frequency" (or on <body>, the browser-UI stop native containment adds) and never in "Set up notifications" or the page; asserts inert and aria-hidden="true" on every step the retained dialog is open; requires more than two mid-handoff presses and at least one after 400ms (inside the fade, which starts ~345ms); requires at least one Tab stop inside "Notification frequency" during the handoff and at least one after it, where a stop is a press that moved focus onto an element outside any inert subtree (body, the browser-UI stop native containment adds per lap, stays allowed but does not count); then 8 presses after the handoff the previous version's attribute checks ran only in the first ~170ms, before the fade. Mutant: dropping fading/exiting from the sheet's isInactive (attributes vanish once the fade starts) fails in Chromium at 344ms and in WebKit at 355ms on retained dialog inert; the fixed build passes 3 of 3 repeats in each engine. Focus itself cannot enter a lower modal dialog in any engine, which is why the attributes are asserted directly. Second mutant: every sheet dialog inert from the start of the handoff on — fails in Chromium and WebKit on midHandoffStopsInside > 0. Third (eighth round): the test now reads focus the moment both dialogs are open, before any Tab, and requires it inside "Notification frequency" and outside every inert subtree (true on this branch, both engines, 3 of 3 repeats); an entering sheet made inert while it enters — focus left on <body> for its first ~300ms — fails in Chromium and WebKit on that first read. Fourth (tenth round): at least one Tab stop inside "Notification frequency" must land after 400ms, inside the retained dialog's fade, so the "its fade included" claim is tested; an entering sheet made inert only while the retained one fades fails in Chromium and WebKit on that count (lateHandoffStopsInside); the real build passes 4 of 4 repeats in each engine

Record edit in this PR, exactly: BottomSheetSwitcher.spec.md's verification map drops "(implementation gap: not yet in the tree)" after BottomSheetNativeModal.a11y.browser.spec.ts, because this PR adds that file. Nothing else in either record changes.

BottomSheetSwitcher.test.tsx gains "keeps the lock engaged under a Core Dialog opened from a step, and the Dialog paints above the step" with its DialogInStepFlow fixture (that fixture renders the Dialog inside the step's content; the story renders it as a sibling — both are valid shapes for FR5, so this is a unit companion rather than a literal twin), and "forces alertdialog on a required step and leaves the other sheet dialogs with no role when the switcher sets none"; BottomSheetEdgeTint.test.tsx gains "gives a modal switcher flow exactly one tint at rest, and one per presented dialog through a handoff". BottomSheetSwitcher.test.tsx also gains "keeps the scrim takeover pin under prefers-reduced-motion" (the compiled-stylesheet check) and the two server-markup cases ("renders the switcher id and data-testid once, on the active sheet's dialog, in the server markup of a flow that mounts open"; "renders every sheet dialog closed in server markup, with the switcher id on no element until the first commit", with hydrateRoot). All six moved here from #7720 to keep that PR under the size threshold.

Tab test hardening (eleventh round). The 400 ms "late" window was measured from a runner-side timestamp taken after the second dialog had been observed open, so on a slow run it started late and could end after the retained dialog closed (1 failure in 16 slow Chromium runs). A MutationObserver installed before the Continue click now stamps performance.now() on the page the moment the second dialog opens, and every step reads its elapsed time from that stamp in the same page.evaluate as the focus and attribute reads; the first read asserts the stamp exists. 32 of 32 (4 tests × 4 repeats × Chromium and WebKit); the fade-only-inert mutant still fails in both engines on lateHandoffStopsInside.

Merge-order note (no change here). #7648 (open-order stacking) deprecates BottomSheetSwitcher and retitles its stories "(deprecated)". If #7648 lands, the "Dialog from a step" story this PR adds becomes one more copyable switcher example in the deprecated stories; whichever direction the owner picks for the switcher decides whether this story should then be removed or kept as the deprecated component's evidence. No package source, documentation, or record changes. The one non-test file is apps/storybook/stories/BottomSheetSwitcher.stories.tsx: a "Dialog from a step" story (the frequency step gains a Reset control that confirms through a Core Dialog), which the lock test drives and which the record's verified_by already lists. The Dialog is a sibling of the step's BottomSheet (a fragment), not sheet content, so the sheet keeps its lone-Section content geometry: the frequency step's panel measures 302px in both "Hug content" and "Dialog from a step" (a closed <dialog> inside the content, even placed before the Section, made it 318px). Removable tagalongs: none.

Authority

Change and evidence

Browser (Chromium 149 and WebKit under Playwright 1.61, static Storybook of this branch). The committed spec: 42 passed in Chromium (38 pre-existing + these 4), 34 passed in WebKit (30 + 4); the whole file 3 of 3 repeats in Chromium and 4 of 4 in WebKit. Re-measured on this build against main's in both engines (dim per frame normal and reduced motion, focus after each switch and dismissal, Tab stops, the lock's pin and offset through a handoff and after the exit): identical to main's readings apart from the browser-UI body Tab stop native containment adds; 0 console errors or warnings.

Writing rubric results

This PR edits two knowledge records, only where the browser spec it adds is named: verified_by in component:BottomSheet and component:BottomSheetSwitcher lists __tests__/BottomSheetNativeModal.a11y.browser.spec.ts again, and the switcher's verification-map row for FR5–FR7 and AR1–AR3 drops its "(implementation gap: not yet in the tree)" note, so the row names the file as it now exists. The rest of that table's diff is column realignment from the shorter cell; no other cell changes.

  • R1 narration: none; the edited row and verified_by entries name the file as present, with no history or "now".
  • R2 research in the record: none.
  • R3 evidence naming a change: none; the row names the verifying file, not a pull request or commit.
  • R4 rejected alternatives over one line or outside a decision: none added.
  • R5 pull request or issue references remaining: none in the edited lines (searched # + digits, /pull/, /issues/).
  • R6 ruled open questions still listing options: none touched.

Scope

  • This PR has one primary intent (the engine evidence).
  • Independently removable deltas were removed or split.
  • Public text and artifacts contain no internal Meta context.

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/
  this branch:   Test Files 86 passed, Tests 2475 passed   (+4 unit cases over #7720; same unrelated hook timeout)
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
node scripts/release/changeset-coverage.mjs pr --base origin/main --head HEAD --released v0.7.1   ✓
pnpm storybook:build                                      ✓
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project chromium   42 passed
pnpm exec playwright test packages/core/src/BottomSheet/__tests__/ --project webkit     34 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 Ready Ready Preview Oct 22, 2026 6:31pm 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, 6:54 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

@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 evidence PR waits on a maintainer decision: the behavior its four browser tests commit as expected (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and a CSS page-scroll lock with no <body> styles) is governed only by component:BottomSheetSwitcher DEC-1/FR2/FR8 and component:BottomSheet FR5/DEC-2, which are still draft, so a maintainer needs to approve or reject those records as proposed in #7744. Separately, the Tab test's not.toBe('retained') check (line 139) cannot fail: its loop starts 900 ms after Continue, after the retained dialog has already closed, and a control inside that dialog can never take focus. Please press Tab while both dialogs are open and assert that focus lands inside the "Notification frequency" dialog, or drop the "never the retained dialog" claim from the file header and the description. The four tests otherwise pass in Chromium and WebKit.

[Automated review]

Comment on lines +123 to +139
await page.waitForTimeout(900);
for (let index = 0; index < 12; index += 1) {
await page.keyboard.press('Tab');
const where = await page.evaluate(() => {
const active = document.activeElement;
if (active == null || active === document.body) {
return 'body';
}
const dialog = active.closest('dialog');
return dialog == null
? 'page'
: dialog.hasAttribute('inert')
? 'retained'
: 'active';
});
expect(where, `after ${index + 1} Tab presses`).not.toBe('page');
expect(where, `after ${index + 1} Tab presses`).not.toBe('retained');

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 loop starts 900 ms after Continue, but the retained dialog closes 644-689 ms after Continue in both Chromium and WebKit, so no retained dialog is open during any Tab press. Even mid-handoff a control inside the retained dialog cannot take focus, and if the inert attribute were missing this classifier would report it as active, so not.toBe('retained') can never fail. Consider pressing Tab right after toHaveCount(2) and asserting that focus is inside the dialog labelled "Notification frequency" (or on <body>), or drop "never the retained dialog" from the header (lines 9-10).

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 7e0b6e9 to a60dd43 Compare October 20, 2026 21:36
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from e161852 to 62e0ac1 Compare October 20, 2026 21:36

@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 evidence PR waits on a maintainer decision: the behavior its four browser tests pin as expected (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and a CSS page-scroll lock with no <body> styles) is governed only by component:BottomSheetSwitcher DEC-1/FR2/FR8 and component:BottomSheet FR5/DEC-2, which are still draft, so a maintainer needs to approve or reject those records as proposed in #7744. The Tab test now presses Tab during the handoff, but its last inert/aria-hidden check runs within about 170 ms of Continue while the retained dialog stays open, and fades, until about 650 ms, so a regression that drops either attribute during the fade still passes even though the header says "throughout" (line 12). Please keep checking until the retained dialog closes, reading the open count and both attributes in one page.evaluate so the close cannot race a toHaveAttribute retry, or narrow the header, the in-test comment and the description to the start of the handoff. All four tests pass in Chromium and WebKit.

[Automated review]

Comment on lines +154 to +171
for (let index = 0; index < 4; index += 1) {
await page.keyboard.press('Tab');
const openCount = await page.locator('dialog[open]').count();
if (openCount < 2) {
break;
}
midHandoffPresses += 1;
const where = await focusLocation(page);
expect(where, `mid-handoff, after ${index + 1} Tab presses`).not.toBe(
'page',
);
expect(where, `mid-handoff, after ${index + 1} Tab presses`).not.toBe(
OPENER,
);
expect(['Notification frequency', 'body']).toContain(where);
await expect(retained).toHaveAttribute('inert', '');
await expect(retained).toHaveAttribute('aria-hidden', 'true');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These four presses all land within the first ~75-170 ms after Continue in both Chromium and WebKit, but the retained dialog stays open until ~645-695 ms and starts fading at ~345 ms, so the inert/aria-hidden checks never see the fade: removing both attributes once the retained panel starts fading leaves this test green in both engines. Line 12 says "throughout" and lines 143-147 say "for as long as it is open". Either keep stepping until the retained dialog closes, reading its state in one call so the close cannot race a toHaveAttribute retry, for example:

for (let step = 0; step < 40; step += 1) {
  await page.keyboard.press('Tab');
  const state = await page.evaluate(name => {
    const retained = Array.from(document.querySelectorAll('dialog')).find(
      dialog => dialog.getAttribute('aria-label') === name,
    );
    const active = document.activeElement;
    const host = active?.closest('dialog');
    return {
      open: retained?.open ?? false,
      inert: retained?.hasAttribute('inert') ?? false,
      hidden: retained?.getAttribute('aria-hidden') ?? null,
      where:
        active == null || active === document.body
          ? 'body'
          : host == null
            ? 'page'
            : host.getAttribute('aria-label'),
    };
  }, OPENER);
  if (!state.open) {
    break;
  }
  midHandoffPresses += 1;
  expect(state.inert, `step ${step + 1}`).toBe(true);
  expect(state.hidden, `step ${step + 1}`).toBe('true');
  expect(['Notification frequency', 'body']).toContain(state.where);
  await page.waitForTimeout(25);
}

or narrow line 12 and the comment to the start of the handoff.

@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 evidence PR waits on a maintainer decision: the behavior its tests commit as expected (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and a CSS page-scroll lock with no <body> styles and its gutter padding fallback) is governed only by component:BottomSheetSwitcher DEC-1/FR2/FR8 and component:BottomSheet FR5/DEC-2, which are still draft, so a maintainer needs to approve or reject those records as proposed in #7744. The Tab test now covers the retained dialog's fade, but its focus checks (lines 194 and 211) accept <body> on every press, so it still passes when focus can never enter the entering sheet, although its title says Tab stays inside it. Please require at least one Tab stop inside "Notification frequency" during the handoff and one after it, or narrow the title, header and comment to "never the page". All four browser tests pass in Chromium and WebKit, and the new unit case passes.

[Automated review]

const label = `mid-handoff, Tab ${index + 1} at ${Date.now() - started}ms`;
expect(step.where, label).not.toBe('page');
expect(step.where, label).not.toBe(OPENER);
expect(['Notification frequency', 'body'], label).toContain(step.where);

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 accepts body on every press, as does line 211, so the test stays green when the entering sheet can never take focus: with every open sheet dialog made inert from the start of the handoff, it passes 3 of 3 in Chromium and in WebKit with all 21 reads body, while unmutated runs land in "Notification frequency" on 10-11 of the 13 mid-handoff reads and 6-7 of the 8 later ones. Counting those reads and requiring at least one per loop would make the title's "Tab stays inside the entering sheet" something the test proves:

let insideMid = 0;
// in the loop, after the checks above
if (step.where === 'Notification frequency') insideMid += 1;
// after the loop
expect(insideMid, 'Tab reached the entering sheet mid-handoff').toBeGreaterThan(0);

The same count after the handoff covers line 211; otherwise narrow the title, header and comment to "never the page".

@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 evidence PR waits on a maintainer decision: the behavior its browser tests commit as expected (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and one page scroll lock held for the whole flow) is governed only by component:BottomSheetSwitcher DEC-1 with FR2, FR5 and FR8, which are still draft, so a maintainer needs to approve or reject them as proposed in #7744. Separately, the probe dialog in the scroll-lock test (lines 185-206) is a raw showModal() element that never takes Core's shared lock, so the test still passes when a nested holder's release unpins the page; please open a real Core Dialog from a step and assert the pin after it closes, or reword the comment, title and header to what the probe shows. The Tab test's new stop counts close the earlier focus-landing gap, and all four tests pass in Chromium and WebKit.

[Automated review]

Comment on lines +185 to +186
// A probe showModal() dialog above the step shares the lock's counter;
// its close gives back only its own hold.

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 probe is a raw <dialog> the test opens itself, and Core's counter only moves when a component calls useScrollLock (packages/core/src/hooks/useScrollLock.ts, lines 33 and 53-109, which observe no DOM), so the probe never takes a hold and its close gives nothing back. With the hook's release guard reduced to originalBodyState == null, so that any holder's release unpins the page, this test still passes 3 of 3 in Chromium and in WebKit, while that mutant would unpin the page when a Dialog opened from a step closes (Dialog.tsx line 586 holds the same lock). Consider opening a Core Dialog from a step and asserting position, top and the scroll counter after it closes, or narrowing this comment, the title (line 154) and the header (lines 9-10) to what the probe shows: an unrelated native modal dialog opening and closing above a step leaves the lock alone.

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from 88f9927 to 86af9ad Compare October 21, 2026 02:48
@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.

Approval of this evidence PR waits on a maintainer decision: the behavior its browser tests commit as expected (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and one page scroll lock held for the whole flow, including beneath a Dialog opened from a step) is governed only by component:BottomSheetSwitcher DEC-1 with FR2, FR5 and FR8, which are still draft, so a maintainer needs to approve or reject them as proposed in #7744. The scroll-lock test now opens a real Core Dialog from the new "Dialog from a step" story and fails in Chromium and WebKit when a nested holder's release unpins the page, which resolves the earlier probe-dialog finding, and all four tests pass in both engines. Optionally, keep the story's Section as the last child of the sheet's content: the closed Dialog rendered after it removes the Section's edge compensation, so that frequency step renders 16 px taller than in "Hug content".

[Automated review]

</HStack>
</VStack>
</Section>
{hasResetDialog ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional polish: this closed Dialog renders after the Section, so the Section is no longer the last child of the sheet's content and loses its :last-child block-end compensation (packages/core/src/Section/Section.tsx line 76). In this story the frequency step therefore renders 16 px taller than in "Hug content": the Section computes margin-block-end: 0px instead of -16px, and the content area is 265 px tall instead of 249 px. Keeping the Section as the last child would give the step the same geometry as the other stories; nothing the lock test asserts depends on it.

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 6a02977 to 7a31451 Compare October 21, 2026 04:07
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from 86af9ad to 0da8423 Compare October 21, 2026 04:07

@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 evidence PR needs a maintainer to approve the spec that governs what its browser tests expect: each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and one page scroll lock held for the whole flow, including beneath a Dialog opened from a step, are decided only by component:BottomSheetSwitcher DEC-1 with FR2, FR5 and FR8, which are still draft in #7744. All four tests pass in Chromium and WebKit against a Storybook built from this branch and fail when the shared lock is released by a nested holder or the retained dialog stops being inert as it fades, and the “Dialog from a step” story now keeps the frequency step’s geometry. Nothing else is needed unless the maintainer amends those requirements, in which case the tests’ expectations should follow.

[Automated review]

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 7a31451 to b77bd3e Compare October 21, 2026 04:50
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from 0da8423 to bf0478f Compare October 21, 2026 04:50

@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 evidence PR waits on a maintainer decision: what its tests encode (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, and one page scroll lock held through a handoff and beneath a Dialog opened from a step) is decided only by component:BottomSheetSwitcher DEC-1 with FR2, FR5, FR8 and PR1, which landed on main with #7744 but are still authority: draft with approved_by: null. A maintainer needs to make those claims current as written, or amend them, in which case the tests' expectations should follow. All four browser tests pass in Chromium and WebKit against a Storybook built from this branch, and they fail as they should when a nested lock holder's release unpins the page or the retained dialog stops being inert as it fades; the inline note on the Tab test's 400 ms late-press check is optional hardening.

[Automated review]

// stopped inside the entering sheet, else an entering sheet that can never
// take focus (every stop on the browser-UI `body` stop) would pass.
expect(midHandoffPresses).toBeGreaterThan(2);
expect(lateHandoffPresses).toBeGreaterThan(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional hardening: this check failed once in 16 Chromium runs against a Storybook built from this branch, in a run where each test took two and a half to five times its usual duration; the other runs, and both WebKit runs, passed. The late window starts 400 ms after started, which is taken after the toHaveCount(2) wait and a page read (about 40–60 ms after the Continue click), and ends when the retained dialog closes (about 670–710 ms after the click), so it holds only four to five presses at the usual ~48 ms per press. Counting a press as late when the retained panel is actually fading (for example its computed opacity below 1, read in handoffStep) would tie the check to the fade it means instead of to wall-clock time.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

46 of 494 shot(s) changed.

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

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 26 more.


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

@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 evidence PR waits on a maintainer decision: what its tests encode (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, one page scroll lock held through a handoff and beneath a Dialog opened from a step, and the switcher's id and data-testid on the host dialog) is decided only by component:BottomSheetSwitcher DEC-1 and DEC-2 with FR2, FR5, FR8, FR9 and PR1, which are still authority: draft with approved_by: null on main. A maintainer needs to make those claims current as written, for example by landing #7822, or amend them, in which case the tests' expectations should follow. All four browser tests pass in Chromium and WebKit against a Storybook built from this branch, they fail as they should when a nested lock holder's release unpins the page or the retained dialog stops being inert as it fades, and the Tab test's late window now holds under load.

[Automated review]

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 271bec8 to 478616c Compare October 22, 2026 05:41
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from e6c150b to a7d9e09 Compare October 22, 2026 05:41

@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 evidence PR waits on a maintainer decision: what its tests encode (each BottomSheetSwitcher step in its own native modal dialog, one scrim handed over without a fade, one page scroll lock held through a handoff and beneath a Dialog opened from a step, and the switcher's id and data-testid on the host dialog) rests on component:BottomSheetSwitcher DEC-1 and DEC-2 with FR2, FR5, FR8, FR9 and PR1, which are still authority: draft with approved_by: null on main. The promotion in #7822 now has an engineering owner's approval; once it lands with those claims current as written, and #7720 beneath this PR lands, nothing else here blocks, and if the claims are amended instead, the tests' expectations should follow. All four browser tests pass in Chromium and WebKit against a Storybook built from this branch, and they fail as they should when a nested lock holder's release unpins the page or the retained dialog stops being inert as it fades.

[Automated review]

@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from a7d9e09 to 4587172 Compare October 22, 2026 06:44
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from 478616c to e43f5a8 Compare October 22, 2026 06:44
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from 4587172 to f701ca6 Compare October 22, 2026 06:47
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native branch from e43f5a8 to 991e1df Compare October 22, 2026 06:47

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

One small change is needed before this can be approved: because the PR edits two spec records (BottomSheet.spec.md and BottomSheetSwitcher.spec.md), its description needs a ## Writing rubric results section with one line per rubric item R1–R6 from docs/contributing/spec-writing.md. Everything the tests expect is current spec on main since #7822 landed, and all four browser tests pass in Chromium and WebKit against a Storybook built from this branch and fail as they should when the lock or the retained dialog's inertness is broken. No code changes are needed.

[Automated review]

| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| ---------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| FR1–FR4, FR8 | `BottomSheetSwitcher.test.tsx` | closed, first open, handoffs in either completion order, rapid replacement, unmount, close | A child becomes interactive at the wrong time, a retained child disappears early, a second backdrop paints, or a sheet dialog's lifecycle breaks. | `audit:BottomSheetSwitcher/behavior` |
| FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; `BottomSheetNativeModal.a11y.browser.spec.ts` | modal/non-modal, info/form/required, nested layer, IME, focus return, scroll lock in engines, non-modal local ordering | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges, or a sheet dialog takes a page-level z-index. | `audit:BottomSheetSwitcher/accessibility` |

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 edit, together with the verified_by entries in both records, makes this a record-changing pull request, so the description needs a ## Writing rubric results section with one line per rubric item R1–R6 (docs/contributing/spec-writing.md, required by architecture:knowledge-contracts). The changed lines themselves carry no pull request reference or narration.

@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 is ready to approve: everything the new browser and jsdom tests expect is stated by component:BottomSheetSwitcher, current on main, and the two record edits only list the new test file. All four browser tests pass in Chromium and WebKit against a Storybook built from this branch, and they fail as they should when a nested lock holder's release unpins the page or the retained dialog stops being inert as it fades. The ## Writing rubric results section requested earlier is now in the description, so nothing more is needed from you; this reaches main together with #7720 beneath it.

[Automated review]

Adds the Chromium and WebKit spec for a flow whose every step owns a native
modal dialog: the flow's one page scroll lock (the shared hook) holding
through a handoff and under a Core Dialog opened from a step, never released
between steps nor when that Dialog closes, with the offset restored after
the exit; focus inside the entering sheet as soon as both dialogs are open
and on Tab stops for as long as the retained dialog is open (inert and
hidden through its fade), inside the flow after; Escape and backdrop
dismissal with focus returned to the opener; and one steady scrim through a
handoff. Adds the 'Dialog from a step' switcher story the lock test drives,
the unit case for the lock under a Core Dialog opened from a step, the
EdgeTint case for one tint per presented dialog through a handoff, the
reduced-motion scrim-pin case, and the server-markup cases. The verification
map no longer marks the engine spec as missing from the tree.
@imdreamrunner
imdreamrunner changed the base branch from feat/switcher-per-sheet-dialog-native to main October 22, 2026 18:20
@imdreamrunner
imdreamrunner force-pushed the feat/switcher-per-sheet-dialog-native-2 branch from f701ca6 to 8a7e375 Compare October 22, 2026 18:20
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 8a7e375

@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 is ready to approve: everything the new browser and jsdom tests expect is stated by component:BottomSheetSwitcher, current on main, and the two record edits only list the new test file. All four browser tests pass in Chromium and WebKit against a Storybook built from this branch, and they fail as they should when a nested scroll-lock holder's release unpins the page or the retained dialog stops being inert as it fades. Nothing more is needed from you.

[Automated review]

@imdreamrunner
imdreamrunner merged commit 0d2cb4b into main Oct 23, 2026
68 checks passed
@github-actions
github-actions Bot deleted the feat/switcher-per-sheet-dialog-native-2 branch October 23, 2026 06:23

This branch was successfully deployed

1 active deployment
Preview — 8a7e3752 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant