Skip to content

chore(Layer): the outside-press watch can close on a pan, follow chosen pointer types, and judge a press in a closed shadow root - #9814

Open
vjeux wants to merge 51 commits into
meta:mainfrom
vjeux:uikit/c20-outside-pan-watch
Open

vjeux wants to merge 51 commits into
meta:mainfrom
vjeux:uikit/c20-outside-pan-watch

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #9814 → #8125 → #9816 → #8133
Merge group: #9814, #8125, #9816, #8133

Maintainer impact

ContextMenu's touch model keeps the page free to scroll while its menu is up, so a touch outside the menu must close it as that touch begins to pan, as UIKit does (spec:AST-094 FR32). Today the layer's outside-press watch hears only presses and lifts, and it cannot follow one pointer type or judge a press inside a closed shadow root. #8125 needs all three. This pull request adds them alone, for size.

Intended invariant

Every existing watch keeps today's behavior: a caller that passes no options gets the same press, lift and cancel handling. No component passes the new options here.

Change

watchOutsidePress and useOutsidePress take an optional OutsidePressOptions:

  • pointerTypes: the pointer types the watch follows; every type when omitted.
  • closesOnPan: a press that starts outside also closes the layer as it begins to pan, once its larger axis reaches core's pan threshold (UIKIT_PAN.hysteresis, the pan recognizer's own test), or when the browser takes the press. While one press outside is followed, other pointers wait; a new primary pointer starts a new press. Its moves come through core's input stream (subscribeInput), not a new window listener.
  • root: the shadow root the layer renders in, from any window. The document sees a press inside a closed root as a press on its host. So each root from it out to the document judges presses in the capture phase too: an inner root's judgment of a press's start stands, and each skips a lift that goes into the root below it. A host judges a lift on itself. Core's flat-tree owner gains shadowHostOf and shadowRootsOutward for it, and the selection lock now walks shadow roots through them.
  • A pointer type the watch does not follow is ignored for presses, lifts and cancels.
  • A layer it closes on a pan or a cancel counts as an outside dismissal (fix(Dialog, Drawer): a backdrop press that closes a popover inside closes only the popover #9988), as at a lift, so a modal host beneath stays open.

Evidence

  • Failure before the change: the new outsidePress.test.ts cases fail at the test commit (6 failures), and the dismissal-count case fails before its fix.
  • Success after the change: the Layer suite passes.
  • Product/runtime behavior verified unchanged: the existing outside-press cases, and every Layer suite that starts a watch without options, pass unchanged.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

The changeset is [chore] Internal:; the watch is not exported from the package entry point.

Testing

Rebased onto main 9e559e9, which it conflicted with: #9988 added the per-document outside-dismissal count to outsidePress.ts and its test, so the test file imports both and keeps both describe blocks, and the header lists both. The pan and the cancel close now count as a dismissal too, as #9988's lift does (two commits; red first, 1 of 22 cases fails before the fix). git range-diff against ae1d74a shows no other change. Local checks at e11abfe, on main 9e559e9: core typecheck and the Layer, ContextMenu, DropdownMenu, utils and motion units (2,779 passed); the full ui (21,278 passed) and node (16,672 passed) suites, CI=true pnpm lint (0 errors), check:changesets, check:knowledge, the Chromium interaction specs for ContextMenu, Layer, DropdownMenu, Dialog and Drawer (60 passed, 1 skipped), WebKit's ContextMenuLongPress, inputHold, preserveAnimations and DropdownMenuDragHighlight (35 passed, 8 skipped), and the ContextMenu, Layer and DropdownMenu a11y contracts (249 passed) ran at the stack's top, 6b4f1dc. Size (INV6), gzip: ContextMenu's import 85,035 → 85,016 B, DropdownMenu's unchanged (79,767 B), CSS unchanged. Red first: pointer-type filtering, the pan close (on each axis and at the threshold itself), the browser's cancel, the second finger, a new primary pointer after an unseen lift, the shadow-root judgment of a press and of a lift on the layer (in a closed root, in one inside another, and in a same-origin iframe's), a lift on an outer root's control that stops it, an unfollowed pointer's cancel, shadowHostOf for another window's root, and the pan or cancel close's dismissal count each fail before the change.

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 6, 2026 5:14pm UTC

Request Review

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

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 5:21 PM UTC

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 57f4e92

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Tokenizer 85.4 kB (+627 B) · 18.1 kB CSS (0) — — · — · —
DateTimeInput 195.9 kB (+549 B) · 18.1 kB CSS (0) — — · — · —
DateInput 173.4 kB (+524 B) · 16.4 kB CSS (0) — — · — · —
134 more components
Component Size LoC Visual · A11y · RTL
PowerSearch 271.7 kB (+516 B) · 23.1 kB CSS (0) — — · — · —
ChatComposer 68.8 kB (+509 B) · 11.2 kB CSS (0) — — · — · —
Typeahead 74.9 kB (+509 B) · 12.1 kB CSS (0) — — · — · —
ChatComposerTokenElement 66.9 kB (+505 B) · 10.6 kB CSS (0) — — · — · —
ChatComposerInput 66.9 kB (+504 B) · 10.6 kB CSS (0) — — · — · —
BaseTypeahead 62.5 kB (+493 B) · 10.3 kB CSS (0) — — · — · —
Lightbox 103.2 kB (+177 B) · 9.8 kB CSS (0) — — · — · —
TimeInput 149.9 kB (+158 B) · 15.5 kB CSS (0) — — · — · —
Slider 102.0 kB (+145 B) · 8.7 kB CSS (0) — — · — · —
AlertDialog 95.9 kB (+135 B) · 13.7 kB CSS (0) — — · — · —
Toast 89.4 kB (+115 B) · 9.6 kB CSS (0) — — · — · —
Switch 96.4 kB (+108 B) · 9.8 kB CSS (0) — — · — · —
SegmentedControl 89.6 kB (+41 B) · 6.8 kB CSS (0) — — · — · —
ButtonGroup 40.1 kB (+37 B) · 3.7 kB CSS (0) — — · — · —
Drawer 44.8 kB (+18 B) · 8.2 kB CSS (0) — — · — · —
ContextMenu 90.5 kB (+14 B) · 16.6 kB CSS (0) — — · — · —
SideNav 81.2 kB (+11 B) · 10.7 kB CSS (0) — — · — · —
TopNav 63.3 kB (+9 B) · 10.2 kB CSS (0) — — · — · —
TopNavHeading 66.3 kB (+9 B) · 10.4 kB CSS (0) — — · — · —
AvatarGroupOverflow 44.5 kB (+8 B) · 5.5 kB CSS (0) — — · — · —
ListItem 65.0 kB (+8 B) · 10.9 kB CSS (0) — — · — · —
AppShell 81.5 kB (+7 B) · 12.8 kB CSS (0) — — · — · —
FileInput 64.2 kB (+7 B) · 10.6 kB CSS (0) — — · — · —
SideNavHeading 66.8 kB (+7 B) · 10.6 kB CSS (0) — — · — · —
TopNavMegaMenu 61.1 kB (+7 B) · 11.2 kB CSS (0) — — · — · —
BreadcrumbItem 76.0 kB (+6 B) · 11.5 kB CSS (0) — — · — · —
ComplexSelector 65.9 kB (+6 B) · 11.3 kB CSS (0) — — · — · —
Link 48.8 kB (+6 B) · 6.9 kB CSS (0) — — · — · —
ChatDictationButton 52.3 kB (+5 B) · 9.4 kB CSS (0) — — · — · —
CheckboxInput 59.9 kB (+5 B) · 9.1 kB CSS (0) — — · — · —
CheckboxListItem 66.0 kB (+5 B) · 10.9 kB CSS (0) — — · — · —
InfoTip 43.3 kB (+5 B) · 5.3 kB CSS (0) — — · — · —
SideNavItem 61.7 kB (+5 B) · 10.4 kB CSS (0) — — · — · —
DropdownMenuSubMenu 64.7 kB (+4 B) · 9.4 kB CSS (0) — — · — · —
MobileNav 61.6 kB (+4 B) · 9.9 kB CSS (0) — — · — · —
TabMenu 63.2 kB (+4 B) · 10.4 kB CSS (0) — — · — · —
TextInput 62.8 kB (+4 B) · 10.8 kB CSS (0) — — · — · —
Thumbnail 56.1 kB (+4 B) · 10.9 kB CSS (0) — — · — · —
ChatLayout 55.0 kB (+3 B) · 9.8 kB CSS (0) — — · — · —
ChatSendButton 52.2 kB (+3 B) · 9.3 kB CSS (0) — — · — · —
NumberInput 66.9 kB (+3 B) · 11.1 kB CSS (0) — — · — · —
Step 29.8 kB (+3 B) · 5.6 kB CSS (0) — — · — · —
Stepper 53.4 kB (+3 B) · 9.5 kB CSS (0) — — · — · —
TableSelectionToolbar 61.3 kB (+3 B) · 12.4 kB CSS (0) — — · — · —
Avatar 39.5 kB (+2 B) · 5.5 kB CSS (0) — — · — · —
Banner 53.7 kB (+2 B) · 9.8 kB CSS (0) — — · — · —
Calendar 62.7 kB (+2 B) · 9.9 kB CSS (0) — — · — · —
MobileNavToggle 52.1 kB (+2 B) · 9.3 kB CSS (0) — — · — · —
ToggleButton 52.6 kB (+2 B) · 9.4 kB CSS (0) — — · — · —
TopNavMenu 66.0 kB (+2 B) · 10.5 kB CSS (0) — — · — · —
ChatLayoutScrollButton 52.2 kB (+1 B) · 9.5 kB CSS (0) — — · — · —
ChatMessageList 23.9 kB (+1 B) · 6.9 kB CSS (0) — — · — · —
CommandPalette 59.5 kB (+1 B) · 12.4 kB CSS (0) — — · — · —
DrawerHeader 54.7 kB (+1 B) · 12.2 kB CSS (0) — — · — · —
IconButton 51.7 kB (+1 B) · 9.3 kB CSS (0) — — · — · —
SelectorOption 19.5 kB (+1 B) · 5.8 kB CSS (0) — — · — · —
BottomSheet 58.6 kB (0) · 7.5 kB CSS (0) — — · — · —
BottomSheetSwitcher 17.5 kB (0) · 3.4 kB CSS (0) — — · — · —
Carousel 59.6 kB (0) · 10.3 kB CSS (0) — — · — · —
ChatToolCalls 27.8 kB (0) · 8.3 kB CSS (0) — — · — · —
ClickableCard 16.7 kB (0) · 7.1 kB CSS (0) — — · — · —
Collapsible 24.8 kB (0) · 4.5 kB CSS (0) — — · — · —
CommandPaletteItem 11.9 kB (0) · 3.6 kB CSS (0) — — · — · —
ContextMenuItem 26.8 kB (0) · 6.1 kB CSS (0) — — · — · —
DropdownMenuCheckboxItem 21.3 kB (0) · 6.4 kB CSS (0) — — · — · —
DropdownMenuItem 20.5 kB (0) · 6.0 kB CSS (0) — — · — · —
DropdownMenuRadioItem 21.3 kB (0) · 6.4 kB CSS (0) — — · — · —
FieldLabel 47.1 kB (0) · 5.6 kB CSS (0) — — · — · —
HoverCard 24.4 kB (0) · 4.3 kB CSS (0) — — · — · —
Item 16.1 kB (0) · 5.3 kB CSS (0) — — · — · —
LayerProvider 4.1 kB (0) · 20 B CSS (0) — — · — · —
Markdown 162.2 kB (0) · 19.5 kB CSS (0) — — · — · —
MoreMenu 86.3 kB (0) · 16.7 kB CSS (0) — — · — · —
Outline 23.7 kB (0) · 4.1 kB CSS (0) — — · — · —
Pagination 103.3 kB (0) · 17.3 kB CSS (0) — — · — · —
ProgressBar 11.5 kB (0) · 6.2 kB CSS (0) — — · — · —
RadioListItem 62.7 kB (0) · 9.8 kB CSS (0) — — · — · —
SegmentedControlItem 16.2 kB (0) · 4.3 kB CSS (0) — — · — · —
Spinner 20.5 kB (0) · 6.1 kB CSS (0) — — · — · —
StatusDot 27.6 kB (0) · 5.3 kB CSS (0) — — · — · —
Tab 13.3 kB (0) · 4.2 kB CSS (0) — — · — · —
Text 7.6 kB (0) · 5.3 kB CSS (0) — — · — · —
Timer 9.7 kB (0) · 5.9 kB CSS (0) — — · — · —
Timestamp 23.2 kB (0) · 9.6 kB CSS (0) — — · — · —
Token 27.4 kB (0) · 4.7 kB CSS (0) — — · — · —
TopNavItem 12.6 kB (0) · 4.0 kB CSS (0) — — · — · —
TopNavMegaMenuItem 12.7 kB (0) · 4.2 kB CSS (0) — — · — · —
CheckboxList 54.3 kB (−1 B) · 7.9 kB CSS (0) — — · — · —
CodeBlock 66.3 kB (−1 B) · 10.6 kB CSS (0) — — · — · —
CommandPaletteInput 27.9 kB (−1 B) · 7.5 kB CSS (0) — — · — · —
Dialog 22.5 kB (−1 B) · 6.7 kB CSS (0) — — · — · —
DialogHeader 54.5 kB (−1 B) · 10.8 kB CSS (0) — — · — · —
Heading 8.2 kB (−1 B) · 5.3 kB CSS (0) — — · — · —
Selector 93.2 kB (−1 B) · 16.9 kB CSS (0) — — · — · —
SideNavCollapseButton 52.6 kB (−1 B) · 9.4 kB CSS (0) — — · — · —
Tooltip 25.9 kB (−1 B) · 4.3 kB CSS (0) — — · — · —
TreeList 41.1 kB (−1 B) · 5.4 kB CSS (0) — — · — · —
DropdownMenu 86.1 kB (−2 B) · 16.7 kB CSS (0) — — · — · —
SelectableCard 16.9 kB (−2 B) · 7.3 kB CSS (0) — — · — · —
Button 51.7 kB (−3 B) · 9.3 kB CSS (0) — — · — · —
Table 99.9 kB (−3 B) · 17.3 kB CSS (0) — — · — · —
InputGroup 52.1 kB (−4 B) · 7.8 kB CSS (0) — — · — · —
TableHeaderCell 91.3 kB (−4 B) · 16.8 kB CSS (0) — — · — · —
AvatarGroup 42.2 kB (−5 B) · 5.6 kB CSS (0) — — · — · —
Field 50.5 kB (−5 B) · 7.2 kB CSS (0) — — · — · —
TableCell 91.7 kB (−5 B) · 17.0 kB CSS (0) — — · — · —
TextArea 57.9 kB (−5 B) · 9.2 kB CSS (0) — — · — · —
RadioList 51.9 kB (−6 B) · 7.2 kB CSS (0) — — · — · —
Popover 78.5 kB (−8 B) · 13.4 kB CSS (0) — — · — · —
MultiSelector 88.7 kB (−14 B) · 16.3 kB CSS (0) — — · — · —
DateRangeInput 85.8 kB (−18 B) · 12.1 kB CSS (0) — — · — · —
charts/Chart 176.0 kB (new) · 494 B CSS (new) — — · — · —
charts/ChartLegend 35.8 kB (new) · 224 B CSS (new) — — · — · —
charts/ChartTooltip 153.4 kB (new) · 423 B CSS (new) — — · — · —
lab/ChatEmojiPicker 96.5 kB (new) · 773 B CSS (new) — — · — · —
lab/ChatReactionBar 97.7 kB (new) · 1.1 kB CSS (new) — — · — · —
lab/CommentComposer 138.2 kB (new) · 1.9 kB CSS (new) — — · — · —
lab/CommentPin 52.7 kB (new) · 1.2 kB CSS (new) — — · — · —
lab/Comments 307.1 kB (new) · 2.5 kB CSS (new) — — · — · —
lab/CommentThread 302.0 kB (new) · 2.4 kB CSS (new) — — · — · —
lab/DiffViewer 78.1 kB (new) · 2.1 kB CSS (new) — — · — · —
lab/EmojiPicker 81.3 kB (new) · 751 B CSS (new) — — · — · —
lab/ListInput 95.4 kB (new) · 1.6 kB CSS (new) — — · — · —
lab/NavigationScreen 78.4 kB (new) · 1.5 kB CSS (new) — — · — · —
lab/NavigationStack 78.4 kB (new) · 1.5 kB CSS (new) — — · — · —
lab/PageControl 71.7 kB (new) · 709 B CSS (new) — — · — · —
lab/Schedule 99.4 kB (new) · 3.9 kB CSS (new) — — · — · —
lab/SelectableCardGroup 59.7 kB (new) · 162 B CSS (new) — — · — · —
lab/TourStep 84.7 kB (new) · 590 B CSS (new) — — · — · —
lab/TransferList 90.1 kB (new) · 1.7 kB CSS (new) — — · — · —
lab/TransferListSelector 97.1 kB (new) · 1.9 kB CSS (new) — — · — · —
richtext/RichTextEditor 290.9 kB (new) · 2.6 kB CSS (new) — — · — · —
richtext/RichTextEditorToolbar 244.1 kB (new) · 454 B CSS (new) — — · — · —
richtext/RichTextView 271.1 kB (new) · 2.3 kB CSS (new) — — · — · —

Package size: measured; main has no measurement of the merge base to compare yet.

API changes: cli +14 · ~5 · −0 (against the merge base's committed report)

Committed API report behind this build: cli 19 rows, core 24 rows; run pnpm api:report.

19 API changes (advisory)
Package Where Signature Usage
+ cli ./api · Types · type SearchResultEntry deprecated?: ComponentDeprecation | undefined import type {SearchResultEntry} from '@astryxdesign/cli/api'; const x: SearchResultEntry = {deprecated: …}
+ cli ./authoring · Types · interface ComponentDeprecation interface ComponentDeprecation import type {ComponentDeprecation} from '@astryxdesign/cli/authoring'
+ cli ./authoring · Types · interface ComponentIconSlotDoc interface ComponentIconSlotDoc import type {ComponentIconSlotDoc} from '@astryxdesign/cli/authoring'
+ cli ./authoring · Types · interface ComponentIconSlotStateDoc interface ComponentIconSlotStateDoc import type {ComponentIconSlotStateDoc} from '@astryxdesign/cli/authoring'
+ cli ./authoring · Types · interface MultiComponentDoc deprecated?: ComponentDeprecation import type {MultiComponentDoc} from '@astryxdesign/cli/authoring'; const x: MultiComponentDoc = {deprecated: …}
+ cli ./authoring · Types · interface SingleComponentDoc deprecated?: ComponentDeprecation import type {SingleComponentDoc} from '@astryxdesign/cli/authoring'; const x: SingleComponentDoc = {deprecated: …}
+ cli ./doc · Types · interface ComponentBaseDoc deprecated?: ComponentDeprecation import type {ComponentBaseDoc} from '@astryxdesign/cli/doc'; const x: ComponentBaseDoc = {deprecated: …}
+ cli ./doc · Types · interface SubComponentDoc deprecated?: ComponentDeprecation import type {SubComponentDoc} from '@astryxdesign/cli/doc'; const x: SubComponentDoc = {deprecated: …}
+ cli ./doc · Also exported from an earlier entry point · ComponentDeprecation ComponentDeprecation (./authoring) import {ComponentDeprecation} from '@astryxdesign/cli/doc'
+ cli ./doc · Also exported from an earlier entry point · ComponentIconSlotDoc ComponentIconSlotDoc (./authoring) import {ComponentIconSlotDoc} from '@astryxdesign/cli/doc'
+ cli ./doc · Also exported from an earlier entry point · ComponentIconSlotStateDoc ComponentIconSlotStateDoc (./authoring) import {ComponentIconSlotStateDoc} from '@astryxdesign/cli/doc'
+ cli ./template · Also exported from an earlier entry point · ComponentDeprecation ComponentDeprecation (./authoring) import {ComponentDeprecation} from '@astryxdesign/cli/template'
+ cli ./template · Also exported from an earlier entry point · ComponentIconSlotDoc ComponentIconSlotDoc (./authoring) import {ComponentIconSlotDoc} from '@astryxdesign/cli/template'
+ cli ./template · Also exported from an earlier entry point · ComponentIconSlotStateDoc ComponentIconSlotStateDoc (./authoring) import {ComponentIconSlotStateDoc} from '@astryxdesign/cli/template'
~ cli ./api · Functions · function search function search: (query: string, options?: { cwd?: string | undefined; type?: "template" | "component" | "hook" | "theme" | "doc" | undefined; limit?: number | undefined }) => Promise<SearchResponse> → function search: (query: string, options?: { cwd?: string | undefined; type?: "template" | "component" | "doc" | "theme" | "hook" | undefined; limit?: number | undefined }) => Promise<SearchResponse> import {search} from '@astryxdesign/cli/api'; search(…)
~ cli ./authoring · Types · interface MultiComponentDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {MultiComponentDoc} from '@astryxdesign/cli/authoring'; const x: MultiComponentDoc = {theming: …}
~ cli ./authoring · Types · interface SingleComponentDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {SingleComponentDoc} from '@astryxdesign/cli/authoring'; const x: SingleComponentDoc = {theming: …}
~ cli ./doc · Types · interface ComponentBaseDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {ComponentBaseDoc} from '@astryxdesign/cli/doc'; const x: ComponentBaseDoc = {theming: …}
~ cli ./doc · Types · interface SubComponentDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {SubComponentDoc} from '@astryxdesign/cli/doc'; const x: SubComponentDoc = {theming: …}

Wide change (shared styles: packages/core/src/theme/derivedVarRegistry.test.ts, packages/core/src/theme/derivedVarRegistry.ts, packages/core/src/theme/generateThemeRules.test.ts, and 1 more): add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.

CI impact: +16.9 job-min per update · pool +14% · time to green −10.3 min · standard runners · 5 CI action or script files changed (not measured) ⚠️
Before After Change
Job-minutes per PR update 116.8 133.7 +16.9
Runner pool, runner-minutes a day 53,692 61,463 +7,771 (+14%)
Average concurrent jobs 37.3 42.7 +5.4
Time to green (median → this run) 28.3 min 17.9 min −10.3 min
Runs a day 460 460 0

Changed jobs: pr-interaction changed: standard · pr-interaction-webkit changed: standard

Not measured (the jobs that use these keep their recent minutes above): .github/scripts/ci-interaction-webkit.test.mjs, .github/scripts/ci-interaction.test.mjs, .github/scripts/playwright-legs.mjs, .github/scripts/playwright-legs.test.mjs, .github/scripts/release-publish-workflow.test.mjs.

Flags: runner-pool usage +14%.

Assumptions: before is each job's average over the last 20 pull-request runs; after keeps that for unchanged jobs and uses this run's minutes for added or changed ones, weighted by how often each changed job ran in those runs; runs a day averages the seven whole days before today; time to green compares this run with the median of those runs that ran the same jobs.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for one fix in the new root option.

  • In a closed shadow root, root judges only where a press starts, so a press that starts outside the layer and lifts on it closes the layer while the same press leaves a light-DOM or open-root layer open; judge the lift at the root as well (for example, register handleUp on root and, at the document, skip a lift whose composed path holds root.host) and add a test for that press.

Non-blocking: handleCancel does not check pointerTypes when closesOnPan is off, and the onOutside JSDoc and the file header still describe only the lift-and-cancel model. The pan close, its 10 px threshold on either axis, the second-finger handling and the unchanged default watches all check out.

[Automated review]

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
Comment on lines +119 to +120
const judges: EventTarget[] =
root != null && root !== doc ? [doc, root] : [doc];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only pointerdown is judged at root; pointerup (line 129) stays on the document, which sees a closed root's contents as its host. So, with root set, a press that starts outside the layer and lifts on it closes a layer in a closed root but not one in the light DOM or an open root (with and without closesOnPan). Consider registering handleUp on root too and, at the document, skipping a lift whose composed path holds root.host, plus a test for that press.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in bef1d72, red first in b65071d (with and without closesOnPan). The document and the host see a lift inside a closed root as a lift on the host, so the root judges a lift inside it on the way down and the host judges, on the way back up, one the root did not: a lift on the host itself. The host comes from a new shadowHostOf in utils/flatTree.

};
const handleCancel = () => {
startedOutside = false;
const handleCancel = (event: PointerEvent) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: unlike handleDown and handleUp, this does not check follows(event). Without closesOnPan, a pointercancel from an unfollowed type (a pen while pointerTypes is ['touch']) clears a followed touch's press, so its tap outside no longer closes the layer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in bef1d72, with a red case in b65071d: pointercancel now follows the watch's pointer types, as its press and lift do.

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
): () => void {
const doc = owner.ownerDocument;
let startedOutside = false;
// The pointer a pan follows, and where it went down.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): a name can carry this, for example let panStart: {id: number; x: number; y: number} | null, and the comment can go. The onOutside JSDoc above ("Called once for each press that starts and ends outside") also needs the closesOnPan case: "With closesOnPan, also when that press pans or is cancelled."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in bef1d72: panStart, and the onOutside JSDoc and the file header name the pan close.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for two fixes in the new root option.

  • In a closed shadow root that sits inside another closed shadow root, the document sees a press as a press on the outer host, so the document's skip test and the host listener, which use the root's own host, miss it and a press that starts outside the layer and lifts on it closes the layer; use the outermost shadow host for both and add a nested case, since ContextMenu judges nested closed roots today.
  • shadowHostOf tests instanceof ShadowRoot, which is false for a shadow root from a same-origin iframe or a Picture-in-Picture window, so the root's lift listener is never added and the same press closes the layer there; check by nodeType, as this module's isShadowRoot does, and add an iframe case.

Non-blocking: the closesOnPan JSDoc says only the first pointer counts, but a second finger outside is followed when the first went down inside the layer, and the move subscription could start at the outside press instead of with the watch. The single closed-root lift fix, the pan close at 10 px on either axis, the pointer-type filter and the unchanged default watches check out.

[Automated review]

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
// The host judges, on the way back up, a lift the root did not judge.
let liftJudgedInRoot: Event | null = null;
const handleUp = (event: PointerEvent) => {
if (host == null || !event.composedPath().includes(host)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When root is a closed root nested inside another closed root, the document sees this lift as a lift on the outer host, so includes(host) is false and the document judges a lift on the layer as outside. Walking host.getRootNode() up to the outermost shadow host, and using that host here and for the host listener, keeps a press that starts outside and lifts on the layer from closing it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a7ded9d, red first in cf85ccf: a new outermostShadowHostOf in utils/flatTree walks up to the host the document sees, and both the document's skip test and the host listener use it. The nested test also checks that a lift on the outer root's own content still closes.

Comment thread packages/core/src/utils/flatTree.ts Outdated

/** The host of a shadow root, open or closed, and null for any other node. */
export function shadowHostOf(node: Node): Element | null {
return node instanceof ShadowRoot ? node.host : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

instanceof ShadowRoot is false for a shadow root from a same-origin iframe or a Picture-in-Picture window, so watchOutsidePress gets a null host and never adds the root's pointerup listener. node.nodeType === 11 && 'host' in node, as outsidePress.ts's isShadowRoot does, works across realms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a7ded9d: shadowHostOf checks the node type and a host property, so a same-origin iframe's root has a host. The new iframe test, red first, also checks that a lift outside still closes there.

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
/**
* A press outside also closes the layer as it begins to pan, or when the
* browser takes it. An anchored menu closes so under a finger. Only the
* first pointer counts, and a second one waits for it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: when the first pointer went down inside the layer, or before the watch started, a second finger outside is followed and closes the layer as it pans, so "only the first pointer counts" is not quite true. Suggested: "A press outside also closes the layer as it begins to pan, or when the browser takes it. While one press outside is followed, other pointers are ignored."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in a7ded9d. The move subscription stays with the watch: it shares the window's one input listener, and returns at once while no press outside is followed.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for two fixes in the new root option.

  • In a closed root nested inside another, a lift on the outer root's content is judged only by the outermost host's bubble-phase listener, so a control there whose pointerup handler stops propagation leaves the layer open, and with closesOnPan the stale press then makes the watch ignore every later touch; judging those lifts in the capture phase (the same listener on each closed root between root and the outermost host, each skipping a lift whose path holds the host below it) and a test that taps such a control would fix it.
  • The new shadowHostOf in utils/flatTree repeats the host check that isShadowRoot already does in outsidePress.ts, so isWithinLayer should walk with shadowHostOf and the private isShadowRoot should go, leaving one realm-safe check.

Non-blocking: a few comments could follow the comment style more closely (the "An anchored menu closes so under a finger" sentence, the "on the way down / back up" idioms, and the long file header). The nested and other-window host lookups, the pan close at 10 px on either axis, the pointer-type filter and the unchanged default watches check out.

[Automated review]

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
doc.addEventListener('pointerup', handleUp, true);
if (host != null) {
root?.addEventListener('pointerup', handleRootUp as EventListener, true);
host.addEventListener('pointerup', handleHostUp as EventListener);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In nested closed roots this bubble-phase listener is the only judge of a lift on the outer root's content, so a control there whose pointerup handler calls stopPropagation() keeps the lift from reaching it: the layer stays open, and with closesOnPan the stale panStart makes the watch ignore every later touch. Judging those lifts in the capture phase, with the same listener on each closed root between root and the outermost host (each skipping a lift whose path holds the host of the root below), and keeping this listener for a lift on the host itself, fixes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9461404, red first in 94299af: the document and every shadow root out to it judge a lift in the capture phase, each skipping a lift that goes into the root below it, and a host judges only a lift on itself. The new test taps a control in the outer root that stops its pointerup, then checks that the next press still closes. A new primary pointer also starts a new press, so a lift that never arrives cannot leave the watch stuck.

Comment on lines +47 to +51
export function shadowHostOf(node: Node): Element | null {
return node.nodeType === node.DOCUMENT_FRAGMENT_NODE && 'host' in node
? (node as ShadowRoot).host
: null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isWithinLayer in Layer/outsidePress.ts still walks with its private isShadowRoot(current) ? current.host : null, the same check as this helper. Using current.parentNode ?? shadowHostOf(current) there and deleting isShadowRoot leaves one realm-safe check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 9461404: isWithinLayer walks with shadowHostOf, and the private isShadowRoot is gone.

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
readonly pointerTypes?: ReadonlyArray<string>;
/**
* A press outside also closes the layer as it begins to pan, or when the
* browser takes it. An anchored menu closes so under a finger. While one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): "An anchored menu closes so under a finger." is vague and names a caller; the sentences around it already say what the option does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 9461404.

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
Comment on lines +118 to +120
// The document sees a lift inside closed roots as a lift on the outermost
// host, and so does that host. So the root judges a lift inside it on the way down.
// The host judges, on the way back up, a lift the root did not judge.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): "on the way down" and "on the way back up" are idioms. Suggested: "The document and the outermost host see a lift inside closed roots as a lift on that host. So the root judges a lift inside it in the capture phase. The host judges only a lift the root did not judge."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in 9461404, reworded for the capture-phase judges on every root.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for one fix.

  • shadowRootsOutward in utils/flatTree walks the same shadow-root chain as openShadowRootsOf in utils/selectionLock (whose host lookup for text under a shadow root also repeats shadowHostOf), so the selection lock should call the two shared helpers, filtering to open roots, and drop its own copies; that change keeps today's behavior.

The capture-phase lift judgment on every closed root and the new-primary-pointer rule resolve the last round's findings. Non-blocking: the comment above shadowRootsOutward(root) in outsidePress.ts repeats the helper's JSDoc and can go.

[Automated review]

* The shadow roots from `node` out to its document, innermost first, each with
* its host. Empty when `node` is not a shadow root.
*/
export function shadowRootsOutward(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

openShadowRootsOf in utils/selectionLock.ts (lines 565-583) walks this same chain innermost first and keeps the open roots, and its lookup at lines 277-282 repeats shadowHostOf. Having the selection lock call these two helpers (filtering to open roots) and deleting its copies leaves one walk, with no behavior change:

function openShadowRootsOf(target: EventTarget | null): ShadowRoot[] {
  if (target == null || typeof (target as Node).nodeType !== 'number') {
    return [];
  }
  return shadowRootsOutward((target as Node).getRootNode())
    .map(({root}) => root)
    .filter(root => root.mode === 'open');
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in abee181: the selection lock's open-root walk keeps the open roots of shadowRootsOutward, and its host lookup calls shadowHostOf. Its tests pass unchanged.

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
{pointerTypes, closesOnPan = false, root = null}: OutsidePressOptions = {},
): () => void {
const doc = owner.ownerDocument;
// The shadow roots from `root` out to the document, innermost first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this restates shadowRootsOutward's name and its JSDoc; it can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in abee181.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking findings: the three opt-in options for the outside-press watch are internal and passed by no component yet, and the selection lock now calls the shared shadowHostOf and shadowRootsOutward helpers, which resolves the last round's finding.

The pan close at 10 px on either axis, the pointer-type filter and the closed-shadow-root judgment each have tests that fail before the change, and the existing callers' suites pass.

  • Non-blocking: the outsidePress.ts file header grows to 20 lines and its prose still says a cancelled press is forgotten, which no longer holds with closesOnPan; trimming it to its tags (see the inline note) fixes both.

[Automated review]

Comment thread packages/core/src/Layer/outsidePress.ts Outdated
Comment on lines +7 to +9
* @input Listens for pointer presses on a document, and on the shadow roots
* around a given root; with closesOnPan, for a pan's moves through core's
* input stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this header is now 20 lines, past the 12-line budget, and the prose below it still says a press closes "only when it starts and ends outside" and that a pointercancel "forgets it", which is false with closesOnPan. watchOutsidePress's JSDoc already states the rule, so the header can keep only its tags:

/**
 * @file outsidePress.ts
 * @input Pointer presses on a document and on the shadow roots around a
 *   given root; with closesOnPan, pan moves from core's input stream
 * @output watchOutsidePress; useOutsidePress, one watch per component; and
 *   the inside tests isPressInsideOwnedLayer and isFocusInsideOwnedLayer
 * @position Internal to Layer: the outside-press dismissal for a manual
 *   popover whose owner decides what counts as inside. Not exported.
 */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 3e95544: the header keeps only its tags, and onOutside's @PARAM states the rule and its reason.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested for one wording fix; the new options, their tests and the unchanged no-options path look right.

  • Miss: the title and changeset say the watch can now "judge a press inside a shadow root", but a press inside an open shadow root was already judged correctly before this change and every new test uses a closed root, so say "closed shadow root" in both (for example "…and judge a press inside a closed shadow root, which the document sees as its host").

Non-blocking: the outsidePress.ts file header is still 20 lines, and its prose says a cancelled press is forgotten, which no longer holds with closesOnPan; see the earlier inline note on line 9.

[Automated review]

Comment thread .changeset/outside-press-pan-watch.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: the layer's outside-press watch can follow chosen pointer types, close as a press that starts outside begins to pan (on either axis, at core's pan threshold) or when the browser takes it, and judge a press inside a shadow root there. No component passes these options yet, so every watch keeps today's behavior. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (claims match scope): a press inside an open shadow root was already judged as inside before this change, through the event's composed path, and every new test attaches a closed root. Suggested wording, here and in the title: "…or when the browser takes it, and judge a press inside a closed shadow root, which the document sees as its host."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 3e95544: "…and judge a press inside a closed shadow root, which the document sees as its host." The title says so too.

@vjeux vjeux changed the title chore(Layer): the outside-press watch can close on a pan, follow chosen pointer types, and judge a press in a shadow root chore(Layer): the outside-press watch can close on a pan, follow chosen pointer types, and judge a press in a closed shadow root Nov 4, 2026
@vjeux
vjeux force-pushed the uikit/c20-outside-pan-watch branch from ad8e81b to 3e95544 Compare November 4, 2026 11:11

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet.

The title and changeset now say "closed shadow root", which resolves the last review's only blocking finding, and the outsidePress.ts header keeps only its tags. The three options stay opt-in and no component passes them yet, so today's outside-press dismissal is unchanged.

Next step: CI skips drafts and unlabelled forks, so mark the pull request ready for review and have a maintainer add the ci:run label (it takes effect on the next push, or a close and reopen) so CI runs.

[Automated review]

cixzhang and others added 19 commits November 6, 2026 05:34
* [Component Audit] Kbd: key badges grow with larger text

* Kbd audit spec: measure contrast on the composited badge surface

* Kbd audit: self-review follow-ups

* Kbd: keep only the reason in the badge height comment
…meta#10269)

test(cli): remove packed-CLI fixtures once per run, not in file hooks

Two node suites removed thousands of files in a 30 s per-file hook, and
under a full node run that removal can outlast the hook. The
packed-old-core afterAll removed a 3,123-file copy of the CLI. The
global-local afterAll removed the unpacked CLI and every pooled copy,
about 12k entries.

The packed-old-core fixture now holds the files `npm pack` puts in the
CLI's tarball, not five whole folders. That drops about 460 test files
npm never ships, and adds `host`, which it does ship. Three published
asset folders that theme build never reads (templates, docs and icons,
1,896 files) are one link each to the package's own folder, or a copy
where links are refused. So the fixture writes about 680 files.

The node project's global setup now gives each run a folder. The
unpacked CLI, its pooled copies and the packed-old-core fixture are
made in it, and no per-file hook removes them. The global teardown
moves the folder aside, which takes no time, and a detached child
removes it, so close() never waits on a delete either. The next run's
setup sweeps any trash that a stopped child left.

When a test writes into a file that every placed copy shares, the
fixtures now unpack only the changed paths again, not all 2,581 files.
That repair runs inside the test, which a busy machine could make
outlast its 30 s. The fixtures suite also unpacks the CLI in its own
setup, as global-local does, not inside its first test.

Tests cover the pack-list fixture, a teardown that never deletes
through a link, the copy fallback, the run folder, the background
removal and the sweep. The shared-file test covers the repair.
… and search focusable (meta#10192)

* test(TransferListSelector): a disabled selector keeps its list controls focusable and blocks changes (red)

* fix(TransferListSelector): a disabled selector keeps its list controls focusable

The list fieldset no longer takes native disabled, which blurred a focused
control when the selector became disabled while open. It carries
aria-disabled, every row's transfer and reorder controls take their own
focusable disabled state, and changes are ignored (spec:AST-085 FR1-FR4).

* fix(TransferList): one isDisabled state disables every control focusably, including search

TransferList takes isDisabled: every option is treated as fixed without its
own constraint message, and the search field is disabled but focusable.
TransferListSelector passes its disabled state down instead of rewriting
options.

* fix(TransferList): a list disabled mid-reorder ends the grab without any change

finishReorder clears the session without committing while the list is
disabled, so neither a cancel restore nor a pointer drop reaches onChange,
and the grip shows no pressed state while disabled.

* Merge main into lane4/transfer-selector-focusable-disabled
…ta#10264)

test(core): six date and time cases pin each CLDR version's words

CLDR 47 (ICU 77, as in Node 24.13) and CLDR 48 (ICU 78, as in Node
24.22) write some day-period, weekday and era names differently. Six
cases hard-coded CLDR 48's text, so they failed on every run under
ICU 77:

- timeFormat: the Assamese afternoon, "PM" in CLDR 47 and "অপৰাহ্ন" in
  CLDR 48.
- calendarDateParser: the Vietnamese short Tuesday, "Th 3" and "Thứ 3";
  the English Amete Alem era, "ERA0" and "AA".
- calendarDateParser.roundtrip: the Amharic narrow Minguo eras, "R.O.C."
  and "ROC", "Before R.O.C." and "BROC".
- DateInputTouch.calendar: the English Ethiopic era in two pane labels,
  "ERA1" and "AM".

Each case now expects the literal for the runtime's CLDR version
(process.versions.cldr). Any other version expects CLDR 48's, so new
locale data fails loudly. The cases that read text also check that the
formatter writes the same literal.
…s close (meta#8186)

* chore(core): the layer runtime's exit copy, for a touch presentation's close

placeLayerExitCopy puts a static, inert copy of a closing layer where the
layer was drawn, in the top layer, for a touch presentation's close to play
on: the browser hides a popover the moment it closes. The copy keeps the
layer's box, transform, presented look, overflow and scroll positions; has
no ids, anchor names, form names or form owner; is inert and hidden from assistive
technology; and its descendants' keyframe animations and transitions do not
start again. A refused show during the closing toggle waits, hidden, until
the toggle is over. A layer inside other layers gets its copy after the
outermost one, with what it inherited there (its color scheme included),
so a close that takes the layers around it too still draws the copy. That
copy is shown again above the copy the same close then places for a layer
around it. A layer has one copy. The layer-runtime record states
it, and that the presentation plays its exit on the copy (AST-094 FR7).

Core's shared inertCopy now takes every copied native control and
form-associated custom element out of any form with form="", as its
documentation says, so the swap's copies join no form either. It also
sets anchor-name: none on each copied element that has an anchor name, so
no live anchor gets a second element.

* Merge branch 'main' into uikit/c26-layer-exit-copy

* Merge branch 'main' into uikit/c26-layer-exit-copy

* fix(core): the exit copy keeps no closed layer

The exit copy found the inner layers to raise through a module-level
set of every copied layer. A layer React removed on close stayed
reachable there until a later close showed a copy. The inner layers
are inside the closing layer, so they are now found there, and
nothing keeps a layer once it is gone.

Chromium 150 and later refuse a show during another popover's toggle,
so the copy can open in the next frame. The browser spec's
light-dismiss case now reads the copy after a frame, as its
nested-close cases do. The cap check's comment now names only the
copy cap.

* docs(core): the exit copy's JSDoc states the empty-canvas rule once

The empty-canvas rule stays in the first paragraph, and the inert-copy
paragraph no longer repeats it or `inertCopy`'s own contract.

* Merge main into the layer exit copy branch (layer-runtime.md verification table keeps both rows)

* Merge main into the layer exit copy branch (meta#8147 landed)

* Merge main into the layer exit copy branch (placeOver's beforeNode beside keepInherited)

* Merge main into the layer exit copy branch (meta#10224 landed)

* fix(core): the exit copy reads its host's style before the insertion

keepInherited read the outer layer's parent's computed style right
after the copy was inserted there: a style read after a write. It
reads only that host and writes only on the still-detached copy, so it
now runs before the insertion. What the copy inherits is unchanged.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…ta#10289)

docs(AST-059): anchored layers re-pick once after scrolling stops
…ed (meta#10274)

perf(cli): list each folder once per read, and reuse it while unchanged

Every folder a search, build or component read walks now lists through
the listing cache: the docs, template, theme, icon and package walkers
read it once per operation, where some read a folder twice or outside
the cache. A later operation in the same process reuses a folder's
listing while its stamp (size, times, inode) is the one it was kept
with. A listing is kept only after its stamp has held for two seconds
of this process's clock, so a change within one tick of a coarse file
system clock still gets a new read. A reuse check records the folder
before its stamp is taken, so a change between the two shows.
…root (meta#7931)

* docs(spec): ref-first DOM styling target with explicit exemptions

* Merge main into spec/ref-first-dom-style-props

* docs(spec): AST-089 lists BottomSheetSwitcher as an exception, drops the bound TopNavMenu drawer-header ref, and words Icon's split

BottomSheetSwitcher applies its dialog surface to every step's sheet
dialog, so it joins the target-preservation exemptions. TopNavMenu and
TopNavMegaMenu drawer headers bind their ref, so L10 keeps only SideNav.
Icon styles its span and binds its ref to the svg.

* docs(spec): AST-089 covers the Popover and PowerSearch sheet presentations, names the claims promotion amends, and collapses OQ1

E6 and E7 add the sheet paths of component:Popover DEC-8 and
component:PowerSearch DEC-3. FR6 puts input-fields FR10 components outside
the default. Current-state impact names public-component-api INV15 and
AST-071 FR1. L7 says VisuallyHidden ignores xstyle; L8 says Icon's ref
reaches the svg and its wrapper is unpromised. OQ1's choice moves into DEC-1.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…ut (meta#10281)

* chore(Carousel): the wheel listener's comment names a held row's reads

While a flick holds the row, the listener's move reads the row's stops
as well as its direction. The comment now says so.

* chore(Carousel): the wheel listener's comment says when it reads layout

While a flick holds the row, the listener's move reads the row's stops,
its direction and the theme's motion tempo. The comment now states only
that the listener reads no style or layout unless a flick holds the row.
…nested providers correctly (meta#10283)

* [Component Audit] LayerProvider: its docs reach editors and describe nested providers correctly

* LayerProvider audit: self-review follow-ups
…s back into it (meta#8187)

* chore(core): the layer runtime's exit copy, for a touch presentation's close

placeLayerExitCopy puts a static, inert copy of a closing layer where the
layer was drawn, in the top layer, for a touch presentation's close to play
on: the browser hides a popover the moment it closes. The copy keeps the
layer's box, transform, presented look, overflow and scroll positions; has
no ids, anchor names, form names or form owner; is inert and hidden from assistive
technology; and its descendants' keyframe animations and transitions do not
start again. A refused show during the closing toggle waits, hidden, until
the toggle is over. A layer inside other layers gets its copy after the
outermost one, with what it inherited there (its color scheme included),
so a close that takes the layers around it too still draws the copy. That
copy is shown again above the copy the same close then places for a layer
around it. A layer has one copy. The layer-runtime record states
it, and that the presentation plays its exit on the copy (AST-094 FR7).

Core's shared inertCopy now takes every copied native control and
form-associated custom element out of any form with form="", as its
documentation says, so the swap's copies join no form either. It also
sets anchor-name: none on each copied element that has an anchor name, so
no live anchor gets a second element.

* Merge branch 'main' into uikit/c26-layer-exit-copy

* feat(Popover): under a finger, it grows out of its trigger and shrinks back into it

Under a coarse primary pointer, Popover grows out of its trigger on the
theme duration step nearest iOS's growth (`--duration-medium-min`) and the
theme's entrance easing, and shrinks back into it on the theme's exit
duration and easing, on the layer runtime's exit copy. A fine primary
pointer keeps the layer's own entrance.

* Merge branch 'main' into uikit/c26-layer-exit-copy

* Merge the updated lower stack branch into uikit/c26-popover-present (own diff unchanged)

* feat(Popover): a touch during the growth is ignored

While the popover grows, the layer runtime's input hold takes every touch,
as UIKit ignores touches while it presents a popover. A tap outside does not
dismiss it and reaches nothing under it, and a pinch still zooms the page.
This folds in the hold that meta#8223 carried, so the growth and its hold land
together.

* fix(core): the exit copy keeps no closed layer

The exit copy found the inner layers to raise through a module-level
set of every copied layer. A layer React removed on close stayed
reachable there until a later close showed a copy. The inner layers
are inside the closing layer, so they are now found there, and
nothing keeps a layer once it is gone.

Chromium 150 and later refuse a show during another popover's toggle,
so the copy can open in the next frame. The browser spec's
light-dismiss case now reads the copy after a frame, as its
nested-close cases do. The cap check's comment now names only the
copy cap.

* docs(core): the exit copy's JSDoc states the empty-canvas rule once

The empty-canvas rule stays in the first paragraph, and the inert-copy
paragraph no longer repeats it or `inertCopy`'s own contract.

* Merge the updated lower stack branch into uikit/c26-popover-present (own diff unchanged)

* Merge main into the layer exit copy branch (layer-runtime.md verification table keeps both rows)

* Merge the updated lower stack branch into uikit/c26-popover-present (own diff unchanged)

* Merge main into the layer exit copy branch (meta#8147 landed)

* Merge the updated lower stack branch into the Popover branch (meta#8147 landed; uikit index header lists popover and zoomMedia)

* Merge the updated lower stack branch into the Popover branch

* Merge main into the layer exit copy branch (placeOver's beforeNode beside keepInherited)

* Merge the updated lower stack branch into the Popover branch (meta#10224 landed)

* Merge main into the layer exit copy branch (meta#10224 landed)

* feat(Popover): the touch presentation starts in the next frame

The growth and its input hold now start on the first frame after the
open, not in the task that opens the popover, so the opening tap's
task lays out no more than a mouse opening does. Folded from meta#8662, so
the group's squashes apply in order: these lines are this pull
request's own.

* docs(Popover): record the next-frame window with the code

The input hold starts on the first frame after the open, so a touch that
lands within that frame reaches the page. spec:AST-094 and Popover's
record state that window here, beside the code that opens it. Moved from
meta#8662.

* fix(core): the exit copy reads its host's style before the insertion

keepInherited read the outer layer's parent's computed style right
after the copy was inserted there: a style read after a write. It
reads only that host and writes only on the still-detached copy, so it
now runs before the insertion. What the copy inherits is unchanged.

* Merge the updated lower stack branch into the Popover branch

* perf(Popover): the growth reads its boxes and timing before it writes

The frame that starts a touch presentation wrote the CSS entrance off
and the start scale, read both boxes, wrote the origin and the start
frame, then read the theme's entrance tokens. Each read after a write
restyled or laid the page out again. It now reads both boxes and the
entrance timing in one pass, then writes, the start frame first: the
motion engine may read the element's will-change as it sets it. The
box needs no reset first: the opening task already took the CSS
entrance off, and a dismissal leaves the element at full size.

Shown again during the dismissal, the popover reads its entrance
timing before its first write too.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…rink (meta#9823)

* chore(core): the layer runtime's exit copy, for a touch presentation's close

placeLayerExitCopy puts a static, inert copy of a closing layer where the
layer was drawn, in the top layer, for a touch presentation's close to play
on: the browser hides a popover the moment it closes. The copy keeps the
layer's box, transform, presented look, overflow and scroll positions; has
no ids, anchor names, form names or form owner; is inert and hidden from assistive
technology; and its descendants' keyframe animations and transitions do not
start again. A refused show during the closing toggle waits, hidden, until
the toggle is over. A layer inside other layers gets its copy after the
outermost one, with what it inherited there (its color scheme included),
so a close that takes the layers around it too still draws the copy. That
copy is shown again above the copy the same close then places for a layer
around it. A layer has one copy. The layer-runtime record states
it, and that the presentation plays its exit on the copy (AST-094 FR7).

Core's shared inertCopy now takes every copied native control and
form-associated custom element out of any form with form="", as its
documentation says, so the swap's copies join no form either. It also
sets anchor-name: none on each copied element that has an anchor name, so
no live anchor gets a second element.

* Merge branch 'main' into uikit/c26-layer-exit-copy

* feat(Popover): under a finger, it grows out of its trigger and shrinks back into it

Under a coarse primary pointer, Popover grows out of its trigger on the
theme duration step nearest iOS's growth (`--duration-medium-min`) and the
theme's entrance easing, and shrinks back into it on the theme's exit
duration and easing, on the layer runtime's exit copy. A fine primary
pointer keeps the layer's own entrance.

* test(Popover): browser proofs of the touch presentation's grow and shrink

Real-browser proofs, in Chromium and WebKit, that under a finger Popover
grows out of its trigger on the theme's entrance tokens and shrinks back on
its exit tokens, keeps advancing through a blocked main thread, does no
Layout or Paint per frame, and cross-fades under Reduce Motion, with
storyboards of both.

* Merge branch 'main' into uikit/c26-layer-exit-copy

* Merge the updated lower stack branch into uikit/c26-popover-present (own diff unchanged)

* Merge the updated lower stack branch into uikit/c27-popover-present-proofs (own diff unchanged)

* Merge meta#8187's input hold into the Popover touch proofs

* feat(Popover): a touch during the growth is ignored

While the popover grows, the layer runtime's input hold takes every touch,
as UIKit ignores touches while it presents a popover. A tap outside does not
dismiss it and reaches nothing under it, and a pinch still zooms the page.
This folds in the hold that meta#8223 carried, so the growth and its hold land
together.

* test(Popover): browser proofs of the hold while the popover grows

A tap outside, a tap on the trigger and a pan while the popover grows do
nothing, a pinch still zooms, and a storyboard shows the growth going on
under a tap outside. Folded from meta#8223.

* fix(core): the exit copy keeps no closed layer

The exit copy found the inner layers to raise through a module-level
set of every copied layer. A layer React removed on close stayed
reachable there until a later close showed a copy. The inner layers
are inside the closing layer, so they are now found there, and
nothing keeps a layer once it is gone.

Chromium 150 and later refuse a show during another popover's toggle,
so the copy can open in the next frame. The browser spec's
light-dismiss case now reads the copy after a frame, as its
nested-close cases do. The cap check's comment now names only the
copy cap.

* docs(core): the exit copy's JSDoc states the empty-canvas rule once

The empty-canvas rule stays in the first paragraph, and the inert-copy
paragraph no longer repeats it or `inertCopy`'s own contract.

* Merge the updated lower stack branch into uikit/c26-popover-present (own diff unchanged)

* Merge the updated lower stack branch into uikit/c27-popover-present-proofs (own diff unchanged)

* test(Popover): the blocked-main-thread proof blocks from the page and asserts the verdict

The page now starts the block once the growth has run a frame, and the
proof asserts the harness verdict, which fails a judged part under 4
frames. Here the growth runs 600 ms at a constant speed, so each frame
moves several pixels and a block that starts late on a loaded machine
still lands inside it.

* Merge main into the layer exit copy branch (layer-runtime.md verification table keeps both rows)

* Merge the updated lower stack branch into uikit/c26-popover-present (own diff unchanged)

* Merge the updated lower stack branch into uikit/c27-popover-present-proofs (own diff unchanged)

* Merge main into the layer exit copy branch (meta#8147 landed)

* Merge the updated lower stack branch into the Popover branch (meta#8147 landed; uikit index header lists popover and zoomMedia)

* Merge the updated lower stack branch into the Popover branch (meta#8147 landed; uikit index header lists popover and zoomMedia)

* Merge the updated lower stack branch into the Popover branch

* Merge main into the layer exit copy branch (placeOver's beforeNode beside keepInherited)

* Merge the updated lower stack branch into the Popover branch

* Merge the updated lower stack branch into the Popover branch (meta#10224 landed)

* Merge main into the layer exit copy branch (meta#10224 landed)

* Merge meta#8187 (next-frame start folded in) into the Popover touch proofs

* feat(Popover): the touch presentation starts in the next frame

The growth and its input hold now start on the first frame after the
open, not in the task that opens the popover, so the opening tap's
task lays out no more than a mouse opening does. Folded from meta#8662, so
the group's squashes apply in order: these lines are this pull
request's own.

* test(Popover): browser proofs of the next-frame start

The touch-motion spec's cases for the growth that starts on the first
frame after the open, folded from meta#8662 with the change they prove.

* docs(Popover): record the next-frame window with the code

The input hold starts on the first frame after the open, so a touch that
lands within that frame reaches the page. spec:AST-094 and Popover's
record state that window here, beside the code that opens it. Moved from
meta#8662.

* Merge meta#8187 (next-frame records moved in) into the Popover touch proofs

* fix(core): the exit copy reads its host's style before the insertion

keepInherited read the outer layer's parent's computed style right
after the copy was inserted there: a style read after a write. It
reads only that host and writes only on the still-detached copy, so it
now runs before the insertion. What the copy inherits is unchanged.

* Merge the updated lower stack branch into the Popover branch

* perf(Popover): the growth reads its boxes and timing before it writes

The frame that starts a touch presentation wrote the CSS entrance off
and the start scale, read both boxes, wrote the origin and the start
frame, then read the theme's entrance tokens. Each read after a write
restyled or laid the page out again. It now reads both boxes and the
entrance timing in one pass, then writes, the start frame first: the
motion engine may read the element's will-change as it sets it. The
box needs no reset first: the opening task already took the CSS
entrance off, and a dismissal leaves the element at full size.

Shown again during the dismissal, the popover reads its entrance
timing before its first write too.

* Merge the updated lower stack branch into the Popover touch proofs

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…utes each (meta#10258)

* ci: the Chromium interaction proofs run in as few legs as fit ten minutes each, from re-recorded times

* ci: the leg count costs an unrecorded spec by its tests at the lane's mean time a test, and the recorded Chromium times add TabBar and three newer specs

* ci: the Chromium legs' matrix holds six, since main's merge ref already plans four

* ci: the plans cost files as their counts do, an unreadable spec stops the leg, and a test pins the Chromium count's arguments

* ci: the plan reads the test counts the leg count wrote, since the head checkout has replaced the scope checkout by then

* ci: a changed spec the merge commit no longer holds costs nothing in the leg count, and tests cover the tests.json hand-off
…eta#10273)

test(cli): search goldens and the readme probe read the catalog once

experimental-isolation's search goldens run in this process through
runCli, as its docs topics do, so the seven queries share one read of
the docs catalog instead of fourteen new processes. One spawned search
checks that the in-process run prints what the bin prints.

The icon-set project's ten searches run in one new process, through a
new test helper (startCliSequence) that streams each result as it
ends. Nothing read for the project without icon sets can answer for
it, and its searches share one read of the catalog instead of ten new
processes. One spawned search checks that process against the bin.

The readme test probes one Core name of each kind of match outside an
operation, instead of every eighth name and two variants of each.
…rver in another zone no longer causes a mismatch (meta#10226)

* fix(Calendar, DateRangeInput): today is read after hydration, so a server in another zone no longer causes a mismatch

Calendar and DateRangeInput read the local date during render. A server
and a browser on different dates then render different months, today
markers, or range text, and React hydration fails.

A new internal useToday hook reads today through useSyncExternalStore:
null on the server and in the render that hydrates server markup, the
local date in every later render. Calendar marks no today and, with no
focusDate or value, keeps its month grid's space hidden until the month
is known. DateRangeInput shows the year until then.

* chore(changeset): tag and credit the Calendar today changeset

* fix(Calendar): keep the opening month once today is known, and prove hydration in a real browser

After hydration a Calendar with no focusDate or value re-derived its
opening month on every render, so a pick in the second month of a
two-month view moved both months. The first render that knows today now
stores the opening month.

useToday builds on useIsHydrating instead of a second hydration gate.
The layout claim is limited to the default six-row grid. New tests cover
the empty month label, the unavailable month buttons, the skipped
announcement and the two-month pick, and a real-browser fixture renders
on one date and hydrates on the next.

* test(Calendar): file the hydration fixture stories under Core

Storybook files every story under a package root, so the fixture moves
from Fixtures/ to Core/CalendarHydration.
…ent docs (meta#10227)

test(visual-gate): regenerate the probe theme from the current component docs

The probe theme covered 874 of the 892 documented theming selectors, so
the visual gate did not probe the other 18. This reruns the existing
generator (pnpm visual:probe-theme); its --check now passes (275
targets, 892 selectors). Fixture-only.
…OM (meta#10275)

* test(core): a presentation resolves with no matchMedia, as in a test DOM

A ContextMenu or a DropdownMenu with `presentation="adaptive"`, and the
presentation hooks themselves, throw where the window has no
matchMedia: `window.matchMedia is not a function`. An app's own jsdom
tests have no matchMedia unless they stub it. These cases fail until
the next commit.

* fix(core): a presentation resolves with no matchMedia, as in a test DOM

`usePresentation` and `useAdaptivePresentation` read the pointer and
the compact size through matchMedia. Where the window has none, they now
answer no match, so the component renders its default popover, as
`useMediaQuery` already does. The check runs in the subscription and
the snapshot, never during a server render.

* test(core): an adaptive Popover renders with no matchMedia

Popover resolves `presentation="adaptive"` through its own resolver,
which still throws `window.matchMedia is not a function` in a DOM
without matchMedia. coarsePointer's `matchesMediaQuery` reads any query
and matches nothing there. The Popover case fails until the next commit.

* fix(core): each presentation resolver reads matchMedia through one guard

The two presentation hooks and Popover's resolver subscribe through
coarsePointer's `watchMediaQuery` and read through `matchesMediaQuery`,
which answer nothing where the window has no matchMedia. The check now
lives in one owner, beside `isCoarsePointer`, so a later fix reaches all
three.
* ci: the WebKit interaction scope covers utils/inputAge

The gesture fixtures will bundle the gesture host's input-age helper, so a change to it runs the WebKit interaction proofs, as the other modules they bundle do.

* Merge branch 'main' into ci/webkit-scope-input-age
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 6, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 6, 2026

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: the unit, typecheck, lint, interaction, accessibility and visual checks pass on this head, and the final test check is still running.

The three opt-in options for the layer's outside-press watch (closesOnPan, pointerTypes and root) follow the touch-presentation spec's rule that a finger or pen outside a menu closes it as it begins to pan, stay internal, and are passed by no component yet, so every existing watch keeps today's behavior; the new cases fail against the watch on main and pass here.

  • Non-blocking, for the caller in #8125: closesOnPan follows every pointer type unless pointerTypes narrows it, so pass both to keep a mouse's close at its release.
  • Non-blocking: the flatTree.ts header's "a shadow root's host" repeats shadowHostOf's name; the earlier inline suggestion still applies.
  • #8125's branch does not contain this head yet; it still builds on this pull request's previous revision.

[Automated review]

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 57f4e92

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

This branch was successfully deployed

1 active deployment
Preview — 57f4e926 Deployed Nov 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants