Skip to content

Review 5550

Cindy Zhang edited this page Aug 31, 2026 · 6 revisions

Review #5550 — bounded Drawer

freddymeta · collaborator · OPEN · view on GitHub

Original pinned review. Round 1 judges exactly 0019e5c9b3a20dcec8c5e4ec891026bf7e154cb7, four files and +641/−50 relative to its GitHub merge-base. No PR review or comment was posted.

AUTHORITY CORRECTION — 2026-08-31: The earlier auto-ratified Drawer API direction is withdrawn as decision authority. Only an applicable committed authority: current specification inside facebook/astryx can settle review judgement. Current in-repo architecture/family specs do not decide whether Drawer should add independent scope, modality, and hasScrim axes, and current main has no Drawer.spec.md. That choice is novel-human: AUTHOR CAN PROCEED: no; no contributor-facing REVIEW may be written; nothing may be posted or merged until a human answers and a current in-repo spec records the boundary. The runtime evidence and objective defects in this record remain useful.

HISTORICAL DISPOSITION — Round 4: request changes at 934a85c; the objective bounded-stacking defect was confirmed, but the posted review also relied on the non-authoritative Drawer ruling and therefore must not be reused as policy.

PR

#5550 feat(Drawer): containerRef — bind the drawer to an element, not the viewport by freddymeta (bucket: contributor)

HEAD REVIEWED

0019e5c9b3a20dcec8c5e4ec891026bf7e154cb7 — every claim below was verified at this commit.

Feature parent: da75e7b9d2c6d564268ccd20dc120666ad2c4436, the exact head of #5549. Feature-only delta: four files, +426/−22.

Current main checked at cddf57e5d474281150bef92f1167228ab0a6e4a5; no Drawer or shared-layer change landed after this branch point, so main did not invalidate the review.

VERSIONS

LOOP VERSION: 1.5.0 AUDIT RUBRIC: 1.12

LANE

LANE: full WHY: new exported prop and capability; portal/layer/focus/layout ownership; one added and one changed Effect; native dialog state; intended visual change; container lifetime, scroll, sizing, stacking, SSR, dismissal, and RTL all require browser evidence.

STEP 0

Clear. The exact four-file diff changes React/StyleX source, one test, one story, and typed docs. It changes no dependency, lockfile, workflow, lifecycle script, executable, credential/environment read, or network/shell path. Source inspection found no dynamic execution or request path. It was safe to install, compile, test, and render locally.

PROBLEM

WHY 1: Drawer can only cover the viewport; it cannot express an inspector that belongs to one split-view pane, dashboard card, canvas, or chart panel. WHY 2: Builders either let a local inspector take over the whole page or hand-roll a second panel/layer implementation, losing Drawer’s shared dismissal, focus, and motion behavior. WHY 3: A design system should let the same inspector concept preserve scope and behavior across page and pane layouts rather than forcing product code to fork the interaction.

USER-FACING PROBLEM: A person inspecting one item in a multi-pane workspace loses the rest of the workspace to a viewport drawer, or gets a product-specific substitute with inconsistent layer behavior. PROBLEM SEVERITY: missing capability — the PR names four use-case classes and an existing product-system clone; the public Overlay specification also establishes container-confined overlays as a real class, while no landed Drawer API supplies it.

VERDICT: clear

SOLUTION

The Drawer reads an external element ref after commit, portals a dialog and custom scrim into that element, and switches the panel from fixed viewport geometry to absolute container geometry. Bounded mode also changes a scrimmed Drawer from native modal to non-modal, clips the moving panel with an absolute wrapper, and warns when the target is not positioned. Existing viewport mode remains the fallback when the prop is absent.

SOLUTION (4 decisions · ~116 added non-comment runtime lines of 426 added lines)

  1. Add a public ref-selected portal host and container-relative panel sizing — serves pane scope.
  2. Redefine hasScrim under that mode as a pointer-blocking custom scrim without modality, focus trap, or body lock — serves local rather than page-wide blocking.
  3. Add an absolute overflow: clip wrapper with pointer routing — serves entry animation without making the wrapper a scroll container.
  4. Mirror the target ref into state and validate its computed positioning — serves delayed ref availability and builder diagnostics.

BURDEN: high — a public cross-prop mode; one new state/Effect and one changed native-dialog Effect; portal host lifetime; custom scrim; wrapper and z-index participation; render-time style read; duplicated modal/non-modal behavior; 111 test and 128 story/doc additions. BURDEN MATCH: [Needs human judgement] — pane-level inspectors are a real missing capability, but the proposed public mode permanently couples container ownership, modality, and hasScrim semantics. No landed public sibling exposes a portal host this way; anchorRef names an anchor, while the container Overlay owner returns container props and colocated content.

VERDICT: BLOCKS — the implementation does not preserve the promised pane scope through scrolling or target replacement.

ARCHITECTURE

OWNER: Drawer owns the panel; the layer protocol owns dismissal/depth, and the consumer’s pane owns its scrollport and node lifetime. TIER 1: native <dialog>, useScrollLock, useDevWarning, overlayPaddingReset; shared layer dismissal is not reused (pre-existing Drawer debt, not charged here). TIER 2: none. SEAMS: external portal target; scrollable/replaceable pane; scrim/no-scrim; viewport/container sizing; sibling stacks; RTL; SSR hydration; xstyle/className/style on the panel. BEHAVIOR UNIT: inline — the target state Effect and dialog Effect coordinate a mutable ref, portal remounts, native show/close, focus, a listener, and a timer. The current kit requires a named unit plus focused browser tests for that multi-Effect state machine; the PR has jsdom tests with mocked dialog methods and no checked-in browser test.

seam driven result
positioned 500×300 scroll pane opens contained at 400×292; intended control passes
pane scroll 0→140 fails: panel y 44→−96; visible height 292→156
real wheel over the revealed pane fails: scrollTop 0→180; panel y 44→−136
same RefObject, target element replaced fails: document dialog count 1→0 while isOpen remains true
default bounded scrim, reverse Tab twice fails: focus reaches obscured opener; Enter activates it while pointer path is covered
two sibling bounded Drawers passes: z-index 1000/1001; Escape closes only inner
nested Drawer intentionally unsupported by current docs; not charged
RTL side="end" passes: pane left 360, panel left 364 (inline-end is left in RTL)
initial-open SSR/hydration server emits no dialog; hydration is mismatch-free and opens inside the pane after the Effect
300px pane, 900px viewport panel fills all 292px inner width; the 56px reveal appears only after viewport shrinks below 640px

The scroll and replacement failures are independently confirmed by source ownership: the absolute wrapper is a child of the scroll node (Drawer.tsx:290-301,862-885), and the target Effect depends on ref identity plus isRendered, not containerRef.current or node attachment (Drawer.tsx:597-600).

VERDICT: BLOCKS — pane scroll and node lifetime are owned by an unobservable mutable ref, and the important portal/dialog state machine has no focused browser-test boundary.

IMPACT

The intended path works initially: a builder can render the Drawer elsewhere in React, bind it to a positioned pane, keep the rest of the page live, and retain LTR/RTL placement plus sibling LIFO dismissal.

Three reachable paths break. A user wheeling the revealed part of a scrollable inspector pane sees the 292px panel and scrim move upward until only 156px remains after 140px of scroll. A dashboard that replaces the pane element under the same stable ref loses the open Drawer entirely. With the default bounded scrim, a keyboard user can focus and activate the dimmed opener behind that scrim even though a pointer user is blocked.

VERDICT: BLOCKS — scroll, replacement, and keyboard paths each contradict the visible/controlled bounded-pane state.

API

Real call site from the story:

const paneRef = useRef<HTMLDivElement>(null);
<div ref={paneRef} style={{position: 'relative'}}></div>
<Drawer
  isOpen={selected != null}
  onOpenChange={isOpen => !isOpen && setSelected(null)}
  label="Host details"
  containerRef={paneRef}>
  
</Drawer>
change public? class doc’d? verdict
+ `containerRef?: React.RefObject<HTMLElement null>` yes, exported canary DrawerProps no landed portal-host prop; anchorRef is trigger/position precedent only en + zh + dense + story
~ hasScrim?: boolean becomes non-modal when containerRef exists existing public canary prop, changed combined semantics no landed sibling where a scope prop removes scrim modality en + zh + dense P8 finding
~ isFullWidthOnMobile resolves 100% against the pane, but still switches on viewport width existing public canary prop no contained sibling prose mentions the bounded size, not narrow-desktop behavior note

OSSIFICATION: the need is a class (split view, dashboard card, canvas, chart panel), but the spelling is a new concept. Tooltip/Popover/Typeahead use anchorRef for an external anchor, not a portal host. The existing container Overlay owner uses a hook that returns containerRef, container props, and colocated overlay content; its public design explicitly considered and rejected a ref-mutating container option. The cost of being wrong is permanent ref-lifetime behavior plus a sibling prop whose modality changes conditionally. API Conventions says “Prop independence. One prop never suppresses another prop’s output” and each prop is one orthogonal axis; this mode makes containerRef remove hasScrim’s native modality while leaving the dimming surface.

VERDICT: BLOCKS — the combined props produce modality-dependent behavior that differs by input modality. The separate question of whether Drawer should own this new portal-host/scope concept remains [Needs human judgement] in JUDGEMENT.

THEMING

No target, component variable, or variant is added or removed. The panel keeps themeProps('drawer', {side}) on its painting <dialog>; all new paint uses existing surface, border, shadow, duration, easing, and overlay tokens. The custom bounded scrim is a sibling outside the astryx-drawer target and cannot receive the panel’s xstyle/class styling of ::backdrop; that reach question is part of the unresolved public API/design shape, not an independently actionable theme defect on this head.

VERDICT: clear — token integrity and existing targets are unchanged; new scrim reach is routed to the API decision.

BREAKING

BEHAVIOR: existing callers without containerRef remain on the parent path in focused tests and the control arm. The new bounded path has proven scroll, target-lifetime, and keyboard behavior failures. API: additive in canary-only @astryxdesign/lab; no stable consumer migration or codemod is owed, but it is new exported surface. VISUAL: yes, intentionally — panel/scrim move from viewport scope to pane scope. The control pair shows that intended change; reverse-Tab frames expose one unintentional focus state behind the scrim. THEME: no existing target/token removed; bounded scrim has less component-specific reach than ::backdrop.

@astryxdesign/lab remains private: true/canaryOnly; exact-head check:changesets passed without a changeset, consistent with the package lifecycle.

VERDICT: note — existing no-prop behavior is stable and the canary API is additive; the broken opt-in behavior is owned by SOLUTION/IMPACT rather than counted again as an existing-consumer break.

PERFORMANCE & RESOURCES

EFFECTS: one added target-mirroring Effect [containerRef, isRendered]; one changed dialog Effect [isOpen, isModal, portalTarget]; existing callback-ref mirror, unmount close, and registry Effects kept. The first copies a DOM element into state; the second synchronizes native dialog state, focus, transition listener, and timer.

Effect + deps external system why render/handler cannot do it measured render cost lifetime + cleanup focused test
target mirror [containerRef,isRendered] Drawer.tsx:598-600 consumer DOM ref / portal host ref is null during initial render parent→head Profiler callbacks: 1→2 at N=1, 3→6 at N=3, 10→20 at N=10 while every Drawer is closed; target getComputedStyle calls add the same N delta state retains stale detached target; no subscription to replacement none in browser; jsdom only
dialog sync [isOpen,isModal,portalTarget] Drawer.tsx:620-700 native dialog + transition event/timer controlled state must reach imperative dialog no further commit beyond the target update measured; one native open path per instance listener/timer cleanup present; portal replacement remount loses the live node jsdom mocks only

RENDER: one extra commit per bounded Drawer on mount, measured at N=1/3/10 even while closed. The repo has no product callsite; the documented sibling stack makes N=3 the realistic upper check, while N=10 is stress only. LISTENERS/OBSERVERS: transition listener and backstop timer are scoped to exit and cleaned; no observer/dependency added. LAYOUT: getComputedStyle(portalTarget).position runs during bounded render; matched arms added N calls at N=1/3/10. Scroll geometry itself is CSS, but the absolute host follows scroll content rather than the scrollport. BUNDLE: no dependency; feature-only runtime source adds 187/−16 lines gross, ~116 non-comment additions.

VERDICT: BLOCKS — the new ref-to-state Effect adds a measured render/style-read pass and still fails node replacement; the multi-Effect native/portal protocol lacks focused browser coverage.

VISUAL EVIDENCE

VISUAL CHECK: manual frames required WHY: the feature intentionally changes panel/scrim geometry and introduces a custom rendered scrim and clipping wrapper. Exact-head pr-visual is green, but source-gated snapshots cannot establish the new scrollport and focus behavior.

The same local story ran at the exact #5549 parent and exact #5550 head. The only probe-story arm differences are five containerRef={paneRef} lines across the scoped, performance, replacement, and stacking stories; the inspected exact arm diff and reviewed feature diff are banked. Expectations were authored before observation.

Sensor Parent / before Exact head / after
Build da75e7b9d2c6d564268ccd20dc120666ad2c4436 0019e5c9b3a20dcec8c5e4ec891026bf7e154cb7
Receipt control JSON · focus JSON control JSON · focus JSON
Story review-5550--scrollable-pane same
Theme / mode / direction neutral / light / LTR same
Viewport / media 900×520 @1; forced colors off; reduced motion off; fine pointer; hover same
Semantic state named “Pane details” dialog open; pane text present; opener at activation 1 same
Geometry one visible 400×520 dialog one visible 400×292 dialog
Settled / errors fonts loaded; 0 animations; no Storybook/page error same

Control — intended scope change

#5549 parent #5550 exact head
Before: viewport Drawer After: Drawer and scrim contained in the pane

PIXEL JUDGEMENT: intentional. This is the PR’s stated result: “the panel is inside the pane, the scrim dims only the pane.” The panel changes from x=500/y=0/400×520 to x=136/y=44/400×292; the pane remains 500×300.

Finding — reverse Tab exposes a dimmed pane control

#5549 parent #5550 exact head
Before: modal path does not paint focus on the pane opener After: focus ring appears on the pane opener under the scrim

PIXEL JUDGEMENT: unintentional. The after control→reverse-Tab delta is 2,060/468,000 pixels (0.4402%) in one 78×52 box at (58,90), exactly the focus-ring expansion around the revealed part of the active 444×32 opener at (68,100). The parent control/focus images are byte-identical. DOM evidence confirms the after focus is inside the pane, outside the dialog, beneath a 492×292 scrim; Enter activates it. This contradicts the PR/docs statement that the scrim “dims and blocks the container.” Pixel classification · DOM boxes · probe results.

The frames were opened through the image reader; this harness exposed metadata rather than inline pixels, so the visual call is fail-closed on matching sensor receipts, exact hashes, exhaustive pixel classification, and DOM-box attribution rather than an unauditable prose impression.

VERDICT: BLOCKS — the intended containment renders, but the default scrim paints a blocked state while keyboard focus and activation reach the obscured control.

A11Y & I18N

Exact-head pr-a11y and pr-rtl ran and passed; the baseline file is unchanged. The dialog is named, built-in close is named, reduced-motion is guarded, and no new runtime visible/AT string, locale formatter, physical layout property, or directional glyph was added. Real Chromium confirms close button, Escape, and scrim click each close and restore focus to “Open pane drawer (1)”; the outside-page button remains operable. RTL side="end" pins to the pane’s left edge correctly. Sibling bounded Drawers preserve inner-first Escape.

The default bounded scrim fails input-modality parity. Two reverse Tabs from the built-in close focus the obscured opener behind the scrim, and Enter runs it; pointer hit testing is covered by the scrim/panel. A keyboard user can therefore operate pane controls that the visual and pointer state present as unavailable.

SSR hydration emitted no mismatch and opened inside the pane, but an initially-open bounded Drawer is absent from server markup until the target Effect runs; first-paint timing was not visually captured and is not raised as a separate finding.

VERDICT: BLOCKS — keyboard users can activate controls behind the default container-blocking scrim.

JUDGEMENT

slot verdict
PROBLEM clear
SOLUTION BLOCKS — scroll and replacement fail
ARCHITECTURE BLOCKS — unstable scroll/node ownership and inline multi-Effect protocol
IMPACT BLOCKS — panel can clip/disappear; keyboard reaches blocked content
API BLOCKS — P8 cross-prop semantics; separate scope concept needs human judgement
THEMING clear — token integrity/targets unchanged; scrim reach routed to API
BREAKING note — additive canary path; existing callers stable
PERFORMANCE BLOCKS — measured extra commit/style read plus stale target
VISUAL BLOCKS — focus painted under scrim
A11Y & I18N BLOCKS — keyboard activates obscured pane control

GOAL: partly met — initial LTR/RTL containment, dismissal, focus restoration, and bounded sibling stacking work, but the canonical scrollable pane loses 136/292px after 140px of scroll, replacement removes the dialog, and the default scrim blocks pointer but not keyboard interaction. DISPOSITION: scrollport ownership → blocks now; target replacement + extra mount pass/style read → one ref-lifetime root defect, blocks now; scrim keyboard/pointer mismatch → blocks now; new Drawer scope/modality concept → held for API judgement; target-specific theming reach and narrow-desktop reveal → included in that API/design decision, not separate asks. ADVICE: bounded direction — the pane-level host must stay pinned to the pane’s visible scrollport, follow target replacement while open/closed, expose one modality contract across pointer/keyboard, and avoid mirroring a DOM node through an extra render. Preserve the verified exact-head outcomes: external page remains live, close/Escape/scrim restore focus, RTL logical edge works, and sibling Escape stays inner-first. The owner/API mechanism is not prescribed pending the API decision. AUTHOR CAN PROCEED: no — the three defects have complete acceptance criteria, but a contributor should not ossify this new Drawer scope/modality concept until the API owner decides whether Drawer owns it and how hasScrim is represented. WORST OUTCOME: “A keyboard user can activate pane controls beneath a scrim that blocks pointer users, while scrolling or replacing that same pane can clip or remove the open inspector.” → request changes

JUDGEMENT NEEDED: API — should Drawer own a public ref-selected bounded mode whose scrim is non-modal, or should pane-scoped panels compose through a different owner/API? Recommendation: do not accept containerRef plus conditional hasScrim semantics as-is; keep the need, settle an orthogonal scope/modality contract first.

request changes

  1. [BLOCKS] The absolute portal host scrolls with the target’s content → a person wheeling the revealed part of a scrollable split pane moves the panel from y=44 to −136 and leaves only 116/292px visible · packages/lab/src/Drawer/Drawer.tsx:290-301,862-885

  2. [BLOCKS] The ref-to-state target does not follow node replacement and adds a render pass → a responsive dashboard that swaps the pane under a stable ref removes its open inspector while isOpen stays true; every mounted bounded Drawer also adds one commit and one render-time style read · packages/lab/src/Drawer/Drawer.tsx:597-612

  3. [BLOCKS] The default bounded scrim blocks pointer but not keyboard interaction → a keyboard user reverse-tabs to and activates the dimmed opener under the scrim, while a pointer user cannot reach it · packages/lab/src/Drawer/Drawer.tsx:864-880

Independent confirmations: each runtime defect is reproduced in real Chromium and follows directly from the causal source path. The performance delta is a matched Profiler/getComputedStyle count at N=1/3/10. Visual receipts match on every sensor except Build.

HISTORICAL POSTED REVIEW — OBJECTIVE DEFECT EVIDENCE RETAINED; API FRAMING REQUIRES CORRECTION

Thanks—this makes the pane-scoped case concrete, but four bounded-mode paths still break.

In a scrollable pane, wheel input over the reveal scrolls the absolute portal host: scrollTop 0→180 moved the 292px drawer from y=44 to −136 and left 116px visible (Drawer.tsx:290-301). The user can lose most of the panel and scrim.

With the default scrim, two reverse Tabs focused the obscured opener and Enter activated it while pointer input stayed blocked (Drawer.tsx:864-880). Keyboard users can operate controls the scrim says are unavailable.

Replacing the target element behind the same ref left isOpen true but removed the dialog (Drawer.tsx:598-600). A responsive pane swap loses its inspector and dismissal path.

Even closed, target resolution doubled Profiler commits at N=1/3/10 and added one render-time style read per Drawer (Drawer.tsx:598-612). Please preserve the host across scroll/replacement, make scrim blocking consistent across modalities, and avoid the extra mount pass/style read.

[Reviewed by Robohands]

Public review length: 145 words before attribution (request-changes cap: 150). Held draft only; no GitHub action was taken.

INLINE (0)

None. The three findings are cross-cutting ownership/behavior contracts; the causal ranges are in the summary.

EVIDENCE I DID NOT SPEND

  • At a 900px viewport, a 300px pane gets a 292px panel and no reveal; at a 600px viewport the same pane gets 236px and the documented 56px reveal. The bounded mode adapts to viewport width rather than container width.
  • The bounded custom scrim uses tokens but is outside the existing astryx-drawer target and the panel’s xstyle/::backdrop styling path.
  • The exact-head story still gives its pane overflow: hidden even though the new docs say no overflow rule is needed; the dedicated scroll probe removed that masking assumption.

TIME

TIME total 34m setup 7m exact-head + #5549-parent worktrees, cloned installs, core/config builds; warm main reused: no (stack parent was the required before arm) reading 9m kit/critic, two fresh wikis, PR bodies/history, diff, siblings, shared layer/overlay owners measuring 12m 44 focused tests, 1 SSR test, typechecks, 2 matched Chromium arms, 4 sensored frames, 5 interaction/layout probes, N=1/3/10 counts writing 6m R16 draft, critic pass, rewrite, wiki record waste 3m first typecheck before core build; first parent probe used a pointer click blocked by its modal control; unavailable image compositor/PIL

WHAT I COULD NOT VERIFY

  • Safari/WebKit behavior; this Mac’s supported Playwright lane is Chromium only.
  • First-paint timing for an initially-open SSR Drawer; hydration correctness passed, but the server markup intentionally contains no dialog.

EVIDENCE AND TESTS

  • Focused Vitest: 44/44 passed at exact head (log).
  • Lab typecheck and typed-doc typecheck: passed after building core.
  • One-off SSR/hydration test: 1/1 passed; no hydration mismatch, dialog portals and opens after hydration (log).
  • Exact-head CI: test, build, lint, Storybook, a11y, RTL, visual, theme layers, smoke, and Vercel passed; review-required remains pending because no entitled review was posted.
  • Real Chromium: close, Escape, and scrim click close + restore focus; outside-page click remains live; sibling bounded z-index is 1000/1001 and Escape closes inner only; RTL end edge passes (behavior · full matched results / head).
  • Scroll: programmatic 0→140 moves panel y 44→−96 and visible height 292→156; real wheel over the revealed pane moves scrollTop 0→180 and panel y 44→−136 (wheel result).
  • Target lifetime: replacing the element behind the same ref changes document dialog count 1→0 while controlled isOpen remains true.
  • Performance: matched Profiler callbacks 1→2, 3→6, 10→20; target style-read counts add 1, 3, 10.
  • Visuals: four frames, four passing sensor receipts, matching non-build sensors, image hashes/dimensions recorded, and focus pixels mapped to the active DOM box.
  • Diff hygiene: feature-only and probe-arm diffs banked; no dependencies/scripts/workflows; current main has no intervening Drawer change.

CRITIC

DRAFT: request changes on scrollport ownership, target lifetime/performance, and scrim modality; hold public action for API judgement and the #5549 set.

CRITIQUE 1: failed R16 because API combined BLOCKS and [Needs human judgement] on one verdict line instead of choosing one slot floor. Failed R14 because the public draft called the 140px programmatic scroll a wheel result; the real wheel run was 180px. Failed the standing impact rule because THEMING’s note named only a future reach risk, not a current affected theme author. PERFORMANCE needed to identify N=3 as the repo-realistic documented stack and N=10 as stress. BREAKING double-counted defects in an additive canary path rather than answering whether existing consumers break. The three runtime blocks themselves passed R14/R16g: exact-head source and real Chromium independently agree; their causal ranges open at the cited lines.

REWRITE: API now has one BLOCKS line and routes the separate concept decision through JUDGEMENT. The public finding uses the actual wheel 0→180/y 44→−136/116px-visible result. The unsupported theme note was cut, existing-caller BREAKING became a note, and the performance denominator now names N=3 as realistic. Public wording remains 145 words, has one impact per finding, names no private process, and retains the exact signature.

CRITIQUE 1 RESULT AFTER REWRITE: passes R1/R1g/R28 need and API framing; R2 at 145/150 words; R3/R4/R5 problem-first impact; R6d set hold; R12 voice and gratitude; R13 inherited layer debt exclusion; R14 exact head/anchors/current-main check; R15/R38/R39 sensored manual frames; R16 complete slot/verdict shape; R16g two-method confirmation; R18 measured counts at multiple N; R31b/R31c stack and main reconciliation; R32 class matrix; R35 behavior-unit gate; R36 exact versions; R37 AUTHOR CAN PROCEED; R41 three whys; R42 burden/severity match. Final R17 gate is the durable wiki push below.

Relationship to #5549

This is a strict stack. Commits 6ab800c and da75e7b are exactly #5549; #5550 adds dda9323 and 0019e5c. The bounded path depends on #5549’s isRendered exit lifetime, transition-end close, focus restoration, and latched side. This review isolated #5550’s +426/−22 feature delta and used #5549’s exact head as the before arm. All three blocking runtime findings are introduced only by #5550. The set verdict remains held until the independent #5549 review is reconciled; #5550 cannot land first or be judged as if its base mechanics were already accepted.

What changed before posting

Nothing was posted to the PR. The proposed public request-changes text is archived exactly as drafted. No GitHub review, comment, approval, request-changes action, merge, label change, or contributor-branch push was made.

Round 2 — historical auto-ratification, withdrawn as authority

AUTHORITY CORRECTION — 2026-08-31: Decision-window expiry cannot settle review judgement. Only an applicable authority: current specification inside facebook/astryx can. No current in-repo Drawer spec decides independent scope/modality/scrim axes, so the API choice in this round is novel-human, AUTHOR CAN PROCEED: no, and no contributor-facing REVIEW may be posted. The measurements and runtime defects remain useful evidence.

The decision window elapsed without an owner answer, and the loop historically applied its recorded recommendation. That action is preserved here as history, not authority. The reviewed head remains exactly 0019e5c9b3a20dcec8c5e4ec891026bf7e154cb7; no code or evidence changed.

API RULING

Drawer owns pane-scoped side panels. Keep containerRef as the scope/host axis rather than creating another inspector component or routing this behavior through the media/card Overlay primitive.

Modality is a separate explicit axis. Add modality?: 'modal' | 'nonModal' with default 'modal'. containerRef chooses only viewport versus element scope; it never changes focus, inertness, pointer blocking, or aria-modal. hasScrim controls whether scrim paint is present; dismissal and underlying operability follow the chosen modality, never the scrim’s presence.

explicit mode required observable contract
modal content behind the Drawer in the chosen scope is unavailable to both pointer and sequential keyboard interaction; focus restores on close; aria-modal is emitted only when its global semantic promise is true
nonModal content behind the Drawer remains operable by both pointer and keyboard; a scrim cannot present or enforce a contradictory blocked state

Concrete impact. A builder can move the same inspector between viewport and pane without silently changing what hasScrim means. A keyboard user never activates controls a pointer user is blocked from, and a non-modal caller never acquires an implicit focus policy merely by choosing a host.

This is canary-only lab surface, so the PR may migrate existing Drawer stories/docs/call sites from hasScrim-implied modality to the explicit axis without a stable-consumer codemod.

CURRENT JUDGEMENT

slot verdict
PROBLEM clear
SOLUTION BLOCKS — scroll and replacement fail
ARCHITECTURE BLOCKS — unstable scroll/node ownership and inline multi-Effect protocol
IMPACT BLOCKS — panel can clip/disappear; keyboard reaches blocked content
API BLOCKS — current head conditionally changes modality; ruling supplies the accepted shape
THEMING clear
BREAKING note — additive canary path; existing no-prop behavior stable
PERFORMANCE BLOCKS — measured extra commit/style read plus stale target
VISUAL BLOCKS — focus painted under scrim
A11Y & I18N BLOCKS — keyboard activates obscured pane control

GOAL: partly met — the initial bounded render, dismissal, restoration, RTL, and sibling stack work; scroll, node replacement, input-modality parity, and mount cost remain broken. HISTORICAL DISPOSITION (SUPERSEDED): the objective runtime defects block; the public owner/API choice was incorrectly treated as resolved by a non-authoritative auto-ratification. ADVICE: preserve one host against pane scroll and target replacement and remove the ref→state extra mount pass/render-time style read. Do not prescribe the unresolved Drawer API shape. AUTHOR CAN PROCEED: no — objective acceptance criteria exist, but no applicable current in-repo spec settles scope/modality/scrim ownership. WORST OUTCOME: “A keyboard user can activate pane controls beneath a scrim that blocks pointer users, while scrolling or replacing that same pane can clip or remove the open inspector.” → request changes

JUDGEMENT NEEDED: API — novel-human; the historical Ruling 15 is non-authoritative.

request changes

  1. [BLOCKS] The absolute portal host scrolls with the target’s content → a person wheeling the revealed part of a scrollable split pane moves the panel from y=44 to −136 and leaves only 116/292px visible · packages/lab/src/Drawer/Drawer.tsx:290-301,862-885

  2. [BLOCKS] The ref-to-state target does not follow node replacement and adds a render pass → a responsive dashboard that swaps the pane under a stable ref removes its open inspector while isOpen stays true; every mounted bounded Drawer also adds one commit and one render-time style read · packages/lab/src/Drawer/Drawer.tsx:597-612

  3. [BLOCKS] Scope conditionally changes modality and the default scrim blocks pointer but not keyboard interaction → a keyboard user reverse-tabs to and activates the dimmed opener under the scrim, while a pointer user cannot reach it · packages/lab/src/Drawer/Drawer.tsx:596-603,864-880

REVISED EXACT PUBLIC REVIEW

Thanks—pane-scoped Drawer is the right owner. Please keep scope and modality orthogonal: containerRef chooses the host only; expose explicit modality ('modal' | 'nonModal') rather than silently changing hasScrim and focus behavior.

Three runtime paths still block:

  • In a scrollable pane, wheel input over the reveal moved the absolute portal host: scrollTop 0→180 moved the 292px drawer from y=44 to −136, leaving 116px visible (Drawer.tsx:290-301).
  • Two reverse Tabs focused the dimmed opener, and Enter activated it while pointer input stayed blocked (Drawer.tsx:864-880).
  • Replacing the target behind the same ref removed the dialog while isOpen stayed true; even closed, target resolution doubled Profiler commits at N=1/3/10 and added one style read per Drawer (Drawer.tsx:598-612).

Please preserve the host through scroll/replacement, make blocking consistent across input modes, and avoid the extra mount pass/read.

[Reviewed by Robohands]

Public review length: 133 words before attribution (request-changes cap: 150).

ROUND 2 CRITIC

The historical critic treated the auto-ratified ruling as resolving the withheld owner/API choice. That conclusion is superseded. The measured runtime defects remain evidence, but no contributor-facing review can include the proposed API direction until a current in-repo spec settles it.

ROUND 2 TIME

TIME total 7m setup 1m fresh fork-wiki clone and exact-head/no-review verification reading 2m current Review 5550, Reviews index, Rulings policy measuring 0m no code/head change; Round 1 evidence remains exact writing 4m ruling, judgement rewrite, critic pass, pull/rebase/push waste 0m

ROUND 2 WHAT CHANGED BEFORE POSTING

The API hold was resolved to Drawer + explicit modality and AUTHOR CAN PROCEED changed from no to yes. The public request-changes draft was rewritten to include that direction. Nothing was posted to the PR; no review, comment, approval, request-changes action, merge, label change, or contributor-branch push was made.

Round 3 — R1e re-review at current head, 2026-08-27

PR

#5550 feat(Drawer): containerRef — bind the drawer to an element, not the viewport by freddymeta (bucket: collaborator; write access)

HEAD REVIEWED

4fecc8f5c0525c8f5d791fe66c633036c4b2bb38 — every claim below is scoped to this commit. Current main is e5e83effd87469c11a9818c9cbfb6e49d32bcca2.

VERSIONS

LOOP VERSION: 1.5.0 AUDIT RUBRIC: 1.13

LANE

LANE: full WHY: this is an R1e re-review of a new public capability with changed API, six added/changed Effects, observers/listeners, native dialog state, intended visual change, a standing changes-requested review, and a newly landed parent touching the same files.

PRIOR REVIEW (R1e)

Our review at 0019e5c9 said: “Please keep scope and modality orthogonal: containerRef chooses the host only; expose explicit modality ('modal' | 'nonModal') rather than silently changing hasScrim and focus behavior.” It also blocked scrollport drift, pointer/keyboard mismatch, target replacement, and the closed mount/style-read cost.

At this head, the scrollport ask passes in real Chromium and the closed cost is fixed at N=1/3/10. The keyboard ask is only partly satisfied: existing direct children become inert, but a control inserted into the pane after open remains keyboard-active. The target-replacement test passes in jsdom but was not browser-driven. The API ask is contradicted, not extended: hasScrim and modality are both replaced by one isModal prop.

Since that review, #5549 landed useDrawerDialogPresence on main. A three-way merge of current main and this head has content conflicts in Drawer.tsx and Drawer.test.tsx; the reviewed branch still carries the pre-extraction inline dialog lifecycle.

PROBLEM

WHY 1: Drawer cannot currently express an inspector scoped to one split-view pane, dashboard card, canvas, or chart panel. WHY 2: Builders must either let a local inspector take over the viewport or hand-roll a panel, losing shared dismissal, focus, and motion behavior. WHY 3: Multi-pane work should keep the rest of the workspace usable while the inspected pane gets one consistent side-panel interaction. USER-FACING PROBLEM: A person inspecting one item in a multi-pane workspace either loses the rest of the workspace to a viewport Drawer or receives a product-specific substitute with inconsistent interaction. PROBLEM SEVERITY: missing capability — four use-case classes and a downstream clone establish demand; historical Ruling 15 (non-authoritative) assigns the capability to Drawer.

VERDICT: clear

SOLUTION

The Drawer accepts a pane ref, portals the panel into that element, and synchronizes a clipping box to the pane’s visible scrollport. One boolean now controls both dimming and blocking; bounded mode applies inert to the pane’s current direct children, while document and resize observers follow host replacement and geometry. Existing viewport mode uses the native top layer.

SOLUTION (5 decisions · ~215 changed runtime lines in this response)

  1. Keep containerRef as the portal/scope axis — serves pane-scoped inspectors.
  2. Replace both paint and enforcement axes with isModal — contradicts historical Ruling 15 (non-authoritative).
  3. Pin and size the clip box from scroll/client geometry — fixes scrollport drift.
  4. Follow target replacement through a document-wide mutation observer — fixes stable-ref node replacement.
  5. Snapshot current host children and make them inert while open — fixes existing children but misses later direct children.

BURDEN: high — two public API migrations, six added/changed Effects, DOM-node state, document/resize observers, a scroll listener with per-event writes, imperative inert ownership, a custom scrim, and native dialog synchronization. BURDEN MATCH: disproportionate on this head — the capability is real, but current main already extracted dialog presence into a named unit and this branch conflicts with it; the pane behavior remains inline and its modal guarantee is incomplete.

VERDICT: BLOCKS — the solution contradicts the ratified API and leaves late pane content interactive.

ARCHITECTURE

OWNER: Drawer owns pane scope and the panel; the explicit modality axis owns operability; the consumer pane owns its node/scroll lifetime. TIER 1: native <dialog>, useScrollLock, useDevWarning, and the now-landed useDrawerDialogPresence on main. TIER 2: none. SEAMS: external portal target, scrollable/replaced pane, content added after open, modal/non-modal, scrim/no-scrim, sibling stacks, RTL, SSR, and exit presence. BEHAVIOR UNIT: inline on this head — target resolution, document observation, scroll/resize synchronization, inert ownership, and native dialog presence coordinate through nine Effects/layout Effects in the component. Main now owns the dialog-presence half in useDrawerDialogPresence; the two versions conflict rather than compose.

seam driven result
current main merge fails — content conflicts in Drawer.tsx and Drawer.test.tsx
scroll pane, scrollTop 0→180 passes — panel moves 0px and remains 298px high
existing pane control under modal scrim passes — programmatic focus refused; pointer hit blocked
direct pane control inserted after open fails — no inert, programmatic focus accepted while pointer hit remains blocked
target replaced behind the same ref jsdom regression passes; not browser-driven

The late-child failure is independently confirmed by the effect’s one-time host.children snapshot (Drawer.tsx:719-746) and a real Chromium mutation. The current branch cannot be reviewed as the merge result until it is rebased over the landed presence hook.

VERDICT: BLOCKS — merge conflicts leave the integrated lifecycle undefined, and the inline modal manager does not preserve its contract as pane children change.

IMPACT

The scroll fix works at this exact head: after 180px of pane scroll, the panel remains at the same y-position and height. Existing pane children are unavailable to both pointer and keyboard while modal.

A control portalled into the pane after the Drawer opens is different: it is not marked inert, accepts programmatic focus, and stays keyboard-active while the scrim still blocks its pointer path. Builders also cannot express the ratified independent paint and enforcement choices because isModal combines them.

VERDICT: BLOCKS — a changing pane can again expose controls to keyboard users that pointer users cannot reach.

API

Current story call site:

<div ref={paneRef} style={{position: 'relative'}}></div>
<Drawer
  containerRef={paneRef}
  isModal={false}
  isOpen={selected != null}
  label="Host details"
  onOpenChange={setIsOpen}>
  
</Drawer>
change public? class doc’d? verdict
+ `containerRef?: React.RefObject<HTMLElement null>` exported canary DrawerProps split-view, dashboard, canvas, chart inspectors en + zh + dense + stories
- hasScrim?: boolean exported canary surface paint axis migrated in repo finding — ruling retains it
+ isModal?: boolean = true exported canary surface combines paint + enforcement en + zh + dense + stories finding — ruling requires `modality?: 'modal'

OSSIFICATION: historical Ruling 15 (non-authoritative) already settles the class, owner, and spelling: containerRef selects scope, modality selects enforcement, and hasScrim selects paint. This head does not need a new API ruling; it must apply the existing one. Canary-only lab status makes the migration cheap but does not authorize replacing the ratified contract with one combined boolean.

VERDICT: BLOCKS — isModal recombines the two axes the ratified ruling keeps independent.

THEMING

No theme target, token, component variable, or variant changes. The panel keeps the existing drawer target and tokenized surface/border/shadow/motion values. The custom bounded scrim remains component-owned paint outside the panel target, as in the previously reviewed feature.

VERDICT: clear

BREAKING

BEHAVIOR: existing viewport call sites are migrated from hasScrim={false} to isModal={false} inside a canary-only package; bounded modal behavior changes, and late-added direct children remain operable. API: yes in source — removes hasScrim and adds isModal; no stable consumer break because @astryxdesign/lab is private/canary-only. VISUAL: yes, intentionally — the bounded panel/scrim is pane-scoped; current-head geometry is 260×298 and remains fixed through 180px of pane scroll. THEME: no target/token/override is removed or renamed.

VERDICT: note — no stable consumer is broken, but API/behavior defects are owned by API and A11Y rather than counted twice.

PERFORMANCE & RESOURCES

EFFECTS: six added/changed in the response — target resolution, target replacement observation, dev-only static-position warning, scroll/resize synchronization, inert ownership, and dialog synchronization. Three pre-existing Effects remain for callback freshness, unmount cleanup, and registry membership.

Effect + deps external system why render/handler cannot do it measured render cost lifetime + cleanup focused test
layout target sync, no deps (Drawer.tsx:622-630) consumer ref/portal ref is null on first render and can swap closed commits improve 2N→N; open commits worsen 2N→3N at N=1/3/10 every commit; state write bails when equal only full-component jsdom
document mutation observer (:634-646) target attachment stable ref identity does not report replacement current open adds N observers at N=1/3/10 body subtree while open; disconnects replacement jsdom test
static warning (:659-666) computed style browser owns resolved position closed reads improve N→0; open remains N portal lifetime none focused
scroll + resize (:686-709) host scrollport geometry browser owns scroll/client box current open adds N ResizeObservers plus one scroll listener per Drawer open target; both cleaned jsdom geometry test + Chromium scroll probe
inert ownership (:719-746) browser interaction tree browser owns inert behavior no React state write open modal; cleanup removes owned attributes full-component jsdom; Chromium exposed late-child gap
dialog sync (:753-833) native dialog/top layer controlled state drives imperative API included in 3N open commits transition listener/timer cleaned full-component jsdom; main now has focused hook

RENDER: matched harness at N=1/3/10: previous reviewed head closed 2/6/20 commits, current 1/3/10; previous open 2/6/20, current 3/9/30. LISTENERS/OBSERVERS: current open adds one document-subtree MutationObserver and one per-host ResizeObserver/listener per Drawer; counts scale N=1/3/10 and clean up in source. LAYOUT: current closed style reads fall from 1/3/10 to 0; current open remains 1/3/10. Scroll reads four host geometry values and writes transform/inline/block size per event. BUNDLE: no dependency; response is +484/−86 across four files.

The closed-state regression is fixed. The open-state cost grows, but the branch also fixes previously broken scroll/replacement behavior; it is feature work rather than a same-outcome regression. The actionable issue is architectural: this machinery is inline and conflicts with main’s named presence unit.

VERDICT: note — measured closed improvement and open linear cost; no separate performance ask beyond the architecture/rebase block.

VISUAL EVIDENCE

VISUAL CHECK: manual frames required WHY: bounded geometry and modal scrim/inert behavior changed, while exact-head visual CI did not run (only the Visual acceptance initializer ran and failed). Local Storybook used port 6257.

Current-head scroll contract

Current head: bounded modal remains pinned after pane scroll

Sensor receipt: exact head 4fecc8f5c0525c8f5d791fe66c633036c4b2bb38; story lab-drawer--bounded-in-a-scrolling-pane; neutral/light/LTR; 900×520@1; forced colors/reduced motion/coarse pointer off, hover on; one visible 260×298 dialog; fonts loaded; zero running animations/errors; scrollTop 180, panel moved 0px; existing blocked control refused focus and pointer hit.

Finding: content added after open remains keyboard-active

Current head: late pane control focused beneath the modal scrim

Sensor receipt: same exact head/story/theme/mode/direction/viewport/media; one visible 126×24 late control; fonts loaded; zero running animations/errors. Inspected mutation arm: insert one direct pane button before the Drawer clip after open, wait one task, call focus(); no component/source/style change. Result: inert=false, focus accepted, pointer hit blocked.

The screenshot reader did not expose image pixels in this session, so the frames are embedded for human inspection but no visual styling claim is made from them. The semantic/geometry sensors and browser state establish the interaction defect independently.

VERDICT: not looked at — durable sensored frames exist, but pixels were not visible to this reviewer.

A11Y & I18N

Exact-head pr-a11y and pr-rtl did not run; only 11 checks exist and no CI workflow run is attached to 4fecc8f5. Local focused tests pass 54/54. Real Chromium confirms the revised current-child contract at N=1: after 180px scroll the modal remains fixed, existing pane content refuses focus, and pointer hit testing stays blocked.

The contract fails when a direct pane child appears after open. The inert Effect snapshots host.children once; the document observer does not cause that Effect to rerun when the target itself is unchanged. A late control has no inert, accepts focus, and remains keyboard-active while the scrim blocks its pointer path. No new strings, locale formatting, directional glyphs, or physical CSS properties were added; RTL was not rerun at this head.

VERDICT: BLOCKS — a keyboard user can activate late-added pane controls that the modal scrim blocks for pointer users.

JUDGEMENT

slot verdict
PROBLEM clear
SOLUTION BLOCKS — ruling contradicted; late content stays live
ARCHITECTURE BLOCKS — conflicts with landed parent; inline modal manager misses changing pane content
IMPACT BLOCKS — keyboard/pointer mismatch returns for late children
API BLOCKS — combined isModal contradicts ratified independent axes
THEMING clear
BREAKING note — canary-only API migration; no stable consumers
PERFORMANCE note — closed improvement, open linear feature cost
VISUAL not looked at — frames captured but pixels unavailable to reviewer
A11Y & I18N BLOCKS — late-added control remains keyboard-active

GOAL: partly met — exact-head Chromium shows scrollTop 180 with 0px panel movement and existing pane content blocked consistently, and the focused suite passes 54/54; a late pane control remains focusable, and current main does not merge cleanly. The independent scope/modality/scrim API direction is unresolved. HISTORICAL DISPOSITION (SUPERSEDED): late-child modality failure and the two-file conflict are objective blockers; the prior API block incorrectly relied on historical Ruling 15 (non-authoritative). ADVICE: rebase onto current main’s useDrawerDialogPresence and preserve the modal invariant for every pane child present or added while open. Do not prescribe the unresolved public API shape. AUTHOR CAN PROCEED: no — objective defects are measurable, but no applicable current in-repo Drawer spec settles scope/modality/scrim ownership. WORST OUTCOME: “A late pane control has no inert, accepts focus, and remains keyboard-active while the scrim blocks its pointer path.” → request changes

JUDGEMENT NEEDED: API — novel-human; ask one private owner question. No contributor-facing REVIEW, post, or merge.

request changes

  1. [BLOCKS] isModal recombines the ratified paint and enforcement axes → builders cannot independently choose Drawer scope, modality, and scrim paint as the accepted contract requires · packages/lab/src/Drawer/Drawer.tsx:478-519

  2. [BLOCKS] bounded modal inertness snapshots only the pane’s current children → a control portalled into the pane after open remains keyboard-active while its pointer path is blocked · packages/lab/src/Drawer/Drawer.tsx:719-746

  3. [BLOCKS] the branch predates the landed Drawer presence extraction → current head has content conflicts in Drawer.tsx and Drawer.test.tsx, so its integrated lifecycle cannot build or run · current-main merge tree

HISTORICAL POSTED REVIEW — API DIRECTION REQUIRES CORRECTION

Thanks—the scroll and closed-cost fixes now pass, but our prior API ask is still open. The ratified Drawer ruling keeps containerRef as scope, modality as enforcement, and hasScrim as paint; isModal recombines the last two, so please restore those independent axes (Drawer.tsx:478-519).

The inert pass also snapshots only current children. In Chromium, a control inserted into the pane after open had no inert, accepted focus, and stayed keyboard-active while the scrim blocked its pointer path (Drawer.tsx:719-746). Please keep later pane content unavailable for the full modal lifetime.

This also needs a rebase: #5549 landed useDrawerDialogPresence, and current head conflicts in Drawer.tsx and Drawer.test.tsx.

[Reviewed by Robohands]

INLINE (0–3, only when genuinely line-anchored)

None. The API, dynamic-pane, and current-main findings are cross-cutting contracts.

EVIDENCE I DID NOT SPEND

  • The target-replacement regression passes in jsdom, but was not browser-driven at this head.
  • Current-head open cost scales from 2N→3N Profiler commits and adds N document observers plus N ResizeObservers at N=1/3/10; closed cost improves from 2N→N with zero style reads.
  • isModal matches Popover’s spelling, but historical Ruling 15 (non-authoritative) explicitly chose Drawer’s independent modality and hasScrim axes; this run does not reopen that decision.

TIME

TIME total 20m setup/rules 5m full kit, rubric 1.13, prior review, ruling; warm main reused: yes install/build/server 3m cloned install, targeted builds, Storybook port 6257 (launcher did not emit the OS PID), then stopped and verified empty browser/a11y 3m 2 Chromium probes, 2 sensored frames; visual exploration 2m, capture 1m focused tests 2m 54 Drawer tests, lab typecheck, N=1/3/10 matched perf harness code/history 3m response diff, current-main movement, merge-tree conflict critique+wiki 4m 2 presentations, 2 critic gates, fresh clone, pull/rebase/push CI wait 0m no exact-head CI workflow ran waste 2m initial typecheck before core build; first sensor overfit absolute y; occupied port 6255 detected before launch

WHAT I COULD NOT VERIFY

  • Safari/WebKit; this Mac’s supported Playwright lane is Chromium only.
  • Target replacement in a real browser at current head; the regression is jsdom-only.
  • Pixel appearance of the two captured frames; this session’s image reader returned metadata without visible pixels.

ROUND 3 CRITIC GATE

VERDICT CHECK: correct — request changes. The ratified API ask is contradicted, the earlier pointer/keyboard blocker remains for late pane children, and the current branch conflicts with its landed parent.

LENGTH: REVIEW 103 words before attribution (cap 150) → ok. No inlines.

VIOLATIONS: none.

WOULD SHE HAVE POSTED IT: yes. It names what the author fixed, applies the existing ruling without reopening it, gives two actionable code findings plus the required rebase, and ends with the exact attribution.

ROUND 3 WHAT CHANGED BEFORE POSTING

Nothing was posted to the PR. The review is archived exactly as drafted; no review, comment, approval, request-changes action, merge, auto-merge, label change, or contributor-branch push was made.

Round 4 — R1e re-review at current head, 2026-08-31

PR

#5550 feat(Drawer): independent scope, modality, and scrim paint by freddymeta (bucket: collaborator — treated as internal; write permission, not in ENGOWNERS/DESIGNOWNERS)

HEAD REVIEWED

934a85c000d196ee420560b81322c6cfdf0a4424 <- every claim below was verified at this commit

VERSIONS

LOOP VERSION: 2.0.0 AUDIT RUBRIC: 1.15.1

LANE

LANE: full WHY: new public surface (containerRef, modality, hasScrim semantics), six added Effects with two MutationObservers and a ResizeObserver, a new module-level registry, an unresolved standing CHANGES_REQUESTED of ours, and subsystem tells (a concept the system does not have; BottomSheet is queued to adopt it). Fast is unavailable on every condition.

CONTEXT & ROUTING

CHANGED CODE: Drawer gains three axes — containerRef (portal the panel into an element and pin it there), modality ('modal' | 'nonModal', enforcement), hasScrim (paint). Bounded modality is enforced by writing inert onto the container's other children; a second module-level registry of rendered bounded drawers decides which one enforces; a scroll/resize sync pins the clip box to the container's scrollport; a document.body MutationObserver watches for the container being swapped or detached. NEAREST CURRENT COMPONENT CONTRACT: Drawer.tsx on origin/main — one axis (hasScrim) drives showModal(), useScrollLock, aria-modal and backdrop dismissal together; a module-level LIFO openDrawerStack owns Escape ordering and hands non-modal drawers incrementing z-indexes from NON_MODAL_BASE_Z (Drawer.tsx:82-124 at head, :82-113 on main). CURRENT FAMILY: BottomSheet.tsx:275 still conflates paint and enforcement on hasScrim; Popover.tsx spells enforcement isModal?: boolean; packages/core/src/Layer/ (LayerProvider, LayerContext, LayerDepthContext, layerDismissalFamilies) is the landed overlay-ordering subsystem, and Drawer does not use it — pre-existing on main, not this PR's debt (R13). CURRENT API/THEMING/SYSTEM RECORDS: our own review of 2026-08-30 (4bd6e6610): "The independent scope/modality/scrim API is acceptable as reversible lab work… Please fix that bounded-modal stacking boundary; the other API experimentation does not block." That is a current durable ruling over the axes question. @astryxdesign/lab is private: true — no installed consumer. DRAFT CONTEXT: none — context/evidence only; never policy or precedent. STOPPED AT: component contract + our own current ruling — the axes question is answered by the 2026-08-30 ruling, and the remaining questions are correctness against the contract that ruling assumed. REGRESSION EVIDENCE: bug fix / regression claim — the previous head's stacking defect is fixed for the reported case (baseline arm: front panel clickable and focusable, drawer behind inert, Chromium at this head) and reappears at the same contract boundary through the peer-modality path (after arm: front panel not clickable, not focusable). Negative control: reverting the registry entry's leave-and-rejoin flips it back. OWNER QUESTION (PRIVATE): none — the axes decision is settled by the 2026-08-30 ruling and is not re-asked.

candidate issue classification evidence disposition
Three independent axes (containerRef/modality/hasScrim) rather than one prop settled our 2026-08-30 review ratified them as reversible lab work; lab is private: true apply the decision; no finding, not re-asked
A peer drawer's modality change moves bounded enforcement to the drawer behind, inerting the front panel preserves→violated Chromium at head: front z-index 1001 vs back 1000, front control clickable true→false, focusable true→false; jsdom attribute probe; negative control blocks now
"Which bounded drawer is in front" has two writable sources (boundedDrawers order, stackZ from openDrawerStack) that disagree novel? no — derivable the two disagree in the measured arm above: paint says Front, enforcement says Back blocks now (same defect, stated at its own level)
modality="modal" hasScrim={false}: viewport closes on an invisible backdrop click, bounded does not out-of-scope of the block, in-scope as a note driven at head: viewport ::backdrop opacity 0, click at (20,400) closes it; bounded unchanged not blocking — extend xinzi-bot's 18:12 comment
document.body {subtree, childList} observer per open bounded drawer preserves (new cost, not a regression) 200 insertions outside the pane: 0 callbacks closed, 200 at N=1, 400 at N=2 not blocking; carried to JUDGEMENT
Five inline Effects coordinate refs, two MutationObservers, a ResizeObserver, a scroll listener and a native attribute, with no named unit preserves→violated (R35) Drawer.tsx:727-900; tests only through the full component DOM blocks now — same parent as the two above
Drawer is not on the core/Layer stack out-of-scope origin/main Drawer already owns its own LIFO do not charge the author (R13); route separately if wanted
label="Close" is a hardcoded AT-facing string out-of-scope origin/main:Drawer.tsx:558 — identical pre-existing (R13); no finding

PROBLEM

WHY 1: A Drawer can only be the viewport's. Someone building a split view, a dashboard card or a canvas inspector has no way to say this panel belongs to this pane, because position: fixed and showModal() both address the screen. WHY 2: So the builder either takes over the whole page for a panel that belongs to one region — which blanks out the rest of the app the person was working in — or hand-rolls a sheet and re-implements dismissal, focus, and scroll behaviour badly. WHY 3: Master-detail is the pattern Drawer exists for. When the detail view can only be page-modal, the product either loses the surrounding context the person is comparing against, or ships an overlay that does not behave like the rest of the system.

USER-FACING PROBLEM: A person inspecting one row of one pane in a multi-pane app loses the whole page to a modal panel, or gets a hand-rolled panel whose Escape and focus behaviour differs from every other overlay they use. PROBLEM SEVERITY: missing capability — a demonstrated job (pane-scoped inspector) has no supported path. NEW FEATURE CASE: named demand — the PR cites a downstream design system carrying its own "contained" sheet whose source comments call it an approximation of what Astryx lacks; imdreamrunner and rubyycheung plan to promote Drawer to core and to follow this API in BottomSheet (comment, 2026-08-29). Existing mechanisms do not reach: the browser top layer is viewport-sized by specification, so no CSS makes showModal() respect a box. EARLY STOP: clear.

VERDICT: clear

SOLUTION

A drawer normally belongs to the screen, so it opens in the browser's own top layer and the whole page goes out of play behind it. This change lets a caller say the drawer belongs to one region instead: the panel is placed inside that region and pinned to its edge, and "the area behind is out of play" then means that region rather than the page. Because the browser's top layer can only ever be the size of the screen, a region-scoped drawer cannot use it, so the region's other contents are switched off directly instead. What is blocked and what is dimmed became two separate answers, so a caller can dim without blocking or block without dimming.

Hypothesis (7d, early): everything I find will be a child of the drawer keeps its own private answer to "which overlay is in front", and now it keeps two of them.

SOLUTION (3 decisions · ~311 non-comment runtime lines of 1550 added)

  1. containerRef scopes the drawer to an element: portal in, position: absolute, a clip wrapper pinned to the container's scrollport. — serves the stated problem
  2. Split enforcement (modality) from paint (hasScrim), with bounded enforcement written as inert on the container's other children for the whole rendered lifetime. — serves the stated problem; ratified 2026-08-30
  3. A second module-level registry of rendered bounded drawers, so only the front-most blocking drawer in a host enforces. — serves the defect our 2026-08-30 review reported

BURDEN: high — two module-level registries, a useSyncExternalStore subscription, six added Effects, two MutationObservers, a ResizeObserver, a passive scroll listener, direct writes of a native inert attribute onto elements the component does not own, and three new public props. BURDEN MATCH: proportionate to the problem — a missing capability whose mechanism is genuinely constrained by the top layer being viewport-sized. The burden is not the finding; where it is placed is.

VERDICT: note — 3 decisions, all traced to a stated problem; the count is at the threshold and the third exists only because the second's enforcement has no owner

ARCHITECTURE

OWNER: Drawer owns the panel. Nothing owns "which overlay is in front of which" — that is the finding. TIER 1: packages/core/src/Layer/ (LayerProvider, LayerDepthContext, layerDismissalFamilies) is the landed system for overlay ordering and dismissal; Drawer used none of it before this PR and uses none of it now. Reused: useScrollLock, useDevWarning, themeProps, mergeProps, overlayPaddingReset, useDrawerDialogPresence. TIER 2: none. SEAMS: the consumer's container element (any DOM the caller supplies), the portal, sibling drawers in the same host, sibling drawers in different hosts, a container that scrolls, a container that is swapped or detached. BEHAVIOR UNIT: inline — and this is a block. useDrawerDialogPresence shows the boundary this file already knows how to draw; the bounded machinery did not get one. COMPLEXITY BUDGET: the proved problem is one capability with one genuinely constrained mechanism. Budget: one owner for panel placement, one owner for overlay order, zero new orderings of drawers. ACTUAL BURDEN: 9 changed files; ~311 non-comment runtime lines added in Drawer.tsx (2 Effects → 8); 2 module-level registries; 1 external store; 2 MutationObservers + 1 ResizeObserver + 1 scroll listener; focused test matrix: none against a named unit — 64 tests, all through the full component DOM in jsdom. BURDEN TREND: first reviewed head 0019e5c9 → current head 934a85c0: grew at every round — modality added, removed, re-added, collapsed to isModal, re-split; then a second registry, an external store and clipsNotBehind were added to fix the previous round's ownership defect. The stated problem did not change across any of it. RESET TRIGGER: triggered — the remedy for the 2026-08-30 ownership finding added another representation of the disputed fact (a second drawer ordering) rather than contracting to one, and a second model-level finding is present on the same PR (no testable unit for the bounded state machine).

domain fact one authoritative writable source generated / immutable projections other writable copies
which drawer is in front, within one host none two, and they disagree: boundedDrawers array order decides who enforces (Drawer.tsx:217-225); openDrawerStack's counter decides who paints on top via stackZ (:106-110, :1014). Measured disagreement at head — BLOCKS
which drawer Escape closes openDrawerStack (global, not per host) none
whether a container child is out of play the front-most blocking drawer's blocked set the inert attribute none
the portal host containerRef.current portalTarget state none
seam driven result
built-in surface, one bounded modal works — panel inside the pane, container children inert, page outside live
two bounded modals in one host, opened normally works — front interactive, back inert (Chromium, this head)
two bounded modals in one host, peer changes modality while open fails — the front panel goes inert; not clickable, not focusable
two bounded drawers in different hosts works for inert scope (test at Drawer.test.tsx:1015); Escape remains global-LIFO, pre-existing
container that scrolls works — clip box tracks the scrollport
container swapped / detached works — document.body observer re-resolves

The model-level defect. Two orderings of the same drawers exist and nothing derives one from the other. boundedDrawers is appended to whenever a drawer's registry effect re-runs, and that effect's cleanup calls removeBoundedDrawer on any change to blocksBehind, portalTarget or isRendered — so a modality change deletes the entry and re-adds it at the end, making that drawer the front-most enforcer. The upsertBoundedDrawer comment at :171-178 says re-adding "would promote a buried drawer to the front" and its position-preserving branch is unreachable, because the cleanup has always removed the entry first. Paint order, meanwhile, comes from stackZ, which is only assigned on open and does not move. The two therefore disagree on exactly the case the previous round blocked on.

VERDICT: BLOCKS — one domain fact ("which bounded drawer is in front in this host") has two writable sources that disagree at the current head; architecture reset required

IMPACT

Builders: invisible until someone passes containerRef or modality — every existing call site is byte-identical, and @astryxdesign/lab is private: true, so nobody outside the repo installs it. Inside the repo, isModal was removed outright and every branch-owned call site migrated in the same diff. End users, once a product adopts it: a person inspecting a row in one pane keeps the rest of the application live — the other panes, the page chrome, the navigation — instead of losing the page to a modal. That is the whole point of the change and it works. And what landing this newly exposes: two bounded drawers can now be open and independently operable in one region, which was impossible before — a page-modal drawer took everything out of play. That new state is where the block lives, and it is also where the pre-existing global-LIFO Escape rule becomes visible: with two bounded non-modal drawers open in different panes, Escape in the first-opened one does nothing, because isTopDrawer is global. That behaviour is on main today (Drawer.tsx:112) and is not this PR's to fix, but it was previously hard to reach and is now ordinary. NEW FEATURE IMPACT: person — a support engineer comparing two hosts in a split view; current worse outcome — opening one host's details blanks the whole console, so the comparison is lost; observable improvement — the second pane, the filter bar and the navigation stay usable while the inspector is open.

VERDICT: note — a pre-existing Escape limitation becomes ordinary rather than exotic; named here so approving is a choice about a known gap

API

// packages/lab/src/Drawer/Drawer.doc.mjs:196-206 — the documented call site
const paneRef = useRef<HTMLDivElement>(null);

<div ref={paneRef} style={{position: 'relative'}}>
  <HostTable onSelect={setSelected} />
</div>

<Drawer
  isOpen={selected != null}
  onOpenChange={isOpen => !isOpen && setSelected(null)}
  label="Host details"
  containerRef={paneRef}>
  <HostDetails host={selected} />
</Drawer>
change public? class doc'd? verdict
+ Drawer.containerRef?: React.RefObject<HTMLElement | null> yes — lab barrel (private: true package) Popover.anchorRef, Tooltip.anchorRef, Typeahead.anchorRef — same shape, same nullable ref en+zh ok
+ Drawer.modality?: 'modal' | 'nonModal' = 'modal' yes — lab barrel Popover.isModal?: boolean is the nearest member and spells it differently; ratified 2026-08-30 as lab experimentation en+zh ok — settled
~ Drawer.hasScrim?: boolean — was = true and drove showModal(), scroll lock, aria-modal and backdrop dismissal; now paint only, defaulting to modality === 'modal' yes — lab barrel BottomSheet.hasScrim still carries the old conflated meaning en+zh ok — settled; family divergence noted
- Drawer.isModal?: boolean (added and removed within this branch) never released removed everywhere ok

PUBLIC API GATE API ROW: Drawer.containerRef?: React.RefObject<HTMLElement | null> NON-DERIVABLE NEED: yes — the top layer is viewport-sized by specification; no existing prop, token or composition scopes an overlay to a box. MEANING: understandable — "put this drawer inside that element instead of over the screen". PREDICTABILITY: predictable for valid use; invalid use (a position: static container) is caught by a dev warning rather than silently mis-placing the panel; absent, every existing path is byte-identical. CAPABILITY: capable for one drawer per host, driven at this head. Not capable for two drawers in one host across a modality change — see ARCHITECTURE. DOCS OBLIGATION: required — new public entries; met, en+zh, with the stacking contract stated on containerRef.

API ROW: Drawer.modality?: 'modal' | 'nonModal' = 'modal' NON-DERIVABLE NEED: yes — with containerRef present, hasScrim alone can no longer express both "dimmed" and "blocked" for two different scopes. MEANING: understandable, and the doc names both mechanisms. PREDICTABILITY: predictable, with one documented asymmetry (aria-modal and body scroll lock are absent when bounded). CAPABILITY: capable — driven in all four modality/scrim combinations in both scopes. DOCS OBLIGATION: required; met en+zh.

API ROW: Drawer.hasScrim?: boolean = modality === 'modal' NON-DERIVABLE NEED: yes — same split. MEANING: understandable. PREDICTABILITY: partly — with hasScrim={false} on a modal drawer, outside-click dismissal survives in the viewport scope (the ::backdrop is still there at opacity 0) and does not exist in the bounded scope. Same two props, two different dismissal contracts. CAPABILITY: capable. DOCS OBLIGATION: required; met, but the doc says "for modal drawers the scrim is also the pointer dismissal surface" and that is only true bounded.

OSSIFICATION: all three ride in @astryxdesign/lab, private: true, never published — nothing outside the repo can be broken (R14g), so this is the cheap moment to get the shape right rather than a break. containerRef serves a class (three landed anchorRef members). modality and hasScrim were settled by our 2026-08-30 ruling and are not re-argued here. Cost of being wrong: a rename inside the repo plus the BottomSheet family divergence, which imdreamrunner has already offered to follow.

VERDICT: note — the hasScrim promise about pointer dismissal is true bounded and false in the viewport scope

THEMING

No new theme targets, no new tokens, no changed target names. themeProps('drawer', {side: anchoredSide}) is unchanged from main. New style rules (bounded, boundedScrim, viewportScrim, boundedClip, boundedInteractive, boundedScrimClosed) use colorVars['--color-overlay'], durationVars, easeVars only; the imperative writes in the scrollport sync are geometry (transform, inlineSize, blockSize), which T2b exempts as layout values. Grep over the added lines of the whole diff, excluding stories/tests/generated: #hex|rgba?\(|hsla?\(|boxShadow|light-dark\(0 real hits (2 matches are comments containing #5550/#5549).

VERDICT: clear — no theming surface added or removed, grep above

BREAKING

BEHAVIOR: yes, inside the repo only. hasScrim no longer drives showModal(), useScrollLock or aria-modal; a call site that wrote hasScrim={false} to mean "non-modal" now gets a modal drawer with no scrim. Every branch-owned call site was migrated to modality="nonModal" in the same diff, and isModal was removed rather than aliased. Walked states: empty (no children — renders, panel and close button present), loading (n/a — no async), error (n/a), disabled/readonly (n/a — no disabled state), controlled (the only path; isOpen is required), boundary (zero drawers, one, two in a host, two in different hosts, container swapped, container detached, container scrolled past its content). API: no for anyone outside the repo — @astryxdesign/lab is private: true and has never been published, so no installed consumer can be broken (R14g). Inside the repo, isModal removal and the hasScrim meaning change are both carried by the diff. VISUAL: no for existing call sites — with containerRef absent and modality defaulted, the rendered output is the main path. Growth: nothing in the viewport path grew; the bounded path adds a clip wrapper with pointer-events: none inside the consumer's container, which adds no box to the flow (position: absolute). THEME: no — targets, tokens and overrides all resolve unchanged (see THEMING).

VERDICT: note — an in-repo behaviour change on hasScrim, fully migrated in the same diff

PERFORMANCE & RESOURCES

EFFECTS: Drawer.tsx goes from 2 Effects on origin/main to 8 at head — six added, five of them gated on a resolved bounded container.

~ useEffect  [onOpenChange]                       Drawer.tsx:691   unchanged from main
+ useLayoutEffect  (no deps)                      Drawer.tsx:727   resolve portal target
+ useEffect  [needsPortalTarget, portalTarget, containerRef]  :739  MutationObserver on document.body
+ useEffect  [portalTarget]                       Drawer.tsx:765   dev-only getComputedStyle
+ useLayoutEffect  [portalTarget, isRendered]     Drawer.tsx:792   scroll listener + ResizeObserver
+ useLayoutEffect  [drawerId, portalTarget, blocksBehind, isRendered]  :821  registry membership
+ useLayoutEffect  [portalTarget, blocksBehind, isRendered, drawerId, boundedTick]  :852  inert + MutationObserver on host
~ useEffect  [isOpen, drawerId]                   Drawer.tsx:916   unchanged from main
Effect + deps external system why render/handler cannot do it measured render cost lifetime + cleanup focused test
useLayoutEffect (no deps) :727 consumer's ref object, whose contents can change without the object changing a ref read cannot be a dependency 0 extra commits when unchanged (compares before writing); closed bounded Drawer commits ['mount'] only — Drawer.test.tsx:1117 Profiler test for the component's life; no listener yes, :1117
useEffect :739 MutationObserver document.body {subtree, childList} DOM — container swapped or detached React never re-renders on a foreign unmount 200 callbacks per 200 insertions outside the pane at N=1 open bounded drawer; 400 at N=2; 0 when closed attached while a bounded panel is rendered; observer.disconnect() on cleanup no focused test
useEffect :765 dev-only getComputedStyle it is a style read that must not run in render (R18g bright line) production-excluded by process.env.NODE_ENV guard per portalTarget; no listener no
useLayoutEffect :792 scroll + ResizeObserver container scrollport geometry the value only exists after layout writes transform/inlineSize/blockSize straight to the node — 0 renders per scroll frame attached while a bounded panel is rendered; listener removed + observer disconnected yes, :752
useLayoutEffect :821 registry module state must publish the clip element, which does not exist until commit 1 external-store notify per real change, fanning out to every bounded drawer while rendered; removeBoundedDrawer on cleanup indirectly, :867-1012
useLayoutEffect :852 inert + MutationObserver on host {childList} native inert attribute on elements the component does not own a pane is live content; a one-shot pass misses later arrivals iterates host.children per child-list change in the pane while rendered and front-most-blocking; removes only what it added yes, :808, :828

RENDER: no reachable regression on the viewport path — with containerRef absent, needsPortalTarget is false, the layout effect writes nothing, and the five bounded Effects return early. A closed bounded Drawer costs one commit, asserted by the PR's own Profiler test at Drawer.test.tsx:1117 — the previous round's ask, and it holds. LISTENERS/OBSERVERS: the document.body {subtree, childList} observer is the widest scope in the diff and it is held for the whole rendered lifetime, not for the moment it is needed (R18f). Measured with the burst probe: closed 0 callbacks · one open bounded drawer 200 · two open 400, for 200 DOM insertions in an unrelated part of the page. It scales linearly with open bounded drawers because each holds its own. This is the cost of the capability rather than a regression against main (main has no bounded mode), so it is not a measured degradation and does not block — but a page that streams rows anywhere while a pane inspector is open pays it, and the question it answers is only "is my container still connected". LAYOUT: the scrollport sync writes geometry from a passive scroll listener without reading layout in the same frame beyond scrollLeft/scrollTop/clientWidth/clientHeight on the host it is already syncing to; no forced reflow in render. getComputedStyle is dev-only and out of render (R18g bright line respected). BUNDLE: no new dependency. @astryxdesign/lab ESM/CJS 3.5KB, gzipped 1.5KB in the PR Analysis Report on this head. REPRO: banked local observer probe · BURST=200.

VERDICT: note — no measured degradation against main; one document-wide observer per open bounded drawer, priced at 200/400 callbacks per 200 unrelated insertions

VISUAL EVIDENCE

VISUAL CHECK: manual frames required WHY: the Stable visual regression job is skipped on this exact head (24 check runs, all green or skipped), so no visual gate covers it; and the block's premise — that the panel that stops working is the one painted in front — is a claim about what a person sees, which source cannot settle.

The front panel stops working while still painted in front

Arm 1 — back drawer nonModal. The front inspector is painted on top, and its control takes a focus ring when focused.

Bounded pane, back drawer nonModal: the front inspector panel is painted on top and its control takes a focus ring

Arm 2 — the back drawer is switched to modal. The panel is painted identically: same box, same edge, same close button, z-index 1001 against the back drawer's 1000.

Same pane after the back drawer switches to modal: the front panel is painted identically on top, its control takes no focus ring, and the pane behind is dimmed twice

Both frames were captured in real Chromium at this head, opened with the read tool, and are embedded above rather than linked. Every visible difference, labelled:

difference intentional? source
the toggle's own label reads nonModalmodal intentional it is the variable the arm sets
the pane behind is dimmed once → twice intentional two bounded scrims now paint; hasScrim defaults to modality === 'modal' (Drawer.tsx:757), so switching the back drawer to modal adds its scrim
the front control loses its focus ring unintentional — this is the finding the probe focuses that control in both arms; in arm 1 focus lands, in arm 2 it is refused, so no ring is painted
the front panel itself unchanged — same geometry, same paint, same stacking it is the panel a person is working in, and nothing about it says it stopped working

SENSOR RECEIPT — one per frame, written by captureWithSensors() beside each PNG (arm 1 receipt, arm 2 receipt):

sensor arm 1 (before) arm 2 (after)
build 934a85c000d1… asserted against the worktree HEAD same
story probelab-drawerstacking--two-bounded-modals same
theme / colour mode neutral / light same
direction ltr same
viewport / DPR / media 1000×640 @1; forced-colors false, reduced-motion false, pointer fine, hover same
target dialog[aria-label="Front drawer"], count 1, visible, inside viewport same
animation runningAnimations: 0, gated same
fonts loaded same
page / Storybook errors none none
semantic state backModality: nonModal · 2 open dialogs in the pane · frontZ 1001 · backZ 1000 · frontIsPaintedInFront: true backModality: modal · 2 open dialogs · frontZ 1001 · backZ 1000 · frontIsPaintedInFront: true
contract check holds fails: frontClipInert required false, observed true · frontControlClickable required true, observed false · frontControlFocusable required true, observed false

A machine diff of the two receipts reports exactly one differing field — state.backModality — which is the variable under test. Everything else, build included, is identical.

How the two halves are kept apart, because R38 is easy to launder here. The state sensor gates the capture and pins only what the frame is a frame of: the configuration this arm sets, and which panel is painted in front. Both are contract-derived — the story chooses the modality, and paint order follows registry open order — and neither was read off the page and copied back. The component's promise (the panel painted in front stays operable) is a separate contractCheck, written once and identical for both arms, run and printed before each capture and stored in both receipts. So no expectation was loosened to make a frame pass: arm 2's frame has a valid receipt and that receipt carries the failed contract, which is the finding.

REPRO: banked review probe against an uncommitted scratch Storybook story; reviewed source remained unchanged.

Public evidence is pinned to immutable asset commit 27f921e2569c.

VERDICT: BLOCKS — the panel painted in front is inoperable, proved in two receipted frames whose only sensor difference is the variable under test

REMEDY SEARCH

REMEDY SEARCH: not triggered — author owns HOW; acceptance criteria are sufficient

A11Y & I18N

Auto-covered. pr-a11y is success on this head and adds no .github/a11y-baseline.json entries (A18). pr-rtl success. No new user-visible or AT-facing strings (I1/I4/I5): the diff adds no useTranslator call and no locale key, and label="Close" is byte-identical to origin/main:Drawer.tsx:558 — pre-existing, not this PR's (R13).

Checked by me, driven in Chromium at this head:

  • A4 keyboard / A5 focus — FAILS. With two bounded modal drawers in one host, changing the drawer behind's modality makes the front panel's controls unfocusable (element.focus() leaves document.activeElement elsewhere) and unhittable (elementFromPoint over the control is not the control). A person working in that panel cannot reach anything in it by keyboard or by pointer, and nothing on screen says why. Escape still closes it and the close button is inside the inert region — so the only exit is Escape, and only while focus is already in the panel, which it will not be once focus is dropped.
  • A4/A5 baseline, same head: with the drawers opened normally, the front panel is focusable and hittable and the drawer behind is correctly inert. The fix from 2026-08-30 works for the case it was reported on.
  • A11/A12: no disabled={isLoading} shape; no input ARIA touched.
  • A3: bounded modality is exposed through inert on the container's children rather than by colour alone; aria-modal is correctly absent bounded and documented as such.
  • A13: entry/exit both carry @media (prefers-reduced-motion: reduce) at 0.01s.

Not looked at: A14 forced-colors and A15 focus-ring-in-every-theme for the new bounded scrim — one theme, one mode driven.

VERDICT: BLOCKS — a keyboard or pointer user in the front bounded panel can reach nothing in it, in a state a live modality prop can produce

JUDGEMENT

slot verdict
PROBLEM clear
SOLUTION note — 3 decisions, all traced
ARCHITECTURE BLOCKS — two writable sources for "which drawer is in front", measured disagreeing
IMPACT note — pre-existing global-LIFO Escape becomes ordinary
API note — hasScrim's dismissal promise is true bounded, false in the viewport
THEMING clear
BREAKING note — in-repo hasScrim meaning change, migrated in the same diff
PERFORMANCE note — document-wide observer per open bounded drawer, 200/400 callbacks per 200 insertions
VISUAL BLOCKS — the panel painted in front is inoperable
A11Y & I18N BLOCKS — nothing in the front panel is reachable by keyboard or pointer

Reading the bodies, not the labels: ARCHITECTURE, VISUAL and A11Y are three descriptions of one defect at three levels, and the level that owns it is ARCHITECTURE — the a11y symptom is what the missing single source produces. API's note and PERFORMANCE's note are independent and neither compounds into a block.

GOAL: partly met — pane-scoped drawers work, and the previous round's reported stacking case is fixed at this head (baseline arm passes in Chromium). The class it belongs to is not: the same defect returns when a peer's modality changes, which is a live prop change this PR added and tests (Drawer.test.tsx:1254). Measured, both arms, one session, one build.

DISPOSITION:

  • Front bounded panel goes inert on a peer modality change → blocks now
  • Two writable orderings of bounded drawers in one host → blocks now (the same finding at its own level; one ask, not two)
  • Five inline Effects coordinating observers, refs and a native attribute with no named unit and no focused test → blocks now, folded into the same ask — it is why the join has no test
  • hasScrim={false} dismissal asymmetry between scopes → not blocking; xinzi-bot posted this to the PR at 2026-08-31 18:12 on this exact head, driven and confirmed here (viewport ::backdrop opacity 0, click at (20,400) closes it; bounded unchanged). Deliberately kept OUT of the public comment: the author already has it, and carrying it would have pushed the comment over its cap and ended it on the one finding that does not block
  • document.body subtree observer per open bounded drawer → not blocking, accepted as the cost of the capability for lab; named so it is a known cost before promotion to core, which imdreamrunner and rubyycheung have said is next
  • Global-LIFO Escape across bounded drawers in different hosts → not blocking, pre-existing on main (Drawer.tsx:112); named in IMPACT because this PR makes it ordinary. Not the author's (R13)
  • label="Close" untranslated → not blocking, pre-existing on main:558 (R13)

ADVICE: bounded outcome criteria — derived from the one-source analysis, not invented. After the fix: (1) exactly one ordering decides both which bounded drawer paints in front of its peers and which one enforces, so the two cannot disagree; (2) a change to any peer's current runtime inputs leaves that ordering unchanged; (3) a focused test drives a peer state change with two bounded modals in one host and asserts the front panel is still hittable and focusable. Do not prescribe unresolved public API axes.

AUTHOR CAN PROCEED: no — the objective defect and test criteria are proven, but the Drawer scope/modality/scrim API boundary lacks an applicable current in-repo spec.

WORST OUTCOME: "A person working in that panel cannot reach anything in it by keyboard or by pointer, and nothing on screen says why." → request changes.

JUDGEMENT NEEDED: API — novel-human; our prior 2026-08-30 review/ruling is evidence only and cannot settle the axes.

request changes

  1. [BLOCKS] Changing a peer bounded drawer's modality hands the container's inert pass to the drawer behind, which then applies it to the front panel → someone working in the pane inspector on top loses every control in it to both keyboard and pointer, while the panel stays painted in front of the one that took it over · Drawer.tsx:821-830 Confirmed a second way: reverting the registry entry's leave-and-rejoin on a blocksBehind change flips frontControlClickable back from false to true.

  2. [not blocking] hasScrim={false} on a modal drawer keeps invisible backdrop dismissal in the viewport and has none bounded → a builder reading one prop pair gets two different outside-click contracts depending on containerRef · Drawer.tsx:963

HISTORICAL POSTED REVIEW — OBJECTIVE DEFECT EVIDENCE RETAINED; API FRAMING REQUIRES CORRECTION

Thanks — the pane-scoped inspector is the right capability, and the scroll, late-content and closed-cost fixes hold at this head.

We asked for the bounded-modal stacking boundary last round, and the case we reported is fixed. It returns one step over: change one bounded drawer's modality while another is open in the same pane, and the drawer behind takes over the inert pass and applies it to the front panel. In Chromium that panel keeps painting on top and stops responding — whoever is working in that inspector loses every control in it, to keyboard and pointer, with nothing explaining why (Drawer.tsx:821-830).

A bounded pane now carries two orderings of its drawers — which paints in front, and which applies inert — and here they disagree. Could one ordering decide both? A test flipping modality on an open pair would pin it; the 64 we have pass without seeing this.

INLINE (0–3, only when genuinely line-anchored)

  • packages/lab/src/Drawer/Drawer.tsx:828 — This cleanup drops the entry, so the re-add lands at the end and this drawer becomes the one applying inert. anchored line at head: removeBoundedDrawer(drawerId);
  • packages/lab/src/Drawer/Drawer.tsx:191 — The keep-its-position branch never runs; the effect above always removes the entry first. anchored line at head: if (entry.host === host && entry.clip === clip && entry.blocks === blocks) {
  • packages/lab/src/Drawer/Drawer.tsx:963 — Bounded modals have no equivalent, so outside-click means two different things. anchored line at head: if (event.target === event.currentTarget && isTopLayerModal) {

EVIDENCE I DID NOT SPEND

  • A document.body {subtree, childList} observer per open bounded drawer: 200 unrelated insertions cost 0 callbacks closed, 200 at one open drawer, 400 at two.
  • The global LIFO means Escape in a bounded drawer does nothing while a later-opened bounded drawer is live in another pane — pre-existing on main:112, newly ordinary.
  • 64/64 Drawer tests pass at this head, and pr-a11y, pr-rtl, build, test are all green; Stable visual regression is skipped.

TIME

TIME total 33m (wall clock 18:48:52Z -> 19:2xZ, including the evidence-repair round) setup 6m isolated exact-head checkout + install + one @astryxdesign/build build + local Storybook, restarted once for the repair round (warm main reused: yes) reading 6m brief, critic, presentation contract, measurement harness, PR body, 5 prior comments + 4 prior reviews, Drawer.tsx, useDrawerDialogPresence, the test file measuring 10m 7 probe runs — jsdom stacking probe, negative control, Chromium stacking (twice: once unsettled and unreceipted, once settled with receipts on both arms), observer burst at N=1 and N=2, no-scrim dismissal writing 9m round-1 presentation, gate 1, round-2 rewrite, gate 2, round-3 visual rewrite, gate 3 waste 5m (1) the whole evidence-repair round, 4m. My first Chromium probe put the failing arm inside a try/catch and fell back to a bare page.screenshot, so the frame that carries the block was written with no receipt at all, and I then described both frames as receipted. The same probe never passed expected.runningAnimations, which captureWithSensors only asserts when asked — so it recorded runningAnimations: 3 and I called the render settled. Both frames were re-captured mid-transition and had to be retaken; a peer session caught it, not my own gate. (2) 1m of vitest reruns: --reporter=basic is not a reporter, and a package-relative test path finds nothing from the package directory; and row() spreads a JSON string one character per column, so the first Chromium result was unreadable and had to be re-run.

WHAT I COULD NOT VERIFY

  • Forced-colors and non-neutral themes for the new bounded scrim — one theme, one mode driven.
  • Whether a real product would ever change modality on a live bounded drawer; the PR added and tested the capability (Drawer.test.tsx:1254), so I treated it as reachable rather than hypothetical.
  • The focus ring visible in arm 1 and absent in arm 2 is produced by my probe focusing that control, not by a user action. It is honest evidence of the focus outcome and it is not what a person would see unaided; the operability claim rests on the hit-test and focus reads in the receipts, not on the ring.

What changed before posting

The REVIEW block was posted verbatim as a current-head CHANGES_REQUESTED review: https://github.com/facebook/astryx/pull/5550#pullrequestreview-5070342604. The three draft inline notes remain only in this durable record: the summary already carries the blocking ownership defect, and the no-scrim dismissal asymmetry already had an exact-head review comment.

Clone this wiki locally