[Feature] AlertDialog: native <dialog>, match shadcn (Base UI + vega)
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).




What changed
AlertDialogContentis 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.AlertDialogContentgives the<dialog>an id and, through the Phlex context, ids forAlertDialogTitleandAlertDialogDescription; the dialog getsaria-labelledby/aria-describedbyat render time, only for the parts that rendered. Ids and ARIA the author passes win. The controller only setsaria-haspopup,aria-expandedandaria-controlson the button insideAlertDialogTrigger.- 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.
AlertDialogContenttakessize: :default | :sm.:smis narrower and puts the footer buttons in two columns.- New
AlertDialogMediafor an icon next to the title. - Look follows vega:
ring-1 ring-foreground/10 rounded-xl gap-6 bg-popoveron the content, grid header,font-mediumtitle,text-balancedescription with link styles,gap-2footer. AlertDialogrenders adisplay: contentswrapper, so it no longer adds an inline-block box around the trigger.- Audit:
audit/component_map.ymlmaps the new selectors, Media and thesize/openprops.audit/divergences.ymlregisters five differences (native<dialog>, trigger wrapper, root wrapper,aria-controlswhile 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 thecontenttarget, need updating. AlertDialogCanceldropsmt-2 sm:mt-0; the footer spaces buttons withgap-2.- The overlay goes from
bg-black/80tobg-black/10. - AlertDialog must not sit inside a
<form>. The dialog now renders in place, and HTML drops a nested form: abutton_toinAlertDialogActionwould 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'sformattribute.
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
cd gem && bundle exec rake(tests and standardrb).cd docs && bin/dev, open/docs/alert_dialog.- Open the first example. Focus should be on Cancel. Tab and Shift+Tab stay on the two buttons.
- Press Esc. The dialog fades out and focus returns to "Show dialog".
- Open it again and click the dimmed area. It stays open. Click Cancel; focus returns to the trigger.
- Try the Small and Media examples, in light and dark.
- Open a dialog, follow a link with Turbo, press Back. The dialog is closed and the page scrolls.
- 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