[Feature] AlertDialog: native <dialog>, match shadcn (Base UI + vega)

Merged
CI
3d ago
+572-78

AlertDialog now renders a native <dialog role="alertdialog"> opened with showModal() and matches shadcn's AlertDialog (Base UI + vega). It follows the approach Dialog took in #572. The browser closes it on Esc, moves focus to Cancel, keeps Tab inside, makes the page inert, and returns focus to the trigger on close. A click on the backdrop leaves it open, as APG asks for an alert dialog.

On main, Esc did nothing, focus stayed on the trigger behind the overlay, Tab reached the page, focus fell to <body> after Cancel, and the dialog had no accessible name.

Before and after

Columns: RubyUI on main, RubyUI on this PR, shadcn (Base UI + vega).

Open, light
Open, dark
After Esc, light
Focus after opening, light

What changed

  • AlertDialogContent is a <dialog role="alertdialog">. The overlay is its ::backdrop (bg-black/10, backdrop-blur-xs). The <template> that was copied into <body> on every open is gone.
  • AlertDialogContent gives the <dialog> an id and, through the Phlex context, ids for AlertDialogTitle and AlertDialogDescription; the dialog gets aria-labelledby/aria-describedby at render time, only for the parts that rendered. Ids and ARIA the author passes win. The controller only sets aria-haspopup, aria-expanded and aria-controls on the button inside AlertDialogTrigger.
  • The controller wires the <dialog> and the trigger through target callbacks, so swapping either one (a Turbo Stream, say) keeps Esc, cleanup and the trigger's ARIA working. A dialog restored open from Turbo's cache is closed, the page scroll lock is released only when no modal dialog is left, and each exit animation carries a token so a stale one never closes a reopened dialog.
  • Enter and exit animations (fade and zoom, 100ms) run on the dialog and its backdrop. Esc and Cancel wait for the exit animation before closing.
  • AlertDialogContent takes size: :default | :sm. :sm is narrower and puts the footer buttons in two columns.
  • New AlertDialogMedia for an icon next to the title.
  • Look follows vega: ring-1 ring-foreground/10 rounded-xl gap-6 bg-popover on the content, grid header, font-medium title, text-balance description with link styles, gap-2 footer.
  • AlertDialog renders a display: contents wrapper, so it no longer adds an inline-block box around the trigger.
  • Audit: audit/component_map.yml maps the new selectors, Media and the size/open props. audit/divergences.yml registers five differences (native <dialog>, trigger wrapper, root wrapper, aria-controls while closed, native modality). The MCP registry is rebuilt.

Trigger, Cancel and Action still differ from vega in padding, border and shadow. That comes from RubyUI::Button and will be fixed in its own PR.

Breaking changes

The Ruby API is the same, but the rendered HTML changes:

  • The dialog is a <dialog> that stays in the DOM. CSS or tests that looked for the copy appended to <body>, or for the content target, need updating.
  • AlertDialogCancel drops mt-2 sm:mt-0; the footer spaces buttons with gap-2.
  • The overlay goes from bg-black/80 to bg-black/10.
  • AlertDialog must not sit inside a <form>. The dialog now renders in place, and HTML drops a nested form: a button_to in AlertDialogAction would submit the outer form instead. On main the <template> kept it out. The docs have a new "Inside a form" section, also in the MCP registry; render the AlertDialog after the form or use the button's form attribute.

Audit

main
shadcn audit  ruby-ui/ruby_ui
  base e5f38442  head e5f38442  shadcn c0d63c33
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment  browser driven with Playwright headless Chromium 1280×800 (chrome-devtools MCP failed to connect)
✅ Theme  neutral tokens match shadcn
AlertDialog  (ref: bases/base/ui/alert-dialog.tsx + style-vega.css @ c0d63c33)
  ❌ Look       background-color: oklch(0.145 0 0); background-color: oklch(1 0 0); border-bottom-color: oklch(0.922 0 0); border-left-color: oklch(0.922 0 0); border-right-color: oklch(0.922 0 0); border-top-color: oklch(0.922 0 0); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.556 0 0) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.708 0 0) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; column-gap: normal; gap: normal; padding-bottom: 8px; padding-left: 16px; padding-right: 16px; padding-top: 8px; row-gap: normal
               vega: background-color: oklab(1 0 0 / 0.045); background-color: oklab(1 0 0 / 0.075); background-color: oklch(0.97 0 0); border-bottom-color: oklch(0.708 0 0); border-left-color: oklch(0.708 0 0); border-right-color: oklch(0.708 0 0); border-top-color: oklch(0.708 0 0); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.556 0 0 / 0.5) 0px 0px 0px 3px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.708 0 0 / 0.5) 0px 0px 0px 3px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; column-gap: 6px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-right: 10px; padding-top: 0px; row-gap: 6px
               AlertDialogTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — the difference comes from RubyUI::Button (px-4 py-2 shadow-sm/shadow vs vega cn-button px-2.5 gap-1.5 border, dark outline bg-input/30); the fix belongs to Button, AlertDialog only passes variant through.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       AlertDialogContent: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ❌ Look       background-color: oklch(0.145 0 0); border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-bottom-width: 1px; border-left-width: 1px; border-right-width: 1px; border-top-left-radius: 10px; border-top-right-radius: 10px; border-top-width: 1px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.1) 0px 4p…; column-gap: 16px; display: flex; gap: 16px; row-gap: 16px
               vega: background-color: oklch(0.205 0 0); border-bottom-left-radius: 14px; border-bottom-right-radius: 14px; border-bottom-width: 0px; border-left-width: 0px; border-right-width: 0px; border-top-left-radius: 14px; border-top-right-radius: 14px; border-top-width: 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.145 0 0 / 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0) 0px 0px 0px 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.985 0 0 / 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0) 0px 0px 0px 0px; column-gap: 24px; display: grid; gap: 24px; row-gap: 24px
               AlertDialogContent — open/light, open/dark — RubyUI `flex flex-col gap-4 border bg-background shadow-lg sm:rounded-lg duration-200` vs vega `grid gap-6 bg-popover ring-1 ring-foreground/10 rounded-xl duration-100`. Dark bg differs because RubyUI uses --background (0.145) and vega --popover (0.205).
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       AlertDialogHeader: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ❌ Look       column-gap: normal; display: flex; gap: normal; row-gap: normal
               vega: column-gap: 6px; display: grid; gap: 6px; row-gap: 6px
               AlertDialogHeader — open/light, open/dark — RubyUI `flex flex-col space-y-2 text-center sm:text-left` vs vega `grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center sm:…:place-items-start sm:…:text-left`; title/description shrink to content width on shadcn (212px vs 462px).
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       AlertDialogFooter: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ✅ Look       column-gap: normal; gap: normal; row-gap: normal
               AlertDialogFooter — open/light, open/dark — override: RubyUI's sm:space-x-2 + mt-2 sm:mt-0 renders the same 8px between Cancel and Continue as vega's gap-2 (button rects at 1280px: 770→778 vs 781→789). Verified at 1280×800 only.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       AlertDialogTitle: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ Look       AlertDialogDescription: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ Look       AlertDialogAction: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ❌ Look       border-bottom-color: oklch(0.922 0 0); border-bottom-color: oklch(1 0 0 / 0.1); border-bottom-width: 0px; border-left-color: oklch(0.922 0 0); border-left-color: oklch(1 0 0 / 0.1); border-left-width: 0px; border-right-color: oklch(0.922 0 0); border-right-color: oklch(1 0 0 / 0.1); border-right-width: 0px; border-top-color: oklch(0.922 0 0); border-top-color: oklch(1 0 0 / 0.1); border-top-width: 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; column-gap: normal; gap: normal; padding-bottom: 8px; padding-left: 16px; padding-right: 16px; padding-top: 8px; row-gap: normal
               vega: border-bottom-color: rgba(0, 0, 0, 0); border-bottom-width: 1px; border-left-color: rgba(0, 0, 0, 0); border-left-width: 1px; border-right-color: rgba(0, 0, 0, 0); border-right-width: 1px; border-top-color: rgba(0, 0, 0, 0); border-top-width: 1px; box-shadow: none; column-gap: 6px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-right: 10px; padding-top: 0px; row-gap: 6px
               AlertDialogAction — open/light, open/dark — the difference comes from RubyUI::Button (px-4 py-2 shadow-sm/shadow vs vega cn-button px-2.5 gap-1.5 border, dark outline bg-input/30); the fix belongs to Button, AlertDialog only passes variant through.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       AlertDialogCancel: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ❌ Look       background-color: oklch(0.145 0 0); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; column-gap: normal; gap: normal; padding-bottom: 8px; padding-left: 16px; padding-right: 16px; padding-top: 8px; row-gap: normal
               vega: background-color: oklab(1 0 0 / 0.045); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; column-gap: 6px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-right: 10px; padding-top: 0px; row-gap: 6px
               AlertDialogCancel — open/light, open/dark — the difference comes from RubyUI::Button (px-4 py-2 shadow-sm/shadow vs vega cn-button px-2.5 gap-1.5 border, dark outline bg-input/30); the fix belongs to Button, AlertDialog only passes variant through.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family, font-weight, height, letter-spacing, width
               AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel — explained by environment diff: font — except AlertDialogTitle font-weight (600 vs 500), which is a class difference (font-semibold vs font-medium) graded on its own Look line.
  ⚠️ ARIA       aria-controls: (none); aria-expanded: (none); aria-haspopup: (none); snapshot: only here: - button "Delete account"
               shadcn: aria-controls: resolves; aria-expanded: false; aria-expanded: true; aria-haspopup: dialog; snapshot: (none)
               AlertDialogTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark
               ↳ accessibility tree (shadcn-audit collect)
  ⏸ ARIA       AlertDialogContent: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⚠️ ARIA       aria-describedby: (none); snapshot: only here: - alertdialog:
               shadcn: aria-describedby: This action cannot be undone.; snapshot: only here: - alertdialog "Are you absolutely sure?":
               AlertDialogContent — open/light, open/dark
               ↳ accessibility tree (shadcn-audit collect)
  ⏸ ARIA       AlertDialogHeader: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ ARIA       AlertDialogFooter: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ ARIA       AlertDialogTitle: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ ARIA       AlertDialogDescription: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ ARIA       AlertDialogAction: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ⏸ ARIA       AlertDialogCancel: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark — explained by the Behavior finding: Esc doesn't close RubyUI's AlertDialog, so its parts stay visible in after-esc.
  ❌ Look       overlay bg-black/80 backdrop-blur-sm (oklab(0 0 0 / 0.8), blur(8px))
               vega: overlay bg-black/10 supports-backdrop-filter:backdrop-blur-xs (oklab(0 0 0 / 0.1), blur(4px))
               AlertDialogOverlay isn't in component_map parts, so collect didn't grade it; read from getComputedStyle in both sandboxes (open, light).
               ↳ style-vega.css:20
  ❌ Look       AlertDialogTitle text-lg font-semibold (font-weight 600)
               vega: text-lg font-medium font-heading (font-weight 500)
               Computed font-weight 600 vs 500 in light and dark.
               ↳ style-vega.css:36
  ❌ Look       AlertDialogDescription text-sm text-muted-foreground (text-wrap: wrap)
               vega: text-sm text-muted-foreground text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground (text-wrap: pretty)
               Color and size match; text wrapping and link styling missing.
               ↳ style-vega.css:40
  ✅ Look       AlertDialogContent max-w-lg (512px at 1280)
               Same width and centering at 1280×800 (rect x=384 w=512 on both).
               ↳ style-vega.css:24
  ❌ HTML       AlertDialogTrigger = div.inline-block[data-action] wrapping the Button
               shadcn: AlertDialogTrigger is the button itself (render={<Button/>}), data-slot=alert-dialog-trigger
               Extra wrapper div; the action lives on the div, not the button.
               ↳ bases/base/ui/alert-dialog.tsx:13-17
  ❌ HTML       content kept in <template>, innerHTML copied to end of <body> on each open; div[data-controller] > overlay div + div[role=alertdialog] (no tabindex, no data-slot); removed from DOM on Cancel
               shadcn: Base UI portal div[data-slot=alert-dialog-portal] > overlay (role=presentation) + focus guards + div[role=alertdialog][tabindex=-1][data-slot=alert-dialog-content][data-size]
               Title h2 and Description p match. Each open inserts a fresh copy and a new Stimulus controller instance; any ids inside the content would be duplicated (template + copy).
               ↳ bases/base/ui/alert-dialog.tsx:41-62
  ⏸ HTML       no AlertDialogMedia
               shadcn: AlertDialogMedia (data-slot=alert-dialog-media, size-16 bg-muted rounded-md)
               Map gap: shadcn exports AlertDialogMedia (bases/base/ui/alert-dialog.tsx:93-104, style-vega.css:32) and AlertDialogOverlay/Portal, but audit/component_map.yml lists none of them.
  ❌ Props      AlertDialogContent: no size kwarg
               shadcn: AlertDialogContent size="default" | "sm" (data-size drives max-w-xs and the 2-col footer)
               map: size → null.
               ↳ bases/base/ui/alert-dialog.tsx:41-47
  ✅ Props      AlertDialogCancel variant:/size: forwarded to RubyUI::Button (default variant :outline)
               Verified in gem/lib/ruby_ui/alert_dialog/alert_dialog_cancel.rb (attrs merged into Button.new). RubyUI-only: AlertDialog(open:) opens the dialog on connect, close to Base UI Root defaultOpen.
               ↳ bases/base/ui/alert-dialog.tsx:145-151
  ⚠️ ARIA       page behind the popup: no aria-hidden, no inert, no aria-modal (trigger still exposed)
               shadcn: Base UI sets aria-hidden="true" on #root and the trigger while open (data-base-ui-inert); no aria-modal
               Background probe after a real click on the trigger, light theme.
               ↳ bases/base/ui/alert-dialog.tsx:41-62 (sandbox DOM)
  ⚠️ Behavior   initial focus stays on the trigger (behind the overlay)
               shadcn: initial focus moves to Cancel (first tabbable in the popup)
               Focus probe right after a real click on [data-audit-trigger].
               ↳ bases/base/ui/alert-dialog.tsx:41-62 (sandbox DOM)
  ⚠️ Behavior   no focus trap: Tab goes Cancel → Continue → body → trigger (behind the overlay)
               shadcn: focus trapped: Tab/Shift+Tab cycle Cancel ↔ Continue
               5 Tab + 5 Shift+Tab with 150ms between keys (real keyboard input via Playwright).
               ↳ bases/base/ui/alert-dialog.tsx:41-62 (sandbox DOM)
  ⚠️ Behavior   Esc does nothing: dialog stays open
               shadcn: Esc closes and focus returns to the trigger
               Real Escape key after opening with a real click.
               ↳ bases/base/ui/alert-dialog.tsx:41-62 (sandbox DOM)
  ⚠️ Behavior   Cancel closes, but focus is lost to <body>
               shadcn: Cancel closes and focus returns to the trigger
               Real clicks on trigger then Cancel.
               ↳ bases/base/ui/alert-dialog.tsx:145-160 (sandbox DOM)
  ✅ Behavior   outside click at (10,10) keeps the dialog open; Continue doesn't close it
               Real mouse click; matches APG for alertdialog.
               ↳ bases/base/ui/alert-dialog.tsx:132-143 (sandbox DOM)
  ✅ Behavior   scroll locked while open (body.overflow-hidden; wheel + PageDown leave scrollY at 0)
               Body made 300vh; control run without opening scrolled to 400 on both sides.
               ↳ bases/base/ui/alert-dialog.tsx:41-62 (sandbox DOM)
  ⚠️ Animation  enter: content fade-in-0 zoom-in-95 duration-200; overlay animate-in with no fade (no visible effect)
               shadcn: enter: overlay fade-in-0 and content fade-in-0 zoom-in-95, duration-100
               getAnimations() two frames after the click shows `enter` running on overlay and content on both sides.
               ↳ style-vega.css:20-24
  ⚠️ Animation  no exit animation: the element is removed at once on Cancel
               shadcn: exit: overlay fade-out-0, content fade-out-0 zoom-out-95 (duration-100)
               Sampled two frames after Cancel.click(): shadcn has `exit` running on overlay and content; RubyUI's dialog is already out of the DOM.
               ↳ style-vega.css:20-24
  ❌ Native     <div role="alertdialog"> + Stimulus (innerHTML copy, manual overflow-hidden), no focus management
               shadcn: Base UI JS modal (focus guards, aria-hidden siblings, Esc, scroll lock)
               Candidate for <dialog role="alertdialog"> + showModal(): top layer, inert page, Esc (closedby defaults to closerequest, so no light dismiss — right for an alert dialog), initial focus and focus return from the browser. Keep the existing body overflow-hidden for scroll lock and add an exit transition (@starting-style / allow-discrete).
               ↳ https://developer.mozilla.org/pt-BR/docs/Web/HTML/Reference/Elements/dialog
  ❌ Spec       div[role=alertdialog] with no aria-labelledby / aria-label: snapshot shows an unnamed `alertdialog`
               shadcn: aria-labelledby → "Are you absolutely sure?", aria-describedby → "This action cannot be undone."
               WAI-ARIA 1.2 alertdialog: Accessible Name Required = True. RubyUI's popup has no name; shadcn complies.
               ↳ https://www.w3.org/TR/wai-aria-1.2/#alertdialog
  ⚠️ Spec       focus not moved into the dialog, not modal for the keyboard (no trap, Esc ignored, background not hidden)
               shadcn: focus to Cancel, trap, Esc closes, background aria-hidden
               WAI-ARIA 1.2 alertdialog: authors SHOULD make alert dialogs modal and SHOULD set focus to an active element inside. Also APG Alert Dialog #keyboard_interaction (Tab/Shift+Tab stay inside, Escape closes): https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/#keyboard_interaction. SHOULD/guidance → ⚠️; shadcn complies.
               ↳ https://www.w3.org/TR/wai-aria-1.2/#alertdialog
this PR
shadcn audit  ruby-ui/ruby_ui
  base e5f38442  head 521a55c1  shadcn c0d63c33
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment  browser driven with Playwright headless Chromium 1280×800 (chrome-devtools MCP failed to connect)
✅ Theme  neutral tokens match shadcn
AlertDialog  (ref: bases/base/ui/alert-dialog.tsx + style-vega.css @ c0d63c33)
  ❌ Look       background-color: oklch(0.145 0 0); background-color: oklch(0.269 0 0); background-color: oklch(1 0 0); border-bottom-color: oklch(0.922 0 0); border-left-color: oklch(0.922 0 0); border-right-color: oklch(0.922 0 0); border-top-color: oklch(0.922 0 0); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.556 0 0) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklch(0.708 0 0) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; color: oklch(0.205 0 0); column-gap: normal; gap: normal; padding-bottom: 8px; padding-left: 16px; padding-right: 16px; padding-top: 8px; row-gap: normal
               vega: background-color: oklab(1 0 0 / 0.045); background-color: oklab(1 0 0 / 0.075); background-color: oklch(0.97 0 0); border-bottom-color: oklch(0.708 0 0); border-left-color: oklch(0.708 0 0); border-right-color: oklch(0.708 0 0); border-top-color: oklch(0.708 0 0); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.556 0 0 / 0.5) 0px 0px 0px 3px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, oklab(0.708 0 0 / 0.5) 0px 0px 0px 3px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; color: oklch(0.145 0 0); column-gap: 6px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-right: 10px; padding-top: 0px; row-gap: 6px
               AlertDialogTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — comes from RubyUI::Button (px-4 py-2 shadow vs vega cn-button px-2.5 gap-1.5 border); fixed in a separate Button PR, out of scope here.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       AlertDialogMedia: no visible element in any reached state — the fixture has no AlertDialogMedia (same on both sides); classes verified in gem tests against style-vega.css:32.
  ❌ Look       border-bottom-color: oklch(0.922 0 0); border-bottom-color: oklch(1 0 0 / 0.1); border-bottom-width: 0px; border-left-color: oklch(0.922 0 0); border-left-color: oklch(1 0 0 / 0.1); border-left-width: 0px; border-right-color: oklch(0.922 0 0); border-right-color: oklch(1 0 0 / 0.1); border-right-width: 0px; border-top-color: oklch(0.922 0 0); border-top-color: oklch(1 0 0 / 0.1); border-top-width: 0px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; column-gap: normal; gap: normal; padding-bottom: 8px; padding-left: 16px; padding-right: 16px; padding-top: 8px; row-gap: normal
               vega: border-bottom-color: rgba(0, 0, 0, 0); border-bottom-width: 1px; border-left-color: rgba(0, 0, 0, 0); border-left-width: 1px; border-right-color: rgba(0, 0, 0, 0); border-right-width: 1px; border-top-color: rgba(0, 0, 0, 0); border-top-width: 1px; box-shadow: none; column-gap: 6px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-right: 10px; padding-top: 0px; row-gap: 6px
               AlertDialogAction — open/light, open/dark — comes from RubyUI::Button (px-4 py-2 shadow vs vega cn-button px-2.5 gap-1.5 border); fixed in a separate Button PR, out of scope here.
               ↳ computed styles (shadcn-audit collect)
  ❌ Look       background-color: oklch(0.145 0 0); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2…; column-gap: normal; gap: normal; padding-bottom: 8px; padding-left: 16px; padding-right: 16px; padding-top: 8px; row-gap: normal
               vega: background-color: oklab(1 0 0 / 0.045); box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; column-gap: 6px; gap: 6px; padding-bottom: 0px; padding-left: 10px; padding-right: 10px; padding-top: 0px; row-gap: 6px
               AlertDialogCancel — open/light, open/dark — comes from RubyUI::Button (px-4 py-2 shadow vs vega cn-button px-2.5 gap-1.5 border); fixed in a separate Button PR, out of scope here.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family, letter-spacing, width
               AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel — explained by environment diff: font
  ✅ ARIA       aria-controls: resolves; snapshot: only here: - button "Delete account" [expanded] — intentional
               AlertDialogTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — override: aria-controls while closed is registered (the <dialog> stays in the DOM). The open-state snapshot gap is Playwright's own tree ignoring inert: Chrome's accessibility tree (CDP getFullAXTree) holds the same nodes on both sides while open — alertdialog "Are you absolutely sure?", Cancel, Continue — with the trigger excluded.
               ↳ audit/divergences.yml
  ✅ ARIA       modal: true — intentional
               AlertDialogContent — open/light, open/dark — registered: showModal() exposes the dialog as modal natively.
               ↳ audit/divergences.yml
  ⏸ ARIA       AlertDialogMedia: no visible element in any reached state — the fixture has no AlertDialogMedia (same on both sides); classes verified in gem tests against style-vega.css:32.
  ✅ ARIA       AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       ::backdrop bg-black/10 supports-backdrop-filter:backdrop-blur-xs (oklab(0 0 0 / 0.1), blur(4px))
               getComputedStyle(dialog, '::backdrop'), light and dark. Overlay as ::backdrop is the registered HTML divergence.
               ↳ style-vega.css:20
  ✅ Look       Content, Header, Title, Description, Footer: no computed difference
               Computed styles compared in light and dark at 1280×800; dialog rect 384,319 512×162 on both. Title font-weight 500, Description text-wrap pretty.
               ↳ style-vega.css:24-44
  ✅ HTML       <dialog role="alertdialog"> + showModal(), overlay is ::backdrop — intentional
               Native modal dialog: the browser provides inert, Esc, focus trap and focus return. Same approach as Dialog.
               ↳ audit/divergences.yml
  ✅ HTML       AlertDialogTrigger <div> wrapping the Button; ARIA on the button — intentional
               Same as Dialog: Phlex has no render prop.
               ↳ audit/divergences.yml
  ✅ HTML       root <div class="contents"> with the controller — intentional
               display: contents keeps it out of layout.
               ↳ audit/divergences.yml
  ✅ HTML       AlertDialogMedia div[data-slot=alert-dialog-media]
               New component; header/title media layout uses the same has-data-[slot=alert-dialog-media] selectors (gem tests).
               ↳ bases/base/ui/alert-dialog.tsx:93-104
  ✅ Props      AlertDialogContent(size: :default | :sm) → data-size
               Unknown sizes raise ArgumentError.
               ↳ bases/base/ui/alert-dialog.tsx:41-47
  ✅ Props      AlertDialogCancel variant:/size: forwarded to Button; AlertDialog(open:)
               open: true calls showModal() on connect.
               ↳ bases/base/ui/alert-dialog.tsx:145-151
  ✅ ARIA       aria-labelledby → "Are you absolutely sure?", aria-describedby → "This action cannot be undone."; trigger aria-haspopup=dialog, aria-expanded false/true
               Popup facts probe after a real click.
               ↳ bases/base/ui/alert-dialog.tsx:106-130 (sandbox DOM)
  ✅ Behavior   initial focus on Cancel — native
               showModal() focuses the first focusable element.
               ↳ https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
  ✅ Behavior   Tab/Shift+Tab stay on Cancel ↔ Continue, wrapping through the browser chrome — native
               Page behind is inert; headless reports the browser-chrome stop as <body>. Same grade as Dialog in #572 (acceptance_572.md:51).
               ↳ https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
  ✅ Behavior   Esc closes (exit animation) and focus returns to the trigger — native
               Real Escape key; `cancel` is held so the exit animation plays.
               ↳ https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
  ✅ Behavior   Cancel closes and focus returns to the trigger
               Real clicks on trigger then Cancel.
               ↳ bases/base/ui/alert-dialog.tsx:145-160 (sandbox DOM)
  ✅ Behavior   outside click keeps it open; Continue doesn't close it
               Real mouse click at (10,10).
               ↳ bases/base/ui/alert-dialog.tsx:132-143 (sandbox DOM)
  ✅ Behavior   scroll locked while open (body.overflow-hidden)
               Wheel + PageDown leave scrollY at 0; control without opening scrolls to 400.
               ↳ bases/base/ui/alert-dialog.tsx:41-62 (sandbox DOM)
  ✅ Animation  enter fade-in-0 zoom-in-95 + ::backdrop fade-in-0, duration-100
               getAnimations() shows `enter` on the dialog and its ::backdrop.
               ↳ style-vega.css:20-24
  ✅ Animation  exit fade-out-0 zoom-out-95 + ::backdrop fade-out-0, then close()
               Two frames after Cancel.click(): `exit` running on the dialog and ::backdrop, dialog still open.
               ↳ style-vega.css:20-24
  ✅ Native     <dialog role="alertdialog"> + showModal() — native
               Inert page, Esc, focus in/out from the browser; scroll lock and exit animation kept in the controller, so nothing shadcn delivers is lost.
               ↳ https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
  ✅ Spec       alertdialog named by aria-labelledby; focus moves inside; modal
               WAI-ARIA 1.2 alertdialog: Accessible Name Required = True (met); authors SHOULD make it modal and focus an element inside (met).
               ↳ https://www.w3.org/TR/wai-aria-1.2/#alertdialog

How to test

  1. cd gem && bundle exec rake (tests and standardrb).
  2. cd docs && bin/dev, open /docs/alert_dialog.
  3. Open the first example. Focus should be on Cancel. Tab and Shift+Tab stay on the two buttons.
  4. Press Esc. The dialog fades out and focus returns to "Show dialog".
  5. Open it again and click the dimmed area. It stays open. Click Cancel; focus returns to the trigger.
  6. Try the Small and Media examples, in light and dark.
  7. Open a dialog, follow a link with Turbo, press Back. The dialog is closed and the page scrolls.
  8. With a screen reader (or the Accessibility pane in DevTools), the dialog is announced as an alert dialog named "Are you absolutely sure?".

🤖 Generated with Claude Code