Skip to content

feat(AlertDialog): short actions share a row on compact touch screens - #8095

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

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

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8084 → #8095

#8084 lands first. Review only this pull request's change: vjeux/astryx@62c9b4a...8226b5e

User need

On a phone, AlertDialog stacks its two buttons at full width, so "Cancel" and "Delete" take two rows. Under a finger on a compact screen, two short actions now share one row in equal halves, Cancel leading. A label too long for its half stacks them, the destructive action above Cancel: as before on a portrait phone, and new on a landscape phone, where the actions used to sit in a trailing row. With a mouse nothing changes.

Problem and solution fit

  1. Row (FR3): under a finger on a compact screen, two actions whose labels each fit half of the row share it in equal halves, Cancel leading. Visual, DOM and tab order agree, and Cancel keeps initial focus.
  2. Stack: a label too long for its half stacks them, the destructive action above Cancel. On a portrait phone that is today's layout; on a landscape phone it replaces the trailing row.
  3. Fresh decisions: a rotation, a zoom, a web font that finishes loading or a label change decides again while the alert is shown, and an alert mounted closed decides when it opens.
  4. A mouse keeps today's layout: stacked at 640 px and below.

Current specification

Specs decision, 2026-11-04: compact touch screens get the shared row (component:AlertDialog DEC-5). The rule is iOS's: per label, never by the two widths together (alertController.actions.rowShare). The row itself is Astryx's: Astryx Buttons, the footer's Stack gap token, and the theme's type and colors. Nothing of iOS's styling is copied. spec:AST-012 FR9 defines a compact window. The record gains FR3's compact touch layout, PR1's title observation and DEC-5.

Public delta and caller burden

No API change, and no decision for callers: a label too long for its half stacks the actions on its own. Under a finger on a compact screen, two short actions share a row; with a mouse nothing changes.

Design and alternatives

  1. CSS flex-wrap. No script, but it wraps on the sum of both widths and keeps the DOM order when it wraps, so Cancel would land on top, and visual and tab order would disagree.
  2. A container query on the row. No script either, but it can't see each label's length.
  3. Measure each label against half of the row, in script, only when something can change the answer: the alert's first display, a rotation, a zoom, a web font that finishes loading, or a label change.

Only option 3 expresses the per-label rule and keeps visual, DOM and tab order together. It watches the alert's title, whose size the actions never change, so a decision never resizes what it watches and starts no resize observer loop. It also listens for web fonts, which change a label's width without changing the title's size. It commits before the first paint.

Evidence

On a phone, on this head's preview:

  1. Delete story: tap "Delete item". Cancel and Delete share one row, Cancel on the left. On feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084 they stack.
  2. Async story: tap "Revoke access", then "Revoke". The spinner stays inside its half, and the row holds.
  3. Mobile Touch story: "Keep this workspace" and "Permanently delete workspace" are too long for a half, so they stack, the destructive action above Cancel.
  4. Open the Delete alert, then rotate to landscape and back: it decides again (a landscape phone is compact by its height).
  5. With a mouse at 640 px and below, nothing changes: the actions stack.

The spec owner's real-phone check of the action layout, edge cases and long labels included, passed (2026-11-05).

Real Chromium renders of the inline alert story under a coarse pointer, 320 px on the left, 390 px on the right. They were taken at 1f61aea; at this head the same cases render the same pixels, apart from the loading spinner's angle.

  • Two short labels, before (feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's head) and after:
    Before: stacked
    After: one row, Cancel leading
  • One long label stacks them, whatever the other: One long label
  • Two long labels: Two long labels
  • A label that fits its half only on the wider screen ("Delete all items" needs 133 px; a half is 124 px at 320 and 140 px at 390): Stack at 320, row at 390
  • German: short labels share the row, a long one stacks them: Abbrechen and Löschen Abbrechen and Endgültig löschen
  • Right to left: the row mirrors, Cancel on the right: Right to left
  • Twice the text size: the actions stack. The Button keeps its token height, so its label fills the pill; that is Button's existing sizing, unchanged here: Large text
  • 200% zoom (a 320 and a 390 px phone at 160 and 195 CSS px): the actions stack: 200% zoom
  • Another action variant (primary): the same row: Primary action
  • A loading action keeps its half: it is measured by its label, not by its spinner's text: Loading
  • A label change while the alert is shown: "Delete" becomes "Delete every message", and the actions stack: Before the change After the change
  • A mouse keeps today's layout: stacked at 640 px and below: Mouse

Review follow-ups

The earlier review's items, on this rebuild:

  • A mouse keeps the native button: only a finger or a pen is tracked (pointerTypes: ['touch', 'pen']), as on main. The usage doc now says so; it still read "every pointer".
  • No resize observer loop on the first compact touch open: the hook watches the title, not the row. A browser case collects the page's errors and finds none; it fails with the old hook, which watched the row.
  • No flushSync during the observer's registration call inside the layout effect. A unit case renders under StrictMode and logs no React error; it fails with the old hook.
  • The docs, stories and changeset say what ships: the theme's compact definition (by default narrower than 768 px or shorter than 500 px), a coarse pointer, and a mouse that keeps the 640 px stack. The changeset is a [feat].
  • component:AlertDialog FR3, PR1 and DEC-5 record the specs decision (2026-11-04).
  • One commit on feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084, in place of the old stack.

This round's review, at 1f61aea:

  • An alert mounted closed now shares the row once it opens: the title comes through a callback ref, so its mount starts the observation. A unit case and a browser case (the inline story mounted with isOpen false, then opened) each fail without that.
  • PR1 names the web-font listener beside the title's resize observation.
  • The case for a label that fits only on a wider screen now runs at 300 px (a half is about 114 px) and 390 px (140 px) with "Delete forever" (about 125 px), so it has room on both sides.
  • feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084 dropped the unused isInert tracker option.

And at 93ae42e:

  • The changeset and the record's compatibility note say that long labels stacking is new on a landscape phone, where main shows a trailing row.
  • The even split's style is named equalHalfAction, without a comment restating it.

One test change outside the layout: #8084's busy-main-thread proof now counts a frame as "the alert changed" by the mean absolute change of the region's pixels, not by the change of their mean. With the row's shorter alert, a frame came where the alert changed but the region's mean luminance moved by only 0.044: the fade's gain was cancelled by text crossing the region's edge as the alert scaled. That missed frame failed 6 of 9 runs on two cores; with the new measure 6 of 6 pass. A still alert during the block still reads as missed on every frame of the control (58 of 58).

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 8226b5e, on #8084's head 62c9b4a (main 252b45b):

  • The new layout spec, both AlertDialog interaction specs, Dialog's touch entrance and the input hold's browser spec: Chromium twice and pinned to 2 cores three times, 60 of 60 each; WebKit in CI's container five times, 280 passed (the 20 skipped cases are Chromium-only). AlertDialog's accessibility contract and the bottom sheet's toast outlet contract (7) and the hooks, AlertDialog, Dialog, Layer, Toast and motion units (2,538) pass.
  • Red first: 10 of the layout spec's 16 cases fail on feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's Storybook, which has no row, and the mounted-closed case fails on the previous head. The hook's unit cases for watching only the title, for the registration under StrictMode, for a loading label, for a font load and for an alert mounted closed each fail without that part.
  • CI=true pnpm lint (no errors), the build, pnpm typecheck, Storybook, pnpm test:ui (20,121), check:knowledge and check:repo pass. In pnpm test:node 16,453 passed, and only feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's two ci-interaction-webkit.test.mjs cases failed, both on modalOutlet.ts; ci: the WebKit interaction lane covers the modal outlet #10018 clears them. api:report --check reports drift already on main (DropdownMenu, the CLI), none of it from this change.

Writing rubric results

component:AlertDialog: FR3 states the compact touch layout, with visual, DOM and tab order together and Cancel's initial focus in every layout; PR1 adds the title observation and the web-font listener; DEC-5 records the decision and the rejected alternative; the Behavior row and the FR1–FR5 verification row name the layout and its tests; the Compatibility bullet adds FR3.

  • R1 narration, R2 research, R4 rejected alternatives, R5 change references: none in the record.
  • R3 evidence: the per-label rule's source is alertController.actions.rowShare (measured, iOS 26.2); the decision's is DEC-5.
  • R6 open questions: none.

@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 Nov 5, 2026 1:17am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 5, 2026, 1:38 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
Contributor

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
Contributor

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

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

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

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…n-group test counts only the fade's timer) into meta#8095
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 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.

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) or its 320 px touch width, and this change adds springs.alertPresent and springs.alertDismiss to the AST-094 FR4 role table. An engineering owner records that decision by commenting /approve-spec 420d5c2096bec11fbeba2452b443c0b29905849f on this pull request (the author may do so if they are an engineering owner), and these fixes are needed either way:

  • Use flushSync in useAlertActionLayout.ts only for resize-observer deliveries after registration, since its synchronous first callback runs inside the layout effect and logs three React errors when a compact-touch alert's row is already laid out, as in an inline alert under a finger.
  • Say "under a finger" and 768 px in AlertDialog.doc.mjs and the two fine-pointer story notes, and land or split the parent stack so this change is not 25,808 lines.

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

Choose a reason for hiding this comment

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

observeResize calls onResize synchronously when it registers (utils/sharedResizeObserver.ts:89-92), and the registrations at L122-L124 run inside this layout effect, so whenever the row is already laid out (an inline alert, or a re-run while the alert is open) this flushSync runs during React's commit and React logs "flushSync was called from inside a lifecycle method" three times, as AlertDialog.test.tsx and the inline stories under a finger show. Keep the plain setLayout(initial) at L128 for the registration pass and use flushSync only for later observer deliveries.

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
Contributor

Choose a reason for hiding this comment

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

"regardless of pointer type" is not what ships: with a mouse at 640 px and below the actions still stack (AlertDialog.tsx:283), and the shared row needs a finger on a compact viewport (the theme's compact definition, 768 px wide by default). The best practice at L44 drops the finger condition the same way.

* 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
Contributor

Choose a reason for hiding this comment

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

This is the fine-pointer story: with a mouse at 600 px the two short actions stack, and compact is the theme's definition, 768 px wide by default, not 744 px. The NarrowFinePointer note at L136 repeats 744 px.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…th under a finger

The record owner's decision on alert width: on phones (compact width with a
touch pointer), alerts are 320 px wide in every theme, matching iOS, with no
new theme role, and desktop is unchanged. AlertDialog gave every touch
screen 320 px, a tablet included; it now takes UIKit's width only when the
touch presentation is compact as well, and Dialog's 400 otherwise.

`component:AlertDialog` states it as FR6, mapped to the width test. The prop
docs, the doc file and the changeset say "on a phone".

Test, failing on the previous head (113b697): under a finger on a wider
touch screen the alert is 400 wide (it was 320); a phone keeps 320 and a
mouse 400. The media stub answers the compact query (`width < 768px`) the
way meta#8095's does.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…springs in its own table) into meta#8095

AlertDialog.tsx conflicts where both sides changed the width line: the
width now reads this branch's isCompactTouch (a finger and a compact
viewport), which is meta#8084's new condition.

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

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) or its 320 px compact-touch width, the new draft FR6 cites an owner decision this pull request does not show, and the change adds alert.present and alert.dismiss to the AST-094 FR4 role table. An engineering owner records that decision by commenting /approve-spec dd45b139acbbb89e3bad2be0fb220ba1a77cf1e8 on this pull request (the author may do so as an engineering owner), and these fixes are needed either way:

  • Skip the flushSync in useAlertActionLayout.ts for observeResize's synchronous registration callback, which runs inside the layout effect and logs three React errors for every inline compact-touch alert.
  • Make AlertDialog.doc.mjs, the two fine-pointer story notes, FR6 and the changeset say "under a finger on a compact viewport (768 px wide or 500 px tall by default)".
  • Land or split the parent stack so this change is reviewed as its own 849 lines rather than 25,874.

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

Choose a reason for hiding this comment

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

observeResize calls onResize synchronously when it registers (utils/sharedResizeObserver.ts:89-92), and the registrations at L122-L124 run inside this layout effect, so whenever the row is already laid out (an inline alert under a finger on a compact screen, or a re-run while the alert is open) this flushSync runs during React's commit and React logs "flushSync was called from inside a lifecycle method" three times, as AlertDialog.test.tsx:305 and the inline story in Chromium show. Ignore the registration callback (the effect already measures at L125-L129) and flush only for later observer deliveries; the hook test's mock should also call back on registration like the real one.

guidance: true,
description:
'Use concise, specific action labels. Above 640px, complete buttons may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width.',
'Use concise, specific action labels. On a wide viewport, complete buttons may move onto another row; on a compact one, short labels share a row with Cancel leading, and a label too long for its half stacks the destructive action above Cancel, both filling the footer width.',

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.

With a mouse the actions still stack at 640 px and below (AlertDialog.tsx:284), so the compact row here needs "under a finger" (and the theme's compact definition, 768 px wide or 500 px tall by default).

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
Contributor

Choose a reason for hiding this comment

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

"regardless of pointer type" is not what ships: the shared row needs a coarse pointer on a compact viewport, and a mouse keeps the 640 px stack.

* 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
Contributor

Choose a reason for hiding this comment

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

This is the fine-pointer story: with a mouse at 600 px the two short actions stack (pixel-identical to the base), and compact is the theme's definition, 768 px wide by default, not 744 px. The NarrowFinePointer note at L136 repeats 744 px.

| 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
Contributor

Choose a reason for hiding this comment

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

FR6 is a new requirement in a draft record, and its basis ("owner decision, 2026-10-26", state "settled") is not shown by any comment, review or status on this pull request (spec-owner-approval is pending). The code also applies 320 px on the whole compact definition, so a landscape phone at 844 x 390, whose width is not compact, gets it too; "compact width" should match. The FR6 verification row at L191 has two cells where the table has five.

| `springs.menuClose` | damping ratio 0.85, response 0.35 s | measured: UIMenu closing, iOS 26.2 simulator, 2026-10-23 |
| `springs.subMenuOpen` | damping ratio 0.85, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu opening, iOS 26.2 simulator, 2026-10-24 |
| `springs.subMenuBack` | damping ratio 1, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu header back, iOS 26.2 simulator, 2026-10-24 |
| `alert.present` | mass 1, stiffness 522.35, damping 45.71 | measured: UIAlertController alert appearing, iOS 26.2 simulator, 2026-10-23 |

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.

FR4 says roles a component's own record measures "join through the generated motion role table (DEC-5)"; these two rows put AlertDialog's roles into this record's own normative list instead, which needs an engineering owner's approval (or AlertDialog's record naming them).

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…d tracker; presentation moves to meta#8084) into meta#8095

AlertDialog.tsx and AlertDialogTouch.ts conflict where the group tracking
left the touch layer: the touch layer keeps the motion and UIKit's action
layout (useAlertActionLayout); AlertDialog runs the group tracking itself on
the actions' row, the same element the layout measures.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…01's meta#8637 helper) into meta#8095

# Conflicts:
#	packages/core/src/AlertDialog/AlertDialog.tsx
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 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.

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) or its 320 px compact-touch width, the new draft FR6 cites an owner decision this pull request does not show, the change adds alert.present and alert.dismiss to the AST-094 FR4 role table, and a mouse press now tracks across the actions although AST-094 FR41 keeps a mouse's released behavior. An engineering owner records that decision by commenting /approve-spec 24dc38275df8e0a22564cc67738198a9e90d28c5 on this pull request (the author may do so as an engineering owner), and these fixes are needed either way:

  • Skip the flushSync in useAlertActionLayout.ts for observeResize's synchronous registration callback, which runs inside the layout effect and logs three React errors for every inline compact-touch alert.
  • Make AlertDialog.doc.mjs, the two fine-pointer story notes, FR6 and the changeset say "under a finger on a compact viewport (768 px wide or 500 px tall by default)".
  • Land or split the parent stack so this change is reviewed as its own 863 lines rather than 32,980, and report AlertDialog's base-against-head size.

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

Choose a reason for hiding this comment

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

observeResize calls onResize synchronously when it registers (utils/sharedResizeObserver.ts:89-92), and the registrations at L121-L124 run inside this layout effect, so whenever the row is already laid out (an inline alert under a finger on a compact screen, or a re-run while the alert is open) this flushSync runs during React's commit and React 19 logs 'flushSync was called from inside a lifecycle method' three times. Ignore the registration callback (the effect already measures at L125-L129) and flush only for later observer deliveries; the hook test's mock should call back on registration like the real one.

guidance: true,
description:
'Use concise, specific action labels. Above 640px, complete buttons may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width.',
'Use concise, specific action labels. On a wide viewport, complete buttons may move onto another row; on a compact one, short labels share a row with Cancel leading, and a label too long for its half stacks the destructive action above Cancel, both filling the footer width.',

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.

With a mouse the actions still stack at 640 px and below (AlertDialog.tsx:282), so the compact row here needs 'under a finger' and the theme's compact definition (768 px wide or 500 px tall by default).

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
Contributor

Choose a reason for hiding this comment

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

'regardless of pointer type' is not what ships: the shared row needs a coarse pointer on a compact viewport, and a mouse keeps the 640 px stack.

* 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
Contributor

Choose a reason for hiding this comment

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

This is the fine-pointer story: with a mouse the two short actions stack at 640 px and below, and compact is the theme's definition, 768 px wide by default, not 744 px. The NarrowFinePointer note at L137 repeats 744 px.

| 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
Contributor

Choose a reason for hiding this comment

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

FR6 is a new requirement in a draft record, and its basis ('owner decision, 2026-10-26', state 'settled') is not shown by any comment, review or status on this pull request (spec-owner-approval is pending); motion/uikit/alert.ts:33 still calls the width 'pending owner review'. The code applies 320 px on the whole compact definition, so a landscape phone at 844 x 390, whose width is not compact, gets it too; 'compact width' should match.

| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()") | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` |
| FR6 | `AlertDialog.test.tsx` ("takes UIKit's alert width on a phone under a finger, and Dialog's 400 on a wider touch screen or with a mouse") |

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.

This verification row has two cells where the table has five (representative states, mutation expectation and audit section are missing).

| `springs.menuClose` | damping ratio 0.85, response 0.35 s | measured: UIMenu closing, iOS 26.2 simulator, 2026-10-23 |
| `springs.subMenuOpen` | damping ratio 0.85, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu opening, iOS 26.2 simulator, 2026-10-24 |
| `springs.subMenuBack` | damping ratio 1, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu header back, iOS 26.2 simulator, 2026-10-24 |
| `alert.present` | mass 1, stiffness 522.35, damping 45.71 | measured: UIAlertController alert appearing, iOS 26.2 simulator, 2026-10-23 |

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.

FR4 says roles a component's own record measures 'join through the generated motion role table (DEC-5)'; these two rows put AlertDialog's roles into this current record's own normative list instead, which needs an engineering owner's approval (or AlertDialog's record naming them).

// selectable.
trackingRow: {
touchAction: 'pinch-zoom',
userSelect: 'none',

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.

This row style applies while the actions are tracked under every pointer, so a mouse can no longer select the action labels, and with the tracker a mouse press slid onto the other action now runs it. spec:AST-094 FR41 says 'a mouse keeps its released behavior', and spec:AST-017 FR59 needs fine-pointer before-and-after evidence and an owner decision for a new fine-pointer feel.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 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.

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) or its 320 px compact-touch width, AlertDialog's record does not state its touch exit, the change adds alert.present and alert.dismiss to the AST-094 FR4 role table, and a mouse press now tracks across the actions although AST-094 FR41 keeps a mouse's released behavior. An engineering owner records that decision by commenting /approve-spec cf12f30850583e10133a7c07ff40faf474cc2ede on this pull request (the author may do so as an engineering owner), and these fixes are needed either way:

  • Skip the flushSync in useAlertActionLayout.ts for observeResize's synchronous registration callback, which runs inside the layout effect and logs three React errors for every inline compact-touch alert.
  • Make AlertDialog.doc.mjs say what a mouse gets from the tracking, and make the docs, the two fine-pointer story notes, FR6 and the changeset say "under a finger on a compact viewport (768 px wide or 500 px tall by default)".
  • Land or split the parent stack so this change is reviewed as its own 863 lines rather than 33,346, and report AlertDialog's size against main (58,250 to 90,147 B of gzip JavaScript) as INV6 asks.

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

Choose a reason for hiding this comment

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

observeResize calls this callback synchronously when it registers (utils/sharedResizeObserver.ts:89-92), and the registrations below run inside the layout effect, so for an inline alert, or an open one whose labels change or that turns compact, this flushSync runs during React's commit and React logs 'flushSync was called from inside a lifecycle method' once per observed element (three times in AlertDialog.test.tsx's compact-row test and in each inline story under touch). Skipping the flush for the registration call keeps the first-frame fix, since the effect already measures at :125-129; the hook test's observeResize mock should also call back on registration, as the real one does.

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
Contributor

Choose a reason for hiding this comment

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

This says the actions are tracked under a finger or a pen and that 'A mouse, the keyboard and assistive technology keep the click of each button', but the component tracks every pointer (useActionGroupTracking's default, with no pointerTypes from AlertDialog; AlertDialog.test.tsx 'tracks a mouse too'), as the presentation prop doc and the tracking changeset say. The dense text at :206 repeats it.

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
Contributor

Choose a reason for hiding this comment

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

'regardless of pointer type' is not what the code does: with a mouse the actions still stack at 640 px and below, and the shared row needs a finger on a compact viewport. :44 leaves out the finger too.

Comment on lines +119 to +121
* 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
Contributor

Choose a reason for hiding this comment

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

This is a fine-pointer story, and a mouse never gets the shared row; compact is also the theme's definition (768 px wide or 500 px tall by default), not 744 px. The NarrowFinePointer note at :137 has the same 744 px.

| 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
Contributor

Choose a reason for hiding this comment

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

FR6 cites an 'owner decision, 2026-10-26' that no record or comment on this pull request shows, and it says 'compact width' while the code uses the whole compact definition, so a landscape phone at 844 x 390 also gets 320 px. Its verification row at :191 has two cells where the table has five.

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

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) or its 320 px compact-touch width, AlertDialog's record does not state its touch exit, the change adds alert.present and alert.dismiss to spec:AST-094 FR4's own table, and a mouse press now tracks across the actions where spec:AST-094 FR41 keeps a mouse's released behavior. An engineering owner, the author included if they are one, records that decision by commenting /approve-spec 7aeee534086333fe103931486da2fd019f7bc22e on this pull request. Independently of that decision, please fix the following:

  • Skip the flushSync in useAlertActionLayout during the resize observer's registration call, which logs three React errors per inline compact-touch alert.
  • Stop the first compact-touch open from raising "ResizeObserver loop completed with undelivered notifications." by observing only what the layout decision does not resize.
  • Correct the docs, story notes, FR6 and the motion changeset, which misstate the mouse tracking, the finger condition, the 768 px compact edge 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
Contributor

Choose a reason for hiding this comment

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

observeResize calls this callback synchronously when it registers (utils/sharedResizeObserver.ts:89-92), and the registrations at :122-124 run inside this layout effect, so for an inline alert, or an open one whose labels change or that turns compact, this flushSync runs during React's commit and React logs "flushSync was called from inside a lifecycle method" once per observed element (three times in AlertDialog.test.tsx:343 and in each inline story under touch). Skipping the flush for the registration call keeps the first-frame fix, since the effect already measures at :125-129; the hook test's observeResize mock should also call back on registration, as the real one does.

.map(labelElementOf)
.filter((element): element is HTMLElement => element != null);
const stops = [row, ...labelsShown].map(element =>
observeResize(element, onResize),

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.

On the first compact-touch open the callback commits a row decision that resizes the row and labels observed here, so Chromium skips re-delivering them and dispatches "ResizeObserver loop completed with undelivered notifications." as a window error (Delete story at 390 x 844 under touch, with only a window error listener on the page; none with a mouse or on the base). Observing only what the decision does not resize, such as the labels' single-line widths and a zero-height sizer spanning the row, avoids it.

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
Contributor

Choose a reason for hiding this comment

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

This says the actions are tracked under a finger or a pen and that "A mouse, the keyboard and assistive technology keep the click of each button", but the component tracks every pointer (useActionGroupTracking's default, with no pointerTypes from AlertDialog; AlertDialog.test.tsx:842 "tracks a mouse too"), as the presentation prop doc says. The dense text at :206 repeats it.

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
Contributor

Choose a reason for hiding this comment

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

"regardless of pointer type" is not what ships: with a mouse the actions still stack at 640 px and below, and the shared row needs a finger on a compact viewport. :44 leaves out the finger too.

* 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
Contributor

Choose a reason for hiding this comment

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

This is a fine-pointer story, and a mouse never gets the shared row; compact is also the theme's definition (768 px wide or 500 px tall by default), not 744 px. The NarrowFinePointer note at :137 repeats 744 px.

| 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
Contributor

Choose a reason for hiding this comment

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

FR6 cites an "owner decision, 2026-10-26" that no record or comment on this pull request shows, and it says "compact width" while the code uses the whole compact definition, so a landscape phone at 844 x 390 also gets 320 px. Its verification row at :191 has two cells where the table has five.

| `springs.menuClose` | damping ratio 0.85, response 0.35 s | measured: UIMenu closing, iOS 26.2 simulator, 2026-10-23 |
| `springs.subMenuOpen` | damping ratio 0.85, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu opening, iOS 26.2 simulator, 2026-10-24 |
| `springs.subMenuBack` | damping ratio 1, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu header back, iOS 26.2 simulator, 2026-10-24 |
| `alert.present` | mass 1, stiffness 522.35, damping 45.71 | measured: UIAlertController alert appearing, iOS 26.2 simulator, 2026-10-23 |

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.

FR4 says roles a component's own record measures "join through the generated motion role table (DEC-5)", and the code already registers these through registerMotionTable('alert', …); writing them into this current record's own list as well needs an engineering owner's approval, or AlertDialog's record naming them.

// selectable.
trackingRow: {
touchAction: 'pinch-zoom',
userSelect: 'none',

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.

This row style applies while the actions are tracked under every pointer, so a mouse can no longer select the action labels, and with the tracker a mouse press slid onto the other action now runs it. spec:AST-094 FR41 says "a mouse keeps its released behavior", and spec:AST-017 FR59 needs fine-pointer before-and-after evidence and an owner decision for a new fine-pointer feel.

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

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) or its 320 px compact-touch width, AlertDialog's record does not state its touch exit, the change adds alert.present and alert.dismiss to spec:AST-094 FR4's own table, and a mouse press now tracks across the actions where spec:AST-094 FR41 keeps a mouse's released behavior. An engineering owner, the author included if they are one, records that decision by commenting /approve-spec 0918adb96de91e1ab2b72ce17accf5226f485c46 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 docs, story notes, FR6 and the touch-motion changeset, which misstate the mouse tracking, the finger condition, the 768 px compact edge and when the 320 px width applies; if the mouse tracking stays, add the before-and-after fine-pointer evidence spec:AST-017 FR59 asks for.
  • 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
Contributor

Choose a reason for hiding this comment

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

Committing the decision here resizes the row this callback observes (:122-124) when the 'stack' placeholder becomes a row, so the browser skips re-delivering it and dispatches "ResizeObserver loop completed with undelivered notifications." on window. On the Delete story under touch in Chromium, at 390 x 844 and 844 x 390, a window error listener caught exactly that after one tap; the base caught none. Observing only what the decision does not resize (the labels' single-line widths, the row's container) would avoid it.

const labelsShown = actions
.map(labelElementOf)
.filter((element): element is HTMLElement => element != null);
const stops = [row, ...labelsShown].map(element =>

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.

observeResize calls onResize synchronously when it registers (utils/sharedResizeObserver.ts: "Fires the callback once synchronously on registration"), so with the row already laid out (an inline alert, or an open alert whose effect re-runs) the flushSync above runs inside this layout effect and React logs "flushSync was called from inside a lifecycle method" three times. Both inline stories at 390 x 844 under touch, and the unit test "under a finger on a compact screen, in any engine, puts two short actions in a row", log it. The measure() call at :125-129 already covers the first decision, so the registration calls could skip the commit.

* 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
Contributor

Choose a reason for hiding this comment

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

This is a fine-pointer story: with a mouse the two short actions stack at 640 px and below, and compact is the theme's definition, 768 px wide or 500 px tall by default, not 744 px. The NarrowFinePointer note at :137 repeats 744 px.

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
Contributor

Choose a reason for hiding this comment

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

"regardless of pointer type" is not what ships: the shared row needs a coarse pointer on a compact viewport, and a mouse keeps the 640 px stack (the 600 x 800 mouse frame stacks). :44 leaves out the finger too.

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
Contributor

Choose a reason for hiding this comment

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

This says the actions are tracked "under a finger or a pen" and that "A mouse, the keyboard and assistive technology keep the click of each button", but the component tracks every pointer (useActionGroupTracking's default ['touch', 'pen', 'mouse'], and the test "tracks a mouse too"). :206 says the same.

| 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
Contributor

Choose a reason for hiding this comment

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

FR6 cites an "owner decision, 2026-10-26" that no record or comment on this pull request shows, while motion/uikit/alert.ts:33 still calls the width "proposed, pending owner review"; it also says "compact width" while the code uses the whole compact definition, so a landscape phone at 844 x 390 also gets 320 px.

| `springs.menuClose` | damping ratio 0.85, response 0.35 s | measured: UIMenu closing, iOS 26.2 simulator, 2026-10-23 |
| `springs.subMenuOpen` | damping ratio 0.85, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu opening, iOS 26.2 simulator, 2026-10-24 |
| `springs.subMenuBack` | damping ratio 1, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu header back, iOS 26.2 simulator, 2026-10-24 |
| `alert.present` | mass 1, stiffness 522.35, damping 45.71 | measured: UIAlertController alert appearing, iOS 26.2 simulator, 2026-10-23 |

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.

FR4 says roles a component's own record measures "join through the generated motion role table (DEC-5)", and the code already registers these through registerMotionTable('alert', …); writing them into this current record's own table needs an engineering owner's decision, or the rows can move to AlertDialog's record.

// selectable.
trackingRow: {
touchAction: 'pinch-zoom',
userSelect: 'none',

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.

This applies whenever the actions are tracked, under every pointer, so a mouse can no longer select the action labels, and with the tracker a mouse press slid onto the other action now runs it. spec:AST-094 FR41 says "a mouse keeps its released behavior", and spec:AST-017 FR59 asks for an owner decision and before-and-after fine-pointer evidence for such a change.

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

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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.

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

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
Contributor

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
Contributor

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
Contributor

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.

Comment thread .changeset/alertdialog-touch-motion.md Outdated
'@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
Contributor

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.

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

Approval waits on an engineering owner's spec decision, and several fixes are needed whatever that decision is.

This change intentionally replaces AlertDialog's current small-screen stack (FR3, which puts the action above Cancel) with a compact-touch row, and its resize observers go beyond the listeners PR1 lists, so an engineering owner needs to update FR3 and PR1 or keep them as they are. The owner also needs to approve or reject the springs.alertPresent and springs.alertDismiss rows written into spec:AST-094 FR4's own table, which FR4 and DEC-5 otherwise route through the generated motion role table. An engineering owner records the decision by commenting /approve-spec 1658634f6ddb39712b7851956fc51aefcaeb51d3 on this pull request; the author may do so as an engineering owner.

Required fixes:

  • Desktop behavior change needs an owner decision: AlertDialog.tsx tracks a mouse across the actions (a mouse drag from Cancel onto Delete now runs Delete) and turns off text selection on the action row for a mouse, so gate both to touch input as main does, or cite the owner decision that covers it.
  • Stop the first compact-touch open from firing "ResizeObserver loop completed with undelivered notifications" by not committing a layout change that resizes the row the observer watches (useAlertActionLayout.ts).
  • Drop flushSync from the observer's synchronous registration call, which runs inside the layout effect and logs three React errors for an inline compact-touch alert.
  • Correct AlertDialog.doc.mjs (a mouse does not keep each button's click, and the row is not "regardless of pointer type"), the AlertDialog stories (the compact edge is the theme's definition, 768 px by default, and the row needs a coarse pointer), the two changesets that say no component uses the action-group tracker or its marker, and the compact-row changeset, which calls a change to the specified stack a fix.
  • Rebase onto main (or retarget onto #8084) so the diff carries the roughly 900-line AlertDialog change instead of 32,183 lines of parent stack that no longer merges.

[Automated review]

// Tracked while presented, leaving included: a press during either
// transition is taken and dropped (`isInert`) rather than reaching the
// button under it.
const isTracking = motion.isPresented && isInline !== true;

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.

Desktop behavior change needs an owner decision: isTracking has no touch condition and the tracking call passes no pointerTypes, so useActionGroupTracking tracks a mouse too and a mouse drag from Cancel onto Delete runs Delete. Gate it as main does (isTracking requires isTouch, pointerTypes: ['touch', 'pen'], trackingRow only under touch), or cite the decision that covers a mouse.

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
Contributor

Choose a reason for hiding this comment

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

"A mouse, the keyboard and assistive technology keep the click of each button" does not match AlertDialog.tsx, which tracks the actions under every pointer; and the row needs a coarse pointer, so a mouse at a compact width still gets the 640 px stack.

* 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
Contributor

Choose a reason for hiding this comment

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

The code uses the theme's compact definition (768 px wide or 500 px tall by default), not 744 px, and the shared row needs a coarse pointer; this fine-pointer story keeps the stack at 640 px and below.

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

Approval waits on an engineering owner's spec decision, and a few fixes are needed whatever that decision is.

This change intentionally replaces AlertDialog's current small-screen stack (FR3, which puts the action above Cancel) with a compact-touch row, and adds a title resize observer and a font listener beyond the listeners PR1 lists, so an engineering owner needs to update FR3 and PR1 or keep them as they are. The same decision covers shipping FR6 in part (a close or reopen mid-motion restarts from rest instead of turning back) and the new DEC-4 and DEC-5, whose decider has no record yet. An engineering owner records the decision by commenting /approve-spec 1f61aeadb239183aabf1fa61bdcbdc3fc250163b on this pull request; the author may do so as an engineering owner.

Required fixes:

  • An inline AlertDialog mounted with isOpen={false} keeps its actions stacked after it opens under touch, because useAlertActionLayout registers its observer only if the title already exists; register it from a callback ref on the title.
  • useActionGroupTracking gains an isInert option that no caller passes; drop it, or pass it from AlertDialog if presses during the presentation need it.
  • The proposed PR1 names the title's resize observation but not the document.fonts loadingdone listener the hook also adds.
  • At 2,837 authored lines the diff carries all of #8084; declare the stack (Stack: #8084 → #8095 in both descriptions) or land #8084 first.
  • Current FR6's verification asks for WebKit runs and real-device iOS Safari and Android Chrome runs, and none exists at this head.

The browser specs pass in Chromium apart from one layout case that depends on a 7 px font margin ("Delete all items" at 390 px); a label with more room would make it robust.

[Automated review]

useIsomorphicLayoutEffect(() => {
const row = rowRef.current;
const watched = watchRef.current;
if (!isCompact || row == null || watched == 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.

An inline alert renders nothing while closed (Dialog.tsx:817-820), so when it mounts with isOpen={false} this returns early and the effect never runs again: its dependencies don't change when the title mounts on open. Under touch on a 390 px screen the Desktop Fine Pointer story, opened through its isOpen control, keeps Delete above Cancel, while the same story mounted open shares the row. Register the observer and the font listener from a callback ref on the title, so attach and detach start and stop them.

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 at 93ae42e: the title comes through a callback ref, so its mount starts the observation and the font listener. A unit case and a browser case (the inline story mounted with isOpen false, then opened) fail without it.

* browser makes no click of it, but it paints and runs nothing. UIKit drops
* touches during a presentation transition this way.
*/
isInert?: () => boolean;

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 caller passes isInert: AlertDialog drops presses through Dialog's entrance hold and the exit's holdInput. Drop the option and its test here, or pass it from AlertDialog if presses during the presentation need 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.

Gone: #8084 (92e94d3) dropped the option, and this pull request now builds on it.

two queries (a coarse primary pointer and a compact viewport), and the press
tracking `family:action-groups` FR5 gives its actions. On a compact window
under the touch presentation it also observes its title's size on the
shared resize observer, to lay its actions out again. Dialog owns the modal

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.

useAlertActionLayout.ts:131 also adds a document.fonts loadingdone listener; list it here so PR1 names every listener AlertDialog adds.

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.

Listed at 93ae42e: PR1 names the web-font listener beside the title's resize observation.

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

Approval waits on an engineering owner's spec decision, and a few fixes are needed whatever that decision is.

This change intentionally replaces AlertDialog's current small-screen stack (FR3, which puts the action above Cancel) with a compact-touch row, and adds a title resize observer and a font listener beyond the listeners PR1 lists, so an engineering owner needs to update FR3 and PR1 or keep them as they are. The same decision covers shipping FR6 in part (a close or reopen mid-motion restarts from rest instead of turning back) and the new DEC-4 and DEC-5, whose decider has no record yet. An engineering owner records the decision by commenting /approve-spec 93ae42e2d7c15dea536b10a61347a7e647f9ffbe on this pull request; the author may do so as an engineering owner.

Required fixes:

  • At 2,867 authored lines the diff carries all of #8084, and #8084 declares Stack: #8084 → #8095 but this description does not, so add the identical line here or land #8084 first.
  • Miss: .changeset/alertdialog-compact-action-row.md says long labels stack "as before", but on a landscape phone (844 × 390) main shows Cancel above the wrapped action in a trailing row and this change stacks the action above Cancel, so say the stack is new there.
  • Current FR6's verification asks for real-device iOS Safari and Android Chrome runs, and the runs on record predate this head's exit hold, so attach both at this head.

[Automated review]

'@astryxdesign/core': patch
---

[feat] AlertDialog: under a finger on a compact screen (the theme's compact definition: by default narrower than 768px or shorter than 500px), the two actions share a row in equal halves, Cancel leading, when each label fits its half on one line. When one does not, they stack, the destructive action above Cancel, as before. Right to left, Cancel leads on the right. With a mouse nothing changes: the actions stack at 640px and below.

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: "as before" holds on a portrait phone only. On a landscape phone (844 × 390, compact by its height, wider than 640 px) main shows Cancel above the wrapped action in a trailing row, and this change stacks the action above Cancel at full width. Suggest: "When one does not, they stack, the destructive action above Cancel: as before on a portrait phone, and new on a landscape phone, where the actions used to sit in a trailing row."

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 at d46bf42 with your wording: as before on a portrait phone, and new on a landscape phone, where the actions used to sit in a trailing row. The record's compatibility note says the same.

stackedAction: {
width: '100%',
},
// In a compact touch row the two actions split the row evenly.

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 restates flexGrow: 1, flexBasis: 0. Delete it, or name the style equalHalfAction so the name carries 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.

Renamed to equalHalfAction at d46bf42, and the comment is gone.

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

Approval of this stack waits on an engineering owner's spec decision for #8095 and on device evidence for #8084.

#8084: changes requested.

  • Its FR6/PR2 verification row and spec:AST-094 DEC-27 require the iOS Safari device pass and the Android Chrome emulator recording at the exact head, and the runs in its description predate that head, so attach both at its current head.

#8095: waiting on an engineering owner. This change intentionally replaces component:AlertDialog FR3's small-screen stack with the compact-touch row, extends PR1 with a title resize observer and a font listener, and adds DEC-5, so an engineering owner needs to update FR3 and PR1, and say whether spec:AST-012 FR9's compact definition may choose an action layout, or keep them as they are. An engineering owner records the decision by commenting /approve-spec d46bf426ad07d42e04e392527d5b1160d410a3d5 on this pull request; the author may do so as an engineering owner.

Whole stack: the two pull requests fit together with no blocking finding between them, and a mouse sees no change. A pull request lands only when it and every pull request below it are approved.

[Automated review]

| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()"); FR3's compact touch layout: `useAlertActionLayout.test.tsx`, `AlertDialog.test.tsx` ("responsive actions") and `AlertDialog.layout.interaction.browser.spec.ts` | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` |
| FR6, PR2 | `useAlertMotion.test.tsx` (the touch look, the start scale, an exit that closes at once at 0 ms or at its end, the backstop, a reopen mid-exit, a server-rendered open that opens at rest); `AlertDialog.motion.interaction.browser.spec.ts` under a coarse pointer in Chromium and WebKit (the presentation on the theme's entrance tokens, the fade on the exit tokens with the dialog exit switch off and on, the viewport start, reduced motion with its 200 ms dimming and exit) and in Chromium frames through a 1 s main-thread block; the same 1 s block on the iOS simulator in Safari; the owner's real-device pass on iOS Safari at the exact head, and with it the Android Chrome emulator recording at the exact head (Chrome with Gboard; default, Remove animations and a slow profile; `spec:AST-094` DEC-27) | present, dismiss with the dialog exit switch off and on, reopen during dismiss, an open in server markup, Reduce Motion, a fine pointer, a narrow viewport | a timing off the theme's tokens, a start outside the viewport, a dismissal without its fade, a scale or a dimming over 200 ms under Reduce Motion, an entrance replayed after hydration, changed motion under a fine pointer, or an added element fails. No test checks a turn-back on interruption, which does not ship. | `audit:AlertDialog/behavior` |

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.

This row asks for the iOS Safari device pass and the Android emulator recording at the exact head (with spec:AST-094 DEC-27), but the runs #8084's description cites predate its head. Attach both at #8084's current head.

for (const dialogExit of ['off', 'on'] as const) {
if (dialogExit === 'on') {
// A theme that turns the pointer dialog exit on: the touch exit stays.
await setToken(page, '--duration-exit-dialog', `${EXIT_MS}ms`);

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: nothing in packages/core/src reads --duration-exit-dialog yet, so this "dialog exit on" leg cannot fail. Drop it, or assert it once Dialog plays its exit.

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.

Added in #8084 at a3f77df, which this pull request now builds on: the test says that Dialog does not read this switch yet, so the "on" leg repeats the "off" one until Dialog plays a pointer exit.

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

Approval of this stack waits on an engineering owner's spec decision for #8095 and on device evidence for #8084.

#8084: changes requested.

  • Its FR6/PR2 verification row and spec:AST-094 DEC-27 require the iOS Safari real-device pass and the Android Chrome emulator recording at the exact head, and the runs in its description ran at earlier commits (the iPhone pass before the exit-time input hold), so attach both at its current head.

#8095: waiting on an engineering owner. This change intentionally replaces component:AlertDialog FR3's small-screen stack with the compact-touch row, extends PR1 with a title resize observer and a font listener, and adds DEC-5, so an engineering owner needs to update FR3 and PR1, and say whether spec:AST-012 FR9's compact definition may choose an action layout, or keep them as they are. An engineering owner records the decision by commenting /approve-spec a907cc40e9a006bd828704bf1134e5951f1af832 on this pull request; the author may do so as an engineering owner.

Whole stack: the two pull requests fit together with no blocking finding between them, and a mouse sees no change. A pull request lands only when it and every pull request below it are approved.

[Automated review]

| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()"); FR3's compact touch layout: `useAlertActionLayout.test.tsx`, `AlertDialog.test.tsx` ("responsive actions") and `AlertDialog.layout.interaction.browser.spec.ts` | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` |
| FR6, PR2 | `useAlertMotion.test.tsx` (the touch look, the start scale, an exit that closes at once at 0 ms or at its end, the backstop, a reopen mid-exit, a server-rendered open that opens at rest); `AlertDialog.motion.interaction.browser.spec.ts` under a coarse pointer in Chromium and WebKit (the presentation on the theme's entrance tokens, the fade on the exit tokens with the dialog exit switch off and on, the viewport start, reduced motion with its 200 ms dimming and exit) and in Chromium frames through a 1 s main-thread block; the same 1 s block on the iOS simulator in Safari; the owner's real-device pass on iOS Safari at the exact head, and with it the Android Chrome emulator recording at the exact head (Chrome with Gboard; default, Remove animations and a slow profile; `spec:AST-094` DEC-27) | present, dismiss with the dialog exit switch off and on, reopen during dismiss, an open in server markup, Reduce Motion, a fine pointer, a narrow viewport | a timing off the theme's tokens, a start outside the viewport, a dismissal without its fade, a scale or a dimming over 200 ms under Reduce Motion, an entrance replayed after hydration, changed motion under a fine pointer, or an added element fails. No test checks a turn-back on interruption, which does not ship. | `audit:AlertDialog/behavior` |

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.

This row asks for the iOS Safari real-device pass and the Android emulator recording at the exact head (with spec:AST-094 DEC-27), but the runs #8084's description cites ran at earlier commits; the iPhone pass predates the exit-time input hold. Attach both at #8084's current head.

await driver.tap(cancelPoint);
// The tap must land inside the presentation for this to prove anything.
const {start, end, touches} = await readTapLog();
expect(touches[0] - start).toBeLessThan(end);

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, non-blocking: start and end are read in a second evaluate after waitForAnimation returns, so when the entrance has already ended they are NaN and this guard fails (2 of 5 Chromium runs on a slow one-CPU runner). Recording the entrance's timing from an animationstart listener added before the tap, as the mouse case does, keeps the guard meaningful.

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 #8084 at 4977c3b, which this pull request now builds on: the case holds the entrance's animation from its animationstart, so the check reads real times, and gives the entrance 2 s, as it gives the exit, so a tap can land inside it on any runner. On one CPU the previous version failed 16 of 20 at that check and the new one passed 20 of 20.

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.

I'll run a full review to re-check this with your note about the change in #8084.

vjeux added 2 commits November 4, 2026 16:13
Under a touch presentation AlertDialog takes UIKit's alert look on the
theme's motion tokens (component:AlertDialog FR6, DEC-4; spec:AST-094
FR45). It scales from 1.2 to 1 and fades in with its dimming on Dialog's
entrance duration and curve. It leaves by a fade of the alert and its
dimming on the theme's exit duration and easing (spec:AST-090 FR12),
even while a theme leaves its dialog exit off (AST-090 FR14, AST-094
FR7). A close during the presentation, or a reopen during the fade,
restarts from rest; FR6 records that gap. Under reduced motion the alert
and its dimming only fade in, over the reduced 200 ms cross-fade, and
the fade out stays within 200 ms. An alert open in the server markup
opens at rest after hydration.

While the alert fades out, the Layer module's input hold holds its
input with the layer scope: its content is inert, and the hold's shield
takes every press, so a tap, a click, a key or an assistive-technology
activation does nothing (FR7). Escape, the dialog's own cancel event, is
dropped until the alert is hidden. A reopen during the fade gives the
content back, with focus on the action that had it. The toast viewport,
which lives in the latest open modal, stays live and goes back above the
hold's shield, so a toast the action raises takes a tap and is read
during the fade (spec:AST-003 FR16). Dialog already holds a press while
the alert appears. On a phone the alert is 320 px wide in
every theme (FR9); a caller's width still wins. A fine primary pointer
keeps today's motion, and a mouse press on the actions stays native.
Under a finger on a compact screen (the theme's compact definition),
the alert's two actions share a row in equal halves, Cancel leading,
when each label fits its half on one line, as an iOS alert's actions
do. A label too long for its half stacks them, the destructive action
above Cancel, as before. Visual, DOM and tab order agree in every
layout. With a mouse nothing changes.

The decision reads each label's single-line width and the row's width.
It reads them again when the alert's title changes size (its first
display, a rotation, a zoom), when a web font finishes loading, or when
a label changes. The title keeps its size when the actions' layout
changes, so a decision never resizes what it watches. The title comes
through a callback ref, so an alert mounted closed decides when it
opens. A loading action is measured by its label, not by its spinner's
text.

Spec: component:AlertDialog FR3, PR1 and DEC-5, the spec owner's
decision of 2026-11-04.
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 8226b5e

This branch was successfully deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants