Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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.tsL80–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'); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
PR Analysis Report
Generated by PR Enrichment workflow | View current CI run |
5a2e10f to
89bac54
Compare
There was a problem hiding this comment.
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.tsL80–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'); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
89bac54 to
7fcc382
Compare
There was a problem hiding this comment.
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.tsL80–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]
… 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`
7fcc382 to
1a2438d
Compare
1a2438d to
a243866
Compare
|
Addressed at a243866:
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. |
There was a problem hiding this comment.
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
flushSyncerrors (useAlertActionLayout.tsL105–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]
| flushSync(() => { | ||
| setLayout(next); | ||
| }); |
There was a problem hiding this comment.
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.
| const stops = [row, ...labelsShown].map(element => | ||
| observeResize(element, onResize), | ||
| ); |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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)'; |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
| const handle = panel.animate( | ||
| {opacity: 1, scale: 1}, | ||
| {spring: springs.alertPresent}, | ||
| {crossfade: springs.alertPresent}, | ||
| ); |
There was a problem hiding this comment.
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.
a243866 to
aa32c53
Compare
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayout.tsonly 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.mjsand 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(() => { |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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 |
There was a problem hiding this comment.
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.
…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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayout.tsforobserveResize'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(() => { |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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 |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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).
…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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayout.tsforobserveResize'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(() => { |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
'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 |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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") | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayout.tsforobserveResize's synchronous registration callback, which runs inside the layout effect and logs three React errors for every inline compact-touch alert. - Make
AlertDialog.doc.mjssay 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(() => { |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
'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.
| * 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. |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayoutduring 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(() => { |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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 |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayoutduring 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-017FR59 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(() => { |
There was a problem hiding this comment.
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 => |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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.', |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayoutduring 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(() => { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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 | |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
flushSyncinuseAlertActionLayoutfor 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]
| const stops = [row, ...labelsShown].map(element => | ||
| observeResize(element, onResize), | ||
| ); |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
The row applies only under a coarse pointer and uses the theme's compact definition (768 px wide or 500 px tall by default), so for this fine-pointer story a compact viewport keeps the 640 px stack. The same 744 px figure appears at line 108.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] AlertDialog: under a finger the alert appears and leaves the way an iOS alert does. It scales down into place from 1.2 while it and the dimming fade in, and it fades out with the dimming when it closes, on the alert's measured spring; the animation keeps running when the page is busy. While it appears a touch does nothing (keys and assistive technology still act), and while it leaves nothing reaches it: a touch, a click, a key or an assistive-technology activation does nothing. With reduced motion it only cross-fades. Defaults under a finger: on a phone (a compact width) the width with no `width` prop goes from 400 px to UIKit's 320 px in every theme, and this motion replaces the Dialog's entrance (rising 16 px and scaling up from 0.95 while it fades in) and its exit. With a mouse nothing changes. A `width` you pass still wins on a phone. The actions are tracked as one group under every pointer. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.tsxtracks 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 asmaindoes, 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
flushSyncfrom 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; |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
"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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, becauseuseAlertActionLayoutregisters its observer only if the title already exists; register it from a callback ref on the title. useActionGroupTrackinggains anisInertoption 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.fontsloadingdonelistener the hook also adds. - At 2,837 authored lines the diff carries all of #8084; declare the stack (
Stack: #8084 → #8095in 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
useAlertActionLayout.ts:131 also adds a document.fonts loadingdone listener; list it here so PR1 names every listener AlertDialog adds.
There was a problem hiding this comment.
Listed at 93ae42e: PR1 names the web-font listener beside the title's resize observation.
There was a problem hiding this comment.
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 → #8095but this description does not, so add the identical line here or land #8084 first. - Miss:
.changeset/alertdialog-compact-action-row.mdsays long labels stack "as before", but on a landscape phone (844 × 390)mainshows 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. |
There was a problem hiding this comment.
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."
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Non-blocking (INV29): this restates flexGrow: 1, flexBasis: 0. Delete it, or name the style equalHalfAction so the name carries it.
There was a problem hiding this comment.
Renamed to equalHalfAction at d46bf42, and the comment is gone.
There was a problem hiding this comment.
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-094DEC-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` | |
| 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`); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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-094DEC-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` | |
There was a problem hiding this comment.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I'll run a full review to re-check this with your note about the change in #8084.
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.
|
/approve-spec 8226b5e |
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
Current specification
Specs decision, 2026-11-04: compact touch screens get the shared row (
component:AlertDialogDEC-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'sStackgap token, and the theme's type and colors. Nothing of iOS's styling is copied.spec:AST-012FR9 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
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.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:
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.
Review follow-ups
The earlier review's items, on this rebuild:
pointerTypes: ['touch', 'pen']), as on main. The usage doc now says so; it still read "every pointer".flushSyncduring 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.[feat].component:AlertDialogFR3, PR1 and DEC-5 record the specs decision (2026-11-04).This round's review, at 1f61aea:
isOpenfalse, then opened) each fail without that.isInerttracker option.And at 93ae42e:
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
Testing
Local gates at 8226b5e, on #8084's head 62c9b4a (main 252b45b):
CI=true pnpm lint(no errors), the build,pnpm typecheck, Storybook,pnpm test:ui(20,121),check:knowledgeandcheck:repopass. Inpnpm test:node16,453 passed, and only feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084's twoci-interaction-webkit.test.mjscases failed, both onmodalOutlet.ts; ci: the WebKit interaction lane covers the modal outlet #10018 clears them.api:report --checkreports 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.alertController.actions.rowShare(measured, iOS 26.2); the decision's is DEC-5.