Skip to content

fix(AlertDialog): under a finger on a compact screen two short actions share a row, Cancel leading, as iOS's do - #8095

Draft
vjeux wants to merge 1765 commits into
meta:mainfrom
vjeux:uikit/c18-alerts-pr3
Draft

vjeux wants to merge 1765 commits into
meta:mainfrom
vjeux:uikit/c18-alerts-pr3

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8084. Review only this PR's change: vjeux/astryx@19af775...c743c15

Why

On a phone, AlertDialog stacks its two buttons at full width at 640 px and below, so "Cancel" and "Delete" take two rows. iOS keeps two buttons side by side, Cancel on the leading side, unless one label is too long for its half.

What led to it

The UIKit-parity work, which brings Astryx's touch behavior to iOS's, measured iOS 26.2: beside Cancel a label stays in the row up to 115 pt and stacks from 119 pt, per label, not by the two widths together (alertController.actions.rowShare). Every touch device gets the row (spec:AST-094 FR13).

Options and tradeoffs

  1. CSS flex-wrap. No script, but it wraps on the sum of both widths and keeps the row's order when it wraps, so Cancel would land on top.
  2. A container query on the row's width. No script either, but it can't see how long each label is.
  3. Measure each label against half the row with the shared resize observer. Matches UIKit's rule, at the cost of measuring in script: only when the row or a label changes size (a rotation, a late font), never per frame, committed before the first paint.

Why this choice

Only option 3 expresses UIKit's per-label rule. A mouse keeps today's stack; sizes, type and colors stay the theme's. The row reads its gap and each action's padding and borders as the browser computes them, with no fallbacks.

Owner question: component:AlertDialog FR3 stacks the action above Cancel on every small screen, and PR1 lists no resize observer; can FR3 take "Under a finger, on a compact viewport, Cancel and the action share a row, Cancel leading, when each label fits its half on one line; otherwise the action stacks above Cancel. With a mouse they stack at 640 px and below. Cancel keeps initial focus." and PR1 this row's observer?

How to check it

On a phone or the iOS simulator:

  1. Core/AlertDialog Delete story: Cancel and Delete share a row, Cancel on the left. On main they stack.
  2. MobileTouch story ("Keep this workspace", "Permanently delete workspace"): too long for a half, so they stack, the destructive action above Cancel.
  3. With dir="rtl": the row mirrors, Cancel on the right.
  4. Rotate to landscape and back: it decides again.
  5. With a mouse at 640 px and below: stacked, as today.

Steps 1 to 3 were run on the iOS 26.2 simulator (step 1 also on the Android 15 emulator), steps 4 and 5 in the browser tests: each behaved as written. This head's unit and browser tests (Chromium and WebKit) pass locally; local gates are the evidence, and no CI run is expected.

Evidence

iOS 26.2 simulator, Mobile Safari, main and this PR; then right to left:

Before and after Right to left

Android 15 emulator, Chrome 124 (blur off for capture), main on the left:

Android: the compact alert Blur on

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 30, 2026 3:22am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 3:48 AM UTC

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an owner decision: the compact two-action row (Cancel leading while each label fits half the row) and the narrower-than-744 px or shorter-than-500 px compact condition have no governing spec yet, because draft component:AlertDialog FR3 still stacks the action above Cancel on small screens and draft AST-094's FR1–FR23 are unapproved. An engineering owner needs to provide or identify that rule (for example the FR3 amendment in the description) and approve or reject AST-094, then record the decision by commenting /approve-spec 5a2e10f269b1c79e5a9c4422c080991b1fed9b82 on this pull request; the author may do so as an engineering owner. Two fixes are needed whatever is decided:

  • The first open of a mounted dialog on a compact viewport paints one stacked frame (188 px tall) before the row (148 px) in useAlertActionLayout.ts L80–115; decide the layout before that frame paints and add a first-frame test.
  • The PR also carries its 24 predecessor commits (#8052–#8084); retarget it after they land so it contains only the AlertDialog commit.

[Automated review]

rowRef: RefObject<HTMLElement | null>,
{isCompact, itemSelector, labels}: AlertActionLayoutOptions,
): AlertActionLayout {
const [layout, setLayout] = useState<AlertActionLayout>('stack');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

While the dialog is closed row.clientWidth is 0, so measure() keeps this 'stack' placeholder, and the first real measurement comes from the ResizeObserver after the opening frame has painted. At 402 × 714 the Delete story's first open paints Delete above Cancel (dialog 188 px tall), then collapses to the 148 px row a frame later, which reduced-motion users see. Measuring when the dialog opens, in the layout phase, would keep the first frame settled (architecture:layer-runtime INV12).

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 a243866: the resize observer reports between layout and paint, and the hook now commits its decision there (flushSync), so the opening frame paints the row. A new browser case (Chromium and WebKit) reads the layout from a resize observer made after the alert's, which sees what the opening frame paints: Delete and Cancel share a row. Before the fix it read Delete at y 386 and Cancel at 434.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

AlertDialog (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 191B 191B 0B
Lines of Code 182 607 -
Complexity Medium High (28) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.0KB 88.4KB +31.3KB ⚠️ 12.4KB 12.5KB +87B
core/AppShell 58.0KB 58.6KB +577B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 40.1KB 40.7KB +658B 6.4KB 6.4KB 0B
core/Banner 48.0KB 48.6KB +619B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.5KB 55.0KB +586B 9.9KB 9.9KB 0B
core/BreadcrumbItem 66.5KB 67.3KB +813B 11.0KB 11.0KB 0B
core/Button 43.4KB 44.1KB +639B 9.2KB 9.2KB 0B
core/Calendar 55.1KB 55.7KB +623B 10.0KB 10.0KB 0B
core/Carousel 50.4KB 51.0KB +610B 10.2KB 10.2KB 0B
core/ChatComposer 60.8KB 61.4KB +617B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.9KB 56.5KB +616B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.9KB 56.5KB +612B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.6KB 47.2KB +621B 9.5KB 9.5KB 0B
core/ChatLayout 49.0KB 49.6KB +642B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.5KB 47.1KB +623B 9.6KB 9.6KB 0B
core/ChatSendButton 46.0KB 46.6KB +609B 9.2KB 9.2KB 0B
core/CheckboxInput 50.1KB 50.6KB +593B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.2KB 54.8KB +582B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.4KB +663B 6.9KB 6.9KB 0B
core/CodeBlock 60.7KB 61.3KB +611B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 12.3KB +638B 4.2KB 4.2KB 0B
core/CommandPalette 51.0KB 51.7KB +655B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 9.0KB +630B 3.5KB 3.5KB 0B
core/ComplexSelector 56.0KB 56.6KB +627B 10.6KB 10.6KB 0B
core/ContextMenu 74.7KB 75.6KB +846B 14.6KB 14.6KB 0B
core/ContextMenuItem 53.0KB 53.7KB +718B 9.6KB 9.6KB 0B
core/DateInput 99.7KB 100.3KB +642B 14.8KB 14.8KB 0B
core/DateRangeInput 69.5KB 70.2KB +646B 11.2KB 11.2KB 0B
core/DateTimeInput 109.8KB 110.4KB +656B 15.2KB 15.2KB 0B
core/DialogHeader 49.0KB 49.6KB +610B 9.8KB 9.8KB 0B
core/DrawerHeader 49.1KB 49.6KB +585B 11.2KB 11.2KB 0B
core/DropdownMenu 72.8KB 73.6KB +791B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.8KB +646B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 16.0KB +643B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.8KB +652B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.2KB 59.0KB +756B 9.5KB 9.5KB 0B
core/FileInput 54.3KB 54.8KB +575B 10.3KB 10.3KB 0B
core/IconButton 43.5KB 44.1KB +637B 9.2KB 9.2KB 0B
core/Item 11.4KB 12.0KB +638B 4.7KB 4.7KB 0B
core/LayerProvider 63.4KB 64.1KB +636B 10.1KB 10.1KB 0B
core/Lightbox 51.3KB 51.9KB +585B 9.8KB 9.8KB 0B
core/Link 43.7KB 44.3KB +625B 7.6KB 7.6KB 0B
core/ListItem 53.7KB 54.2KB +593B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 145.3KB +807B 17.4KB 17.4KB 0B
core/MobileNav 51.2KB 51.7KB +584B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.4KB 47.0KB +597B 9.4KB 9.4KB 0B
core/MoreMenu 73.2KB 73.9KB +778B 14.7KB 14.7KB 0B
core/MultiSelector 73.0KB 73.6KB +638B 14.6KB 14.6KB 0B
core/NumberInput 55.8KB 56.5KB +642B 10.7KB 10.7KB 0B
core/Outline 28.8KB 29.4KB +629B 4.0KB 4.0KB 0B
core/Pagination 84.9KB 85.6KB +806B 15.3KB 15.3KB 0B
core/Popover 50.0KB 50.6KB +641B 12.1KB 12.1KB 0B
core/PowerSearch 158.1KB 159.1KB +960B 19.9KB 19.9KB +65B
core/RadioListItem 52.4KB 53.0KB +614B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 9.2KB +628B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.4KB +664B 7.1KB 7.1KB 0B
core/Selector 76.4KB 77.2KB +799B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 15.1KB +637B 5.1KB 5.1KB 0B
core/SideNav 58.1KB 58.7KB +616B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.8KB 47.4KB +610B 9.5KB 9.5KB 0B
core/SideNavHeading 54.9KB 55.5KB +599B 10.1KB 10.1KB 0B
core/SideNavItem 54.6KB 55.2KB +581B 10.1KB 10.1KB 0B
core/Step 32.6KB 33.2KB +619B 5.3KB 5.3KB 0B
core/Stepper 47.5KB 48.1KB +611B 9.6KB 9.6KB 0B
core/Switch 48.5KB 49.1KB +611B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.3KB +644B 4.1KB 4.1KB 0B
core/TabMenu 51.8KB 52.4KB +623B 10.0KB 10.0KB 0B
core/Table 83.9KB 84.7KB +826B 15.1KB 15.1KB 0B
core/TableCell 75.8KB 76.6KB +845B 14.8KB 14.8KB 0B
core/TableHeaderCell 75.5KB 76.4KB +863B 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.5KB 53.1KB +628B 11.2KB 11.2KB 0B
core/TextInput 53.7KB 54.3KB +608B 10.5KB 10.5KB 0B
core/Thumbnail 50.8KB 51.4KB +624B 11.0KB 11.0KB 0B
core/TimeInput 86.9KB 87.5KB +560B 13.8KB 13.8KB 0B
core/Timestamp 27.7KB 27.7KB +1B 9.5KB 9.5KB 0B
core/Toast 60.0KB 60.6KB +602B 9.7KB 9.7KB 0B
core/ToggleButton 44.3KB 44.9KB +590B 9.3KB 9.3KB 0B
core/Token 29.7KB 30.4KB +637B 4.5KB 4.5KB 0B
core/Tokenizer 68.8KB 69.5KB +768B 16.5KB 16.6KB +42B
core/TopNav 52.0KB 52.6KB +579B 10.4KB 10.4KB 0B
core/TopNavHeading 54.3KB 54.9KB +589B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.7KB +637B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.3KB 54.9KB +624B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.7KB +635B 4.1KB 4.1KB 0B
core/TopNavMenu 53.7KB 54.3KB +611B 10.1KB 10.1KB 0B
core/TreeList 32.7KB 33.3KB +618B 4.7KB 4.7KB 0B
core/Typeahead 61.6KB 62.2KB +626B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.9KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 66.4KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 94.8KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 48.1KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 247.5KB N/A N/A 2.4KB N/A
lab/Comments N/A 251.8KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.3KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.7KB N/A N/A 722B N/A
lab/ListInput N/A 75.9KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 189.3KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 122.3KB N/A N/A 775B N/A
lab/Schedule N/A 60.6KB N/A N/A 3.9KB N/A
lab/TourStep N/A 56.0KB N/A N/A 590B N/A
lab/TransferList N/A 67.5KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 73.0KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.4KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 213.3KB N/A N/A 454B N/A
richtext/RichTextView N/A 251.9KB N/A N/A 2.3KB N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/AlertDialog +31.3KB ⚠️ +87B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 34.6KB +43B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

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

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision: the compact two-action row and its 744 px / 500 px condition have no governing spec, contradict the draft AlertDialog FR3, and arrive with the unapproved draft AST-094 (FR1–FR23). Any engineering owner, the author included, can record that decision by commenting /approve-spec 89bac54ae97a8fae47160d28972519a8c5263d7e on this pull request. Whatever the decision, two changes are still needed:

  • The first open of a compact alert paints one stacked frame before the row (useAlertActionLayout.ts L80–107), so settle the layout before the opening frame paints and add a first-frame test.
  • The diff carries the whole stack (27,161 authored lines) for a 624-line change, so retarget onto #8084 or rebase once it and its predecessors land.

[Automated review]

rowRef: RefObject<HTMLElement | null>,
{isCompact, itemSelector, labels}: AlertActionLayoutOptions,
): AlertActionLayout {
const [layout, setLayout] = useState<AlertActionLayout>('stack');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On the first open of each instance this 'stack' is what paints: the row has no width until Dialog's passive effect calls showModal(), so measure returns early (L92) and the first decision comes from the resize observer after paint. At 402×714 with reduced motion the dialog paints 188 px tall and stacked, then jumps to the 148 px row in the next frame (layer-runtime INV12: a layer never paints at a geometry it is about to correct). Settling the first decision before paint, for example with flushSync on the observer's first call or a layout effect keyed on the open state, plus a first-frame test, would fix 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 a243866: the resize observer reports between layout and paint, and the hook now commits its decision there (flushSync), so the opening frame paints the row. A new browser case (Chromium and WebKit) reads the layout from a resize observer made after the alert's, which sees what the opening frame paints: Delete and Cancel share a row. Before the fix it read Delete at y 386 and Cancel at 434.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision: the compact two-action row and its 744 px / 500 px condition have no governing spec, contradict the draft AlertDialog FR3, and arrive with the unapproved draft AST-094 (FR1–FR23). Any engineering owner, the author included, can record that decision by commenting /approve-spec 7fcc382ceb97adae5e5ea2fd6eb73dd439537bab on this pull request. Whatever the decision, two changes are still needed:

  • The first open of a compact alert paints one stacked frame before the row (useAlertActionLayout.ts L80–115), so settle the layout before the opening frame paints and add a first-frame test.
  • The diff carries the whole stack (28,139 authored lines) for a 624-line change, so retarget onto #8084 or rebase once it and its predecessors land.

[Automated review]

cixzhang added a commit that referenced this pull request Oct 24, 2026
… platform (#8274)

## Exact decision needed

What "compact" means when a component picks a presentation by screen size, and whether it differs by platform.

## Existing authority searched

- `spec:AST-012` FR1–FR2: width points (`md` is 768) and a closed `when` vocabulary with no height field.
- `architecture:public-component-api` INV14 and change coupling: components keep no parallel hardcoded breakpoint table.
- `component:Selector` FR4: `adaptive` uses the sheet on "compact coarse-pointer screens", with no number.
- `design:mobile-typography` DR3 uses `(width < 1024px) and (pointer: coarse)` for text sizing, a separate concern this PR leaves alone.
- No current record defines compact. Code uses `(max-width: 768px) and (pointer: coarse)`.
- Search terms: compact, 744, 768, height 500, size class, adaptive, coarse pointer, breakpoint. Overlapping open pull requests: #8062 (shared touch resolver and its compact query), #8052 (AST-094 resolver), #8015 (NumberInput steppers on coarse pointers; not a size test), #6817 (compact density, a different meaning), #8089, #8104, #8095 (phone presentations that consume the resolver).

## Decision

- `spec:AST-012` FR9 / DEC-6: a screen is compact while its width is below the effective `md` point or its height is below 500, with the same numbers on every platform (CSS px on the web, pt on iOS, dp on Android); native does not map compact to platform size classes. The theme exports this one definition; the web touch presentation resolver, Selector's `adaptive` presentation, and `@astryxdesign/native` all use it.
- `spec:AST-012` FR2: `when` gains `height`, accepting only `{below: 500}`.
- `architecture:public-component-api` INV17: a component that chooses a presentation by screen size uses that definition and keeps no numbers of its own.
- `spec:AST-082` FR18: `PortableTheme` carries the compact definition, so native reads the same numbers; FR11 and `spec:AST-093` FR3: a native choice between presentations by screen size uses it, not size classes, while the platform's own control still presents the surface.
- Compact governs presentation choice only; a layout width a caller picks, such as AppShell's `mobileNav.breakpoint`, is not compact.
- `component:Selector` records the `adaptive` edge change as pending and compatible under `spec:AST-017` FR1.

## Rejected alternatives

- Mapping compact to each platform's size classes: the same screen would be compact on one platform and not another.

## Claim scope and non-goals

The compact definition and the height condition only. Text sizing under `design:mobile-typography`, which surfaces adopt a touch presentation, and resolver code are outside this PR.

## Evidence and implementation relationship

The touch presentation resolver contract lives only in the open `spec:AST-094` revision (#8052), which this PR does not edit; it must use FR9. Native adopts FR9 when it gains a compact resolver. Behavior delta when the code conforms: a coarse-pointer screen exactly 768 wide and at least 500 tall opens the popover, and a landscape phone wider than 768 but under 500 tall opens the bottom sheet. Code follow-up belongs to separate implementation PRs. INV16 is the caller-control invariant from #8273 (merged); this PR adds INV17. No implementation is included.

## Writing rubric results

- R1 narration: none.
- R2 research in the record: none.
- R3 evidence naming a change: none; verification names edge states.
- R4 rejected alternatives over one line or outside a decision: one, inside DEC-6, one line.
- R5 pull request or issue references remaining: none added.
- R6 ruled open questions still listing options: none.

## Scope

- [x] This PR contains specification records only.
- [x] It records observable behavior and preserves equivalent internal implementations.
- [x] Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
- [x] Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
- [x] It records one intentional durable decision, not a rescue for a separable tagalong.
- [x] The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
- [x] Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
- [x] Public text and artifacts contain no internal Meta context.

## Validation

- `pnpm check:knowledge` (CI lint)
- Prettier
- `git diff --check`
@vjeux
vjeux force-pushed the uikit/c18-alerts-pr3 branch from 7fcc382 to 1a2438d Compare October 24, 2026 21:29
@vjeux vjeux changed the title fix(AlertDialog): on a phone two short actions share a row, Cancel leading, as iOS's do fix(AlertDialog): on iPhone and iPad two short actions share a row, Cancel leading, as iOS's do Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c18-alerts-pr3 branch from 1a2438d to a243866 Compare October 24, 2026 22:07
@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at a243866:

  • The first open paints the decided layout: the resize observer commits it between layout and paint, with a first-frame browser case in Chromium and WebKit (failed before the fix).

Waiting in the reviewers' queue: the spec decision on the compact row and its 744 px / 500 px condition (the FR3 amendment in the description). This pull request carries its stack until it lands; review the last commit.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision. The iOS compact two-action row has no governing spec (the draft AlertDialog FR3 still stacks on small screens), and the draft AST-094 (FR1–FR23) is unapproved. Its 744 px compact condition and the alert's reduced-motion spring fade contradict the current AST-012 FR9 and reduced-motion DR2 and DR4, so either an owner updates those specs or the code conforms to them. Any engineering owner, the author included, can record that decision by commenting /approve-spec a243866944f89a0aa16501b83b06d6c8deac1402 on this pull request. Whatever the decision, these still need changes:

  • The first compact open on iOS raises a "ResizeObserver loop completed with undelivered notifications" error because the observer's callback resizes the row it observes, and re-deciding while open logs three React flushSync errors (useAlertActionLayout.ts L105–129).
  • The docs and changeset say clicks, keys and assistive technology are ignored during both transitions, but while the alert appears only touches are.
  • The diff carries the whole stack (32,443 authored lines) for a 775-line change; retarget onto #8084, or rebase once the stack lands.

[Automated review]

Comment on lines +115 to +117
flushSync(() => {
setLayout(next);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This commit runs inside the observer callback for the row, and it shrinks that same row from 72 px to 32 px when the stack turns into a row. The browser then skips re-delivering the row and dispatches "ResizeObserver loop completed with undelivered notifications." as a window error event on every first compact open in the iOS idiom (reproduced in Chromium at 402×714 with no other observer on the page). Observing an element whose size the decision does not change would avoid it, for example a zero-height element spanning the row's width plus the labels.

Comment on lines +122 to +124
const stops = [row, ...labelsShown].map(element =>
observeResize(element, onResize),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

observeResize fires its callback synchronously on registration (utils/sharedResizeObserver.ts L58, L92). When this effect re-runs while the dialog is laid out, onResize therefore calls flushSync inside the layout effect, and React logs "flushSync was called from inside a lifecycle method" once per observed element. That is three errors when an open alert is resized from 900 px to 402 px wide. The effect body already measures (L125–129), so skipping the registration call is enough.

usage: {
description:
'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. Button labels retain their standard single-line behavior. The breakpoint follows available width, not pointer or hover capability. The body scrolls when block space is constrained.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.',
'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. On iPhone and iPad a compact viewport (narrower than 744px or shorter than 500px) follows the iOS alert instead: the actions share a row, Cancel leading, when each label fits its half of it on one line, and otherwise stack with the destructive action above Cancel, both filling the footer width. Button labels retain their standard single-line behavior. Outside iOS the breakpoint follows available width, not pointer or hover capability. The body scrolls when block space is constrained.\n\nOn a touch screen the two actions are tracked as one group under a finger or a pen, the way iOS tracks the actions of an alert: the action under the finger highlights as soon as it is touched, the highlight follows the finger from one action to the other, and lifting the finger runs the action under it. Lifting it off both actions runs nothing. A mouse, the keyboard and assistive technology keep the click of each button.\n\nOn iPhone and iPad the alert also appears and leaves the way an iOS alert does: it scales down slightly into place while it and the dimming fade in, and it fades out with the dimming when it closes. Nothing reaches it during either transition: a touch, a click, a key or an assistive-technology activation does nothing. With reduced motion it only fades. Everywhere else (a mouse, or a finger on Android) it keeps the theme entrance and closes at once.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Nothing reaches it during either transition: a touch, a click, a key or an assistive-technology activation does nothing" holds only for the fade-out. While the alert presents, only finger and pen presses are dropped (useActionGroupTracking isInert). handleCancel, handleAction and handleOpenChange check only isLeaving, so Escape, Enter or a VoiceOver activation still acts in the first 435 ms. The same wording is in .changeset/alertdialog-touch-motion.md.

export const TOUCH_QUERY = '(pointer: coarse)';

/** The viewport is compact: layout behaviours. */
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-012 FR9 makes compact "below the effective md point (FR1) or … below 500" and says the web touch presentation resolver keeps "no size numbers of its own". DEC-6 rejects mapping compact to platform size classes. The literal 744 departs from both, so a coarse 744–767 px screen is not compact here. Either FR9 is amended, or this reads the theme's md point.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-012 FR2 closes the when vocabulary to width, height, pointer, contrast and motion: "Future environment fields require explicit additions to this closed vocabulary." platform needs that amendment or has to come out.

Comment on lines +295 to +299
const handle = panel.animate(
{opacity: 1, scale: 1},
{spring: springs.alertPresent},
{crossfade: springs.alertPresent},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With reduced motion this fades the dialog and its ::backdrop on springs.alertPresent, about 404 ms, as measured. A theme can retune that spring. Current design:reduced-motion DR2 asks for an opacity-only fade of 200 ms or less on a coarse pointer, DR1 and its verification row exclude a spring, and DR4 forbids a theme value that lengthens it. The open owner question covers whether those records change.

@vjeux
vjeux force-pushed the uikit/c18-alerts-pr3 branch from a243866 to aa32c53 Compare October 24, 2026 23:30
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's spec decision: no current spec covers AlertDialog's compact-touch row (Cancel and the action sharing one row under a finger), the new draft FR6 320 px width cites an owner decision this pull request does not show, the change adds alert.present and alert.dismiss to spec:AST-094 FR4's own table, and AlertDialog's record does not state its touch exit or its action-group tracking. An engineering owner, the author included if they are one, records that decision by commenting /approve-spec d586e2fd962f6e534297d4a2949b6aa9427a79cb on this pull request. Independently of that decision, please fix the following:

  • Stop the first compact-touch open from raising "ResizeObserver loop completed with undelivered notifications." by not resizing the row the layout's own observer watches.
  • Skip the flushSync in useAlertActionLayout during the resize observer's registration call, which logs three React errors per inline compact-touch alert.
  • Correct the story notes, AlertDialog.doc.mjs, FR6 and the touch-motion changeset, which misstate the 768 px compact edge, the finger condition, the mouse tracking and when the 320 px width applies.
  • Land the parent stack and rebase (or retarget onto #8084) so the diff carries this change alone.

[Automated review]

// the decision here settles the layout in that same frame, so no frame
// paints the layout it is about to correct (layer-runtime INV12).
// eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the opening frame must paint the decided layout, and the observer runs between layout and paint
flushSync(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This commit runs inside the observer delivery for the row it resizes: on the Delete story under touch (390 x 844 and 844 x 390, Chromium) the first open raises "ResizeObserver loop completed with undelivered notifications." on window. Because observeResize fires each callback synchronously on registration (lines 122-124), it also runs inside this layout effect, where React logs "flushSync was called from inside a lifecycle method" three times per inline compact-touch mount. Observing only what the decision does not resize, and skipping the commit on the registration call (the effect already measures at line 125), would remove both.

* Wide reference state. In a wide viewport, Dialog preserves AlertDialog's
* preferred 400px surface and AlertDialog renders a horizontal action row.
* preferred 400px surface and AlertDialog renders a horizontal action row. On a
* compact viewport (narrower than 744px or shorter than 500px) the two short

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The code reads the theme's compact definition (768 px by default, not 744) and gives the row only under a coarse pointer; with a mouse this story stacks at 640 px and below whatever the labels.

guidance: true,
description:
'Use concise action labels; >640px complete buttons may move to another row, and <=640px puts the destructive action above Cancel with both buttons full width regardless of pointer type.',
'Use concise action labels; wide: complete buttons may move to another row; compact: short labels share a row with Cancel leading, a label too long for its half stacks the destructive action above Cancel, both full width, regardless of pointer type.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"regardless of pointer type" contradicts the code: with a mouse a compact viewport keeps today's stack at 640 px and below, and the shared row applies only under a coarse pointer.

| FR3 | The anatomy is title, description, then the buttons: Cancel then action on wider screens, the action stacked above Cancel on small screens, Cancel keeping initial focus either way; the action may show a loading state while `isActionLoading`. | shipped source and focused tests | verify |
| FR4 | `isInline` renders the same anatomy without modality and without the alert-dialog role. | shipped source and public docs | verify |
| FR5 | `useImperativeAlertDialog` returns `show`, `hide`, and the element to render, and owns `isOpen`/`onOpenChange` for its caller. | shipped source and focused tests | verify |
| FR6 | On phones (compact width with a touch pointer), alerts are 320 px wide in every theme, matching iOS, with no new theme role; elsewhere the default width is 400. | owner decision, 2026-10-26 | settled |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The code gives 320 px to any coarse pointer on a compact viewport, a landscape phone compact by height included (320 px at 844 x 390), not only to phones by width; and no record or comment on this pull request shows the cited owner decision.

vjeux and others added 26 commits October 28, 2026 06:04
Her merged record (meta#8594) keeps AlertDialog's public contract unchanged:
the touch presentation is a behavior change, and on a phone a caller's
width is its way back (FR9). Pin AlertDialogProps so it has no
presentation prop and keeps width. Fails typecheck while the prop exists.
…keeps the public contract

Her merged AlertDialog record (meta#8594) changes behavior under a touch
presentation without changing the public API: a caller's width is the
way back on a phone (FR9), and no other prop is added. Remove the
presentation prop, its AlertDialogPresentation type, the
DialogPresentation story, its doc entry and the tests that used it. The
width test also covers a caller's width on a phone.
…lertDismiss

Her merged AlertDialog FR6 (meta#8594) names the alert's motion roles
springs.alertPresent and springs.alertDismiss, the springs table's rows.
Expect those role ids, the alert's two springs among the registered
springs roles, and AlertDialog registering those two rows alone. Fails
while the roles are registered as alert.present and alert.dismiss.
…d springs.alertDismiss

Her merged AlertDialog FR6 (meta#8594) names the alert's motion roles by
their springs table rows. Register the alert's two springs among the
springs roles under those names, rename AST-094 FR4's two rows to match,
and let AlertDialog register those two rows alone (registerAlertSprings),
so its bundle still carries no other spring. The alert table keeps only
its measured values. Same springs and timings; only the role ids change.
Main merged meta#8045 and meta#8046. The one conflict is utils/pressFeedback.ts:
it takes main's merged meta#8046 text (the finger and button slots, the mouse
and key press) and keeps this pull request's own additions, the one
writer of the press attribute (writePress, paintPress, readPressPaint)
and the header line naming them.
# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/busy.test.ts
#	internal/interaction-harness/src/busy.ts
#	internal/interaction-harness/src/frames.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
Main's button slot, merged with meta#8046, ends a press's release at once
when the next press starts, by clearing the press attribute directly. On
a row another painter holds, that clear erased the hold, which this pull
request's one writer refuses. A mouse press on a held row, released, then
a press elsewhere must leave the row held. Fails until the early end goes
through the one writer.
finish() writes none through writePress, as this pull request's
controller paints, so a held row stays its owner's when the next press
ends a release early, under the finger and the button slots alike.
Both upstream heads are folded onto main 70ea859 (her meta#8045 frame
evidence and meta#8046 coarse press): meta#8446 takes main's harness files, and
meta#8073 carries the press controller onto meta#8046's slots. meta#8076 has no change
of its own in either set of files, so the merge takes theirs with no hand
resolution, and meta#8076 is clean against main again.
# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/busy.test.ts
#	internal/interaction-harness/src/busy.ts
#	internal/interaction-harness/src/frames.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
#	packages/core/src/utils/pressFeedback.ts
…bute

From the review of meta#8076: the README said paintPress in
utils/pressFeedback.ts is the one writer of data-astryx-press, but the
action-group tracker on main (meta#8346) writes its groups' presses itself.
The sentence now says what holds at this head.
meta#8076's new head carries main 70ea859 with meta#8073's writer of the press
attribute on main's press controller, and its README names both writers.
utils/pressFeedback.ts keeps that controller with this line's own changes
on it: the pointer, drag, scroll and visibility listeners through the
shared input stream (meta#8356) and touchcancel only while the primary pointer
is coarse (meta#8270); the key path stays at the window's bubble phase, so a
Space press the page cancels paints nothing. usePressTracker stays in its
own module, one tracker for a component's life (meta#8315), so meta#8073's
rebuilt-tracker hook and its test are not taken. The harness files are
meta#8076's.
…s, and the action-group tracker writes its own

The header and writePress called it the one writer of the press attribute,
but main's action-group tracker writes its groups' presses itself. They now
say what holds, as meta#8076's gestures README does.
…he press attribute

The release note said every press painter writes data-astryx-press
through one internal writer, but the action-group tracker
(hooks/actionGroupTracker.ts) writes its groups' presses itself, as the
gestures README and utils/pressFeedback.ts already say. The note now says
paintPress writes every press it tracks and the action-group tracker
writes its own.
…arker

Takes meta#8360's press controller as it stands (its finger and button
slots, and the mouse-and-Space press from meta#8046) and re-applies only
this PR's change on it: the group item marker and the controller's
skip of a pointer press on a marked action.
…tion to its tracker

meta#8046 made the controller paint a mouse press itself where the primary
pointer is coarse (a tablet with a trackpad). On an action its group
tracks (family:action-groups), the group's tracker paints that press,
so the controller must paint nothing there, as it already does for a
finger; it paints the press, then turns the tracker's paint to
'fading' at the release. A Space press on the same action still paints:
the group tracks pointers only (guard).
… its tracker

Where the primary pointer is coarse the controller paints a mouse press
itself (meta#8046). It now skips an action a group's tracker paints for a
pointer's press, a mouse's included, as it already did for a finger, so
the two never write the press attribute over each other. A Space press
on the action still paints: the group tracks pointers only.
…he keyboard keeps the buttons

Her AlertDialog record (meta#8594) verifies FR8 with a press lifted outside
the actions under a mouse, a pen and a finger, and with Enter and Space on
each action. Add the two cases the tests lacked: a mouse press that slides
off both actions highlights nothing and runs nothing at the lift, and under
a coarse and a fine pointer Enter runs the action and Space cancels. A
mutant that stops tracking a mouse's moves fails the first; one that drops
the buttons' own click fails the second.
meta#8084 removed AlertDialog's presentation prop to keep her record's public
contract (meta#8594: no public API change; a caller's width is the phone width's
way back). The compact row's case for that prop and the changeset's mention
of it go with it; a mouse still keeps today's stack.
…its tests give the lengths a browser computes

The row's gap fell back to its row gap and then to 0, and each action's
padding and borders fell back to 0. A browser always computes those as
lengths (a flex row with no gap computes its column gap as `normal`), so
the fallbacks only ever served jsdom, which resolves no `var()` (the row's
column gap computes to `var(--spacing-2)`) and computes no padding for the
actions: the tests passed on lengths no browser produces, and the
long-labels case passed only because jsdom lays nothing out.

The column gap is now read as computed, `normal` being no gap, and padding
and borders with no fallback. The AlertDialog tests stub the row's
geometry and the computed lengths together, the long-labels case included,
and the hook's own fixture sets every length it measures. New cases pin
that padding and borders count against an action's half and that a
`normal` column gap counts as none. Without the fixture changes, four
cases fail on this code: they relied on the fallbacks. No change in a
browser. [skip ci]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's spec decision: no current spec covers AlertDialog's compact-touch row (Cancel and the action sharing one row under a finger), because the draft component:AlertDialog FR3 still stacks the action above Cancel on small screens, and this change also adds springs.alertPresent and springs.alertDismiss to spec:AST-094 FR4's own role table, where FR4 and DEC-5 send a component's measured roles through the generated motion role table. An engineering owner, the author included if they are one, records that decision by commenting /approve-spec c743c15dd9650aeece28377cf19555c9f138f458 on this pull request. Independently of that decision, please fix the following:

  • Stop the first compact-touch open from raising "ResizeObserver loop completed with undelivered notifications." by not resizing the row the layout's own observer watches.
  • Skip the flushSync in useAlertActionLayout for the observer's registration calls, which run inside the layout effect and make React log errors for an inline or already-open compact-touch alert.
  • Correct AlertDialog.doc.mjs, the AlertDialog stories and the touch-motion changeset, which say a mouse keeps each button's click, give the compact row no pointer condition or a 744 px edge, and give the 320 px width only to "a compact width".
  • Land the parent stack and rebase (or retarget onto #8084) so the diff carries this change alone.

[Automated review]

Comment on lines +122 to +124
const stops = [row, ...labelsShown].map(element =>
observeResize(element, onResize),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two issues meet here. Observing the row itself and committing the decision with flushSync inside this observer's callback (lines 105-118) resizes an observed element during its own delivery, so the first compact-touch open from the 'stack' placeholder raises "ResizeObserver loop completed with undelivered notifications."; observing only what the decision does not resize (the labels and the row's container width) avoids it. And observeResize calls onResize once synchronously on registration, so these three calls run flushSync inside the layout effect whenever the row is already laid out (an inline alert, or one mounted open), which React reports as "flushSync was called from inside a lifecycle method"; skipping the commit for the registration calls avoids it, since the effect measures right after (lines 125-129).

usage: {
description:
'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. Button labels retain their standard single-line behavior. The breakpoint follows available width, not pointer or hover capability. The body scrolls when block space is constrained.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.',
'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. Under a finger on a compact viewport (narrower than 768px or shorter than 500px by default, the compact definition of the theme) it follows the iOS alert instead: the actions share a row, Cancel leading, when each label fits its half of it on one line, and otherwise stack with the destructive action above Cancel, both filling the footer width. Button labels retain their standard single-line behavior. With a mouse the breakpoint follows available width alone. The body scrolls when block space is constrained.\n\nOn a touch screen the two actions are tracked as one group under a finger or a pen, the way iOS tracks the actions of an alert: the action under the finger highlights as soon as it is touched, the highlight follows the finger from one action to the other, and lifting the finger runs the action under it. Lifting it off both actions runs nothing. A mouse, the keyboard and assistive technology keep the click of each button.\n\nUnder a finger, on any touch device, the alert also appears and leaves the way an iOS alert does: it scales down slightly into place while it and the dimming fade in, and it fades out with the dimming when it closes. While it appears a touch does nothing (keys and assistive technology still act), and while it leaves nothing reaches it: a touch, a click, a key or an assistive-technology activation does nothing. With reduced motion it only cross-fades. With a mouse it keeps the theme entrance and closes at once.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This says a mouse keeps the click of each button, but AlertDialog.tsx tracks the two actions under every pointer, a mouse included, as family:action-groups FR5 requires; the dense copy at line 199 says the same. Line 44 also gives the compact row with no pointer condition and line 219 adds "regardless of pointer type", while a mouse keeps the 640 px stack or the trailing row.

Comment on lines +90 to +92
* preferred 400px surface and AlertDialog renders a horizontal action row. On a
* compact viewport (narrower than 744px or shorter than 500px) the two short
* actions keep sharing a row, Cancel leading, as an iOS alert's do.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The row applies only under a coarse pointer and uses the theme's compact definition (768 px wide or 500 px tall by default), so for this fine-pointer story a compact viewport keeps the 640 px stack. The same 744 px figure appears at line 108.

'@astryxdesign/core': patch
---

[feat] AlertDialog: under a finger the alert appears and leaves the way an iOS alert does. It scales down into place from 1.2 while it and the dimming fade in, and it fades out with the dimming when it closes, on the alert's measured spring; the animation keeps running when the page is busy. While it appears a touch does nothing (keys and assistive technology still act), and while it leaves nothing reaches it: a touch, a click, a key or an assistive-technology activation does nothing. With reduced motion it only cross-fades. Defaults under a finger: on a phone (a compact width) the width with no `width` prop goes from 400 px to UIKit's 320 px in every theme, and this motion replaces the Dialog's entrance (rising 16 px and scaling up from 0.95 while it fades in) and its exit. With a mouse nothing changes. A `width` you pass still wins on a phone. The actions are tracked as one group under every pointer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The 320 px default applies to any coarse pointer on a compact viewport (below md or under 500 px tall), so a landscape phone gets it too; "a phone (a compact width)" undersells that.

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants