[Feature] Toast: match shadcn (Base UI + vega)

Merged
CI
2d ago
+1041-422

RubyUI's Toast started as a port of sonner. shadcn now ships its own Toast built on Base UI (bases/base/ui/toast.tsx, July 2026), so this PR moves RubyUI to that reference with the vega style, following the shadcn audit of the component.

What changes

  • The viewport sits 16px from the corner and is max-w-sm wide (full width minus 16px on mobile). Toasts are rounded-2xl with shadow-lg, the content row is p-4 gap-3, and title and description are text-sm. The action is an outline sm Button and the X a ghost icon-sm Button in the row, shown by default.
  • Each toast is a role="dialog" (aria-modal="false"), named by its <h2> title and described by its <p> description. The region is the only live region (polite). priority: :high gives role="alertdialog" plus a visually hidden role="alert", as Base UI does.
  • Collapsed toasts peek 12px and shrink 10% per step with their content hidden; expanded ones sit 12px apart. Toasts past max stay mounted but hidden (data-limited) and come back when a front toast closes, where before they were thrown away.
  • Hover, keyboard focus and window blur pause the timers. The default is 5000ms, and the region's duration: now reaches spawned toasts (it didn't before).
  • F6 focuses the region and Tab moves to the newest toast. Escape closes the focused toast. When a focused toast goes away, focus moves to the next one or back to where it was.
  • Swipe dismisses down or right for bottom-right (derived from position:, or set with swipe_directions:; [] turns it off).
  • Toasts slide in from and out to translateY(150%) over 500ms, and the node is removed on transitionend instead of after a fixed delay.
  • The region is a popover="manual". A toast fired while a RubyUI Dialog, AlertDialog or Sheet is open (they use showModal()) now shows above it. It can't be clicked until the modal closes, see Follow-ups.
  • ToastItem gains priority: and on_remove:. on_dismiss:/on_remove: take Stimulus action descriptors bound to ruby-ui:toast:close/ruby-ui:toast:remove. The JS API takes onClose, onRemove, priority and swipeDirections, and has RubyUI.toast.close as an alias of dismiss.
  • The Toast now depends on Button (dependencies.yml), and the MCP registry is rebuilt.

Breaking

  • Removed, because they never did anything: ToastRegion offset:, theme:, rich_colors:, dir: and ToastItem invert:, on_auto_close:. Passing them raises ArgumentError with the reason, instead of rendering stray HTML attributes.
  • data-slot names are now shadcn's (toast, toast-title, toast-description, toast-icon, toast-action, toast-cancel, toast-close, toast-viewport). The list is a <div>, no longer an <ol>, and still has the id ruby-ui-toaster for Turbo Streams.
  • Error toasts are no longer role="alert". Urgency comes from priority: :high, as in Base UI.

Registered divergences (audit/divergences/toast.yml): ToastCancel stays; clicking the action still closes the toast; the X stays exposed to assistive tech (Base UI hides it until the toast is focused); one global manager instead of toastManager; and the extra positions and props.

Before / after

Open (light)
Open (dark)
Success + action + close
4 toasts, collapsed
4 toasts, hovered

Audit on main (before)
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head 1998c5e8  shadcn 8a353667
⚠️ 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  fixture shims (local, uncommitted map + fixtures): shadcn.tsx exposes the toast manager as window.__auditToast; RubyUI spawns with duration: Infinity + closeButton: true, shadcn with timeout: 0 (its ToastList always renders the close)
✅ Theme  neutral tokens match shadcn
Toast  (ref: bases/base/ui/toast.tsx + style-vega.css @ 8a353667)
  ❌ Look       padding-bottom: 24px; padding-left: 24px; padding-right: 24px; padding-top: 24px
               vega: padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px
               ToastRegion — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI places the list with the region's p-4 sm:p-6 (24px) and a fixed w-[356px] toast; shadcn's toast-viewport is fixed bottom-4 right-4 (16px) with max-w-sm (384px). Toast box: RubyUI 356×74 at (900,702), shadcn 384×78 at (880,706). Requested: viewport like shadcn (fixed inset-x-4 bottom-4 sm:right-4 sm:left-auto max-w-sm sm:w-full, no region padding; toast w-full). Requested: run `baseline toast 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR.
               ↳ bases/base/ui/toast.tsx:24
  ❌ Look       border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-top-left-radius: 10px; border-top-right-radius: 10px; 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 4px 12px 0px; column-gap: 6px; display: flex; gap: 6px; padding-bottom: 16px; padding-left: 16px; padding-right: 40px; padding-top: 16px; row-gap: 6px
               vega: border-bottom-left-radius: 18px; border-bottom-right-radius: 18px; border-top-left-radius: 18px; border-top-right-radius: 18px; 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: normal; display: block; gap: normal; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; row-gap: normal
               ToastItem — open/light, after-esc/light, open/dark, after-esc/dark. Radius: rounded-lg 10px vs vega .cn-toast rounded-2xl (18px here). Shadow: shadow-[0_4px_12px_rgba(0,0,0,0.1)] vs shadow-lg. Layout: RubyUI puts p-4 + gap-1.5 (6px) on the <li>; shadcn puts p-4 + gap-3 (12px) on toast-content (bases/base/ui/toast.tsx:68). The extra transparent box-shadow layers are Tailwind placeholders on both. Requested: ToastItem rounded-2xl shadow-lg; content p-4 gap-3.
               ↳ style-vega.css:1245
  ❌ 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; color: oklab(0.145 0 0 / 0.6); color: oklab(0.985 0 0 / 0.6)
               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; color: oklch(0.556 0 0); color: oklch(0.708 0 0)
               ToastClose — open/light, after-esc/light, open/dark, after-esc/dark. RubyUI ToastClose: absolute right-2 top-2, size-6 (24px), text-foreground/60, and the toast gets pr-10. shadcn: Button ghost icon-sm, size-8 (32px), in the flex row, vertically centered, text-muted-foreground. Border: 0 vs 1px transparent (renders the same). Requested: ToastClose as Button ghost icon-sm in the flex row, rendered by default (close_button default true); keep it exposed to AT (don't copy Base UI's conditional aria-hidden).
               ↳ bases/base/ui/toast.tsx:111
  ❌ Look       font-size 13px (text-[13px] on the item): title 13px/19.5px, description 13px/18.2px (leading-[1.4])
               vega: title text-sm 14px/20px font-medium, description text-sm 14px/20px
               ToastRegion, ToastItem, ToastTitle, ToastDescription, ToastClose — explained by environment diff: font. Override: only the font-family fallback list differs by environment (Inter Variable loads on both). font-size/line-height come from the component classes; width/height follow from them and from the 356 vs 384px width. Requested: drop text-[13px]/leading-*; ToastTitle text-sm font-medium, ToastDescription text-sm text-muted-foreground.
               ↳ bases/base/ui/toast.tsx:79
  ✅ Look       ToastDescription text-muted-foreground: oklch(0.556) light, oklch(0.708) dark
               Computed on both sides, light and dark.
               ↳ bases/base/ui/toast.tsx:91
  ❌ Look       ToastAction: h-6, text-xs, rounded (4px), solid bg-foreground text-background
               vega: toast-action: Button outline size sm (h-8, text-sm, border-border, bg-background, shadow-xs, rounded-[min(var(--radius-md),10px)])
               toast.success with an action on both sides (screens/toast-rich-*-light.png). ToastCancel has no shadcn counterpart (RubyUI-only). Requested: ToastAction renders Button(variant: :outline, size: :sm). Suggested divergence: ToastCancel is RubyUI-only (Button ghost sm).
               ↳ bases/base/ui/toast.tsx:100
  ✅ Look       ToastIcon (success): 16×16, color foreground
               Position differs only through the item layout (gap/margins on the Look line above). Same Lucide icons. shadcn colors the error icon text-destructive (bases/base/ui/toast.tsx:174); RubyUI keeps text-foreground for every type.
               ↳ bases/base/ui/toast.tsx:211
  ❌ Look       collapsed stack: back toasts fade (opacity .8/.6) with content visible, peek 16px, scale steps .95/.9
               vega: collapsed stack: back cards stay opaque, toast-content hidden (data-behind:opacity-0), peek --peek 0.75rem (12px), scale steps .9/.8
               4 toasts, no timeout (screens/toast-stack-*-light.png). Requested: collapsed stack like shadcn (opaque back cards, content hidden, peek 12px, scale -0.1 per level).
               ↳ bases/base/ui/toast.tsx:40
  ❌ Look       expanded stack gap 14px (gap: 14)
               vega: expanded stack gap --gap 0.75rem (12px)
               Expanded y steps: RubyUI 88px (74+14), shadcn 90px (78+12). Requested: expanded gap default 12.
               ↳ bases/base/ui/toast.tsx:40
  ❌ HTML       div[role=region] > ol > li > div.flex-col > div[data-slot=title] + div[data-slot=description]; no content wrapper
               shadcn: div[role=region][data-slot=toast-viewport] > div[role=dialog][data-slot=toast] > div[data-slot=toast-content] > span[toast-icon], div > h2[toast-title] + p[toast-description], action, close
               Title is a <div> vs Base UI's <h2>, description <div> vs <p>; RubyUI keeps an <ol>/<li> list. Roles graded on the ARIA lines. Requested: Base UI semantics (div role=dialog aria-modal=false, aria-labelledby/-describedby, ToastTitle h2, ToastDescription p, single polite region with aria-relevant="additions text").
               ↳ sandbox DOM
  ❌ HTML       close button only with close_button: true / closeButton: true
               shadcn: ToastList always renders ToastClose
               The fixture passes closeButton: true on RubyUI to compare the part. Requested: close button rendered by default.
               ↳ bases/base/ui/toast.tsx:229
  ❌ Props      ToastRegion: no toastManager; ToastItem: no priority, onRemove, actionProps kwarg
               shadcn: Toaster/Provider toastManager; toast.add priority, onRemove, actionProps
               RubyUI exposes one global manager (window.RubyUI.toast) and actions only through the JS API detail. rubyui_only: position, expand, gap, hotkey, close_button, flash (region), dismissible (item), label/on, variant (note only). Requested: add priority (high → assertive) and on_remove. Suggested divergence: toastManager and actionProps (single global manager; action: via the JS API).
               ↳ @base-ui/react 1.9.0 toast (useToastManager.d.ts)
  ❌ Props      accepted but ignored: ToastRegion duration: (shadcn timeout; not applied to spawned toasts), offset:, theme:, rich_colors:, dir:; ToastItem on_dismiss: (shadcn onClose), invert:, on_auto_close:
               shadcn: timeout and onClose change behavior
               Verified in the browser: with the region's duration 800, offset 80, rich-colors true, dir rtl, theme dark, a toast.success still stayed 1.5s+, sat 24px from the corner, white bg, ltr. The Stimulus values are declared but never read (toaster_controller.js:30-41, toast_controller.js:9-14); the region duration is read only in _mutate (promise). Requested: remove offset:, theme:, rich_colors:, dir:, invert:, on_auto_close: now (breaking, changelog). Requested: make region duration: (timeout) and on_dismiss: (onClose) work.
               ↳ gem/lib/ruby_ui/toast/toaster_controller.js:30
  ✅ Props      max (limit), duration per toast (timeout), variant (type), id
               Read by the controllers and change behavior.
               ↳ gem/lib/ruby_ui/toast/toast_region.rb:7
  ⚠️ ARIA       snapshot: only here:   - list: |     - status: |       - text: Event created Sunday, December 3 at 9:00 AM |       - button "Close toast": |         - img |         - text: Close; snapshot: only here: - region "Notifications": |   - list
               shadcn: snapshot: only here:   - dialog "Event created": |     - heading "Event created" [level=2] |     - paragraph: Sunday, December 3 at 9:00 AM; snapshot: only here: - region "Notifications"
               ToastRegion — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark Requested: Base UI semantics (see HTML line): role=dialog toasts, one live region.
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       aria-describedby: (none); snapshot: only here: - status: |   - text: Event created Sunday, December 3 at 9:00 AM |   - button "Close toast": |     - img |     - text: Close
               shadcn: aria-describedby: Sunday, December 3 at 9:00 AM; snapshot: only here: - dialog "Event created": |   - heading "Event created" [level=2] |   - paragraph: Sunday, December 3 at 9:00 AM
               ToastItem — open/light, after-esc/light, open/dark, after-esc/dark Requested: Base UI semantics (see HTML line).
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - text: Event created
               shadcn: snapshot: only here: - heading "Event created" [level=2]
               ToastTitle — open/light, after-esc/light, open/dark, after-esc/dark Requested: ToastTitle as h2 (see HTML line).
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - text: Sunday, December 3 at 9:00 AM
               shadcn: snapshot: only here: - paragraph: Sunday, December 3 at 9:00 AM
               ToastDescription — open/light, after-esc/light, open/dark, after-esc/dark Requested: ToastDescription as p (see HTML line).
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       snapshot: only here: - button "Close toast": |   - img |   - text: Close
               shadcn: snapshot: (none)
               ToastClose — open/light, after-esc/light, open/dark, after-esc/dark Requested: keep the close exposed to AT (kept as is on aria-hidden).
               ↳ accessibility tree (shadcn-audit collect)
  ⚠️ ARIA       error toast: li role=alert (assertive) inside the polite region; other types role=status
               shadcn: every toast is role=dialog aria-modal=false, labelled by the title and described by the description; type only sets data-type (priority controls urgency)
               RubyUI nests a live region per toast inside a region that is already aria-live=polite (possible double announcement). Base UI uses one polite region (aria-relevant="additions text") and a non-modal dialog per toast. Requested: priority instead of role=alert; no per-toast live region.
               ↳ sandbox DOM
  ✅ Behavior   hover pauses and resumes; close click dismisses; Tab reaches the toast
               behavior.json; toast spawned under a native modal <dialog> is covered on both sides too (see Native).
               ↳ sandbox DOM
  ⚠️ Behavior   keyboard focus on the toast doesn't pause the timer (gone at 6.8s)
               shadcn: focus inside the toast pauses it (still there at 6.8s)
               Tab from the trigger onto the toast, default timeout, wait 6.5s. Requested: pause on focusin, resume on focusout inside the region.
               ↳ sandbox DOM
  ⚠️ Behavior   default duration 4000ms
               shadcn: default timeout 5000ms (present at 4.5s, gone at 5.5s)
               RubyUI: present at 3.5s, gone at 4.5s. Requested: default duration 5000.
               ↳ @base-ui/react 1.9.0 toast provider
  ✅ Behavior   Esc with focus on a toast dismisses it
               Override after the Codex review: Base UI closes the focused toast on Escape. The first run waited 600ms after Escape while Base UI was still animating out, and its F6 had focused the viewport, not the toast, so the toast looked kept. With Tab onto the toast, Escape removes it on both sides. The earlier decision ("remove the Escape handler") is withdrawn; RubyUI keeps closing on Escape.
               ↳ @base-ui/react 1.9.0 toast/root/ToastRoot.js:383
  ⚠️ Behavior   hotkey alt+T focuses the first toast
               shadcn: F6 focuses the toast viewport; Tab then goes to the front toast
               Requested: default hotkey F6, keep hotkey:. Requested: F6 focuses the region; Tab moves to the front toast (Base UI ToastViewport).
               ↳ sandbox DOM
  ⚠️ Behavior   action click always dismisses the toast
               shadcn: action click leaves the toast open; the onClick closes it (toast.close(id) in toast-example.tsx)
               RubyUI force-dismisses after onClick (toaster_controller.js:139). Kept as is. Suggested divergence: action click closes the toast (common case, avoids breaking callers).
               ↳ bases/base/examples/toast-example.tsx:42
  ⚠️ Behavior   swipe dismisses in any direction (left 120px dismissed)
               shadcn: swipe out only down or right (left 120px kept)
               Base UI swipeDirection default ['down','right']. Requested: default swipe down/right (from position) + swipe_directions: prop.
               ↳ sandbox DOM
  ⚠️ Behavior   more than max (3): the oldest is force-dismissed and removed
               shadcn: more than limit (3): the oldest stays mounted with data-limited (opacity 0) and returns when a front toast closes
               4 toasts: RubyUI 3 nodes, shadcn 4 (toaster_controller.js:229). Requested: hide extra toasts (data-limited, opacity-0, no pointer events, timer paused) and re-show when space frees.
               ↳ bases/base/ui/toast.tsx:40
  ⚠️ Animation  enter: opacity 0→1 over ~300ms, no movement
               shadcn: enter: slides up from translateY(150%), transform 500ms cubic-bezier(0.22,1,0.36,1), no fade
               Sampled 0-700ms: shadcn y 823→706, opacity 1 throughout. Requested: slide from translateY(150%), 500ms cubic-bezier(0.22,1,0.36,1).
               ↳ bases/base/ui/toast.tsx:43
  ⚠️ Animation  exit: fade for 200ms, node removed mid-transition (transition 300ms, TIME_BEFORE_UNMOUNT 200ms), no movement
               shadcn: exit: slides down translateY(150%) over ~500ms, opacity 1
               Close click, sampled 0-400ms (toast_controller.js:4,73). Requested: slide out to translateY(150%); remove the node on transitionend, not a fixed timeout.
               ↳ bases/base/ui/toast.tsx:44
  ⚠️ Native     region is position:fixed z-[100], not in the top layer
               shadcn: toast-viewport fixed z-50, not in the top layer either
               RubyUI's Dialog, AlertDialog and Sheet use showModal(), so a toast fired while one is open renders under the ::backdrop and can't be clicked (elementFromPoint = DIALOG, checked with a native modal on both sides). Base UI dialogs aren't top-layer, so shadcn's z-index works there. Candidate: popover="manual" on the region, showPopover() on each spawn. Timers, stacking and swipe have no native equivalent. Requested: popover="manual" on the region; hidePopover()+showPopover() on each spawn so toasts sit above open modals.
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover
  ✅ Spec       div role=region aria-label="Notifications"
               WAI-ARIA 1.2 region: "Authors MUST give each element with role region a brief label": both do; shadcn's non-modal dialogs have an accessible name. Base UI's close is aria-hidden only while the toast is neither expanded nor focused (ToastClose.js:49) and loses it when focus enters, so no focusable aria-hidden element was observed.
               ↳ https://www.w3.org/TR/wai-aria-1.2/#region
Audit on this branch (after)
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head 7141afb1  shadcn 8a353667
⚠️ 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  fixture shims (local, uncommitted map + fixtures): shadcn.tsx exposes the toast manager as window.__auditToast; RubyUI spawns with duration: Infinity + closeButton: true, shadcn with timeout: 0 (its ToastList always renders the close)
✅ Theme  neutral tokens match shadcn
Toast  (ref: bases/base/ui/toast.tsx + style-vega.css @ 8a353667)
  ❌ Look       border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; border-top-left-radius: 16px; border-top-right-radius: 16px
               vega: border-bottom-left-radius: 18px; border-bottom-right-radius: 18px; border-top-left-radius: 18px; border-top-right-radius: 18px
               ToastItem — open/light, after-esc/light, open/dark, after-esc/dark. Both use rounded-2xl; the docs theme has no --radius-2xl, so Tailwind's default (1rem) applies instead of shadcn's calc(var(--radius) * 1.8). Theme follow-up (also affects Empty and Bubble), not a component change.
               ↳ style-vega.css:1245
  ⏸ Look       properties: font-family, letter-spacing
               ToastRegion, ToastItem, ToastTitle, ToastDescription, ToastClose — explained by environment diff: font. Only the font-family fallback list differs by environment (Inter Variable on both); width/height/font-size match (384×78, text-sm).
  ✅ Look       viewport fixed bottom-4 right-4, max-w-sm; toast 384×78 at (880,706)
               Measured in the browser, light and dark.
               ↳ bases/base/ui/toast.tsx:24
  ✅ Look       card border bg-popover shadow-lg; content p-4 gap-3; title/description text-sm
               Computed styles match except the radius line above.
               ↳ bases/base/ui/toast.tsx:68
  ✅ Look       ToastAction Button outline sm (32px, 14px/20px 500); ToastClose Button ghost icon-sm (32px) in the row
               toast.success + action: action at (265,33) 58×32 and close at (335,33) 32×32 on both. ToastCancel is RubyUI-only (audit/divergences/toast.yml).
               ↳ bases/base/ui/toast.tsx:100
  ✅ Look       stack: collapsed peek 12px, scale .9/.8/.7, content hidden behind; expanded gap 12
               4 toasts: collapsed y 706/694/682/670, expanded 706/616/526/436 on both.
               ↳ bases/base/ui/toast.tsx:40
  ✅ Look       ToastIcon 16×16; error icon text-destructive
               ↳ bases/base/ui/toast.tsx:174
  ✅ HTML       div[role=region][data-slot=toast-viewport] > div#ruby-ui-toaster > div[role=dialog][data-slot=toast] > div[data-slot=toast-content] > icon, div > h2 + p, action, close
               The inner #ruby-ui-toaster div is kept as the Turbo Stream target id; it adds no role.
               ↳ sandbox DOM
  ✅ HTML       close button rendered by default (close_button: true)
               close_button: false / closeButton: false still hide it (RubyUI extra).
               ↳ bases/base/ui/toast.tsx:229
  ✅ HTML       ToastCancel (ghost sm) — intentional
               RubyUI extra kept for existing callers.
               ↳ audit/divergences/toast.yml
  ✅ Props      max, duration (region default 5000, now applied to spawned toasts), variant, id, priority, on_dismiss (ruby-ui:toast:close), on_remove
               Region duration verified in the browser. Removed (they never had an effect): offset:, theme:, rich_colors:, dir:, invert:, on_auto_close: raise ArgumentError.
               ↳ gem/lib/ruby_ui/toast/toast_item.rb:14
  ✅ Props      one global manager; action:/ToastAction — intentional
               Registered divergence.
               ↳ audit/divergences/toast.yml
  ✅ ARIA       snapshot: only here:     - button "Close toast" — intentional
               ToastRegion — Follows from the ToastClose entry above.
               ↳ audit/divergences/toast.yml
  ✅ ARIA       snapshot: only here:   - button "Close toast" — intentional
               ToastItem — Follows from the ToastClose entry above: the only difference is the exposed close button.
               ↳ audit/divergences/toast.yml
  ✅ ARIA       snapshot: only here: - button "Close toast" — intentional
               ToastClose — RubyUI keeps the X in the accessibility tree: it is a real, focusable control, and hiding it until focus enters the toast makes it undiscoverable when browsing the region. The toast is still announced through the single polite region.
               ↳ audit/divergences/toast.yml
  ✅ ARIA       ToastTitle, ToastDescription: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ ARIA       toast role=dialog aria-modal=false, aria-labelledby title, aria-describedby description; priority high → role=alertdialog + visually hidden role=alert
               No per-toast live region any more; the region is the single polite one.
               ↳ bases/base/ui/toast.tsx:35
  ✅ Behavior   hover, keyboard focus and window blur pause; default 5000ms; F6 focuses the region and Tab the front toast; Escape closes the focused toast; focus moves on when a focused toast leaves
               Focus: still there at 6.8s on both. Auto-close: present at 4.5s on both. F6 → toast-viewport, Tab → front toast, Escape → removed, on both.
               ↳ sandbox DOM
  ✅ Behavior   swipe dismisses down/right only (left 120px kept), direction derived from position or swipe_directions:
               ↳ sandbox DOM
  ✅ Behavior   past max: oldest stays mounted with data-limited (opacity 0, inert) and returns when a front toast closes
               4 toasts: 4 nodes on both.
               ↳ bases/base/ui/toast.tsx:40
  ✅ Behavior   action click closes the toast — intentional
               Registered divergence.
               ↳ audit/divergences/toast.yml
  ✅ Animation  enter: slide from translateY(150%), 500ms cubic-bezier(0.22,1,0.36,1)
               Sampled 0-700ms: y 823→706 on both.
               ↳ bases/base/ui/toast.tsx:43
  ✅ Animation  exit: slide to translateY(150%), removed on transitionend
               Sampled 0-400ms: y 718→817 on both.
               ↳ bases/base/ui/toast.tsx:44
  ⚠️ Native     region is popover="manual", re-shown on each toast while a modal <dialog> is open: the toast now paints above the modal
               shadcn: not in the top layer
               Visible above a showModal() dialog (:popover-open, painted over the ::backdrop), but the page behind a modal stays inert, so its buttons can't be clicked until the modal closes. Follow-up: render the toast inside the open dialog.
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover
  ✅ Spec       div role=region aria-label="Notifications"
               WAI-ARIA 1.2 region: "Authors MUST give each element with role region a brief label": both do; shadcn's non-modal dialogs have an accessible name. Base UI's close is aria-hidden only while the toast is neither expanded nor focused (ToastClose.js:49) and loses it when focus enters, so no focusable aria-hidden element was observed.
               ↳ https://www.w3.org/TR/wai-aria-1.2/#region

Follow-ups

  • The docs CSS has no --radius-2xl, so rounded-2xl is Tailwind's 16px where shadcn computes 18px (calc(var(--radius) * 1.8)). That's the one Look line still open here. It also affects Empty and Bubble, so it belongs in a theme PR.
  • Toasts fired under a modal are visible but not clickable: the page behind a showModal() dialog is inert, popovers included. Fixing that means rendering the toast inside the open dialog.
  • Base UI pauses only on focus-visible focus; this PR pauses on any focus inside the region.
  • flash: {alert: …} / {error: …} could default to priority: :high. It's left at :low here to stay with Base UI, where type and priority are separate.
  • The audit walkthrough first recorded that Base UI keeps the toast on Escape. That measurement was wrong: Base UI was still animating out, and its F6 had focused the viewport, not the toast. Escape closes the toast on both sides, and the report says so now.

Test steps

  1. cd gem && bundle exec rake (737 runs), cd docs && bin/rails test, cd mcp && bundle exec rake, cd audit && bundle exec rake.
  2. cd docs && bin/dev, open /docs/toast and fire the examples: stacking on hover, the X, Undo, swipe right and down (left doesn't dismiss), F6 then Tab then Escape.
  3. Open a Dialog, fire a toast from the console (RubyUI.toast("Saved")): it shows above the backdrop.
  4. RubyUI::ToastRegion.new(offset: 10) raises ArgumentError.

🤖 Generated with Claude Code


Summary by cubic

Moves RubyUI's Toast from the sonner port to shadcn's Base UI Toast with the vega style, matching shadcn's look, semantics, and behavior.

Changes

  • The viewport sits 16px from the corner at max-w-sm; toasts are rounded-2xl with shadow-lg, a p-4 gap-3 content row, and text-sm title and description.
  • ToastAction renders an outline sm Button, and the close is a ghost icon-sm Button in the row, shown by default.
  • Each toast is a role="dialog" named by its <h2> title and described by its <p> description; a toast without a title uses its description, else "Notification". The region is the single polite live region, and priority: :high upgrades to role="alertdialog".
  • Collapsed toasts peek 12px and shrink 10% per step with hidden content; toasts past max stay mounted hidden and return when a front toast closes.
  • Timers pause on hover, keyboard focus, and window blur; the default is 5000ms, and the region's duration: now reaches spawned toasts.
  • F6 focuses the region, Tab moves to the newest toast, and Escape closes the focused toast; focus moves on when a focused toast goes away.
  • Swipe dismisses down or right, derived from position: or set with swipe_directions:; unknown directions raise ArgumentError.
  • Toasts slide in and out from translateY(150%) over 500ms and are removed on transitionend.
  • The region is popover="manual", so toasts show above RubyUI Dialog, AlertDialog, and Sheet.
  • ToastItem gains priority: and on_remove:; on_dismiss:/on_remove: bind ruby-ui:toast:close/ruby-ui:toast:remove, and the JS API gains matching options plus RubyUI.toast.close.

Breaking

  • Removed dead props ToastRegion offset:, theme:, rich_colors:, dir: and ToastItem invert:, on_auto_close: — passing them raises ArgumentError.
  • data-slot names are now shadcn's (toast, toast-title, toast-description, toast-icon, toast-action, toast-cancel, toast-close, toast-viewport); the list is a <div> but keeps the ruby-ui-toaster id for Turbo Streams.
  • Error toasts no longer use role="alert"; urgency comes from priority: :high.