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

Merged
CI
3d ago
+568-128

Restyles Avatar after shadcn's Base UI + vega Avatar and adds AvatarBadge, AvatarGroup and AvatarGroupCount. With these changes the shadcn audit reports no Look, HTML, Props or ARIA differences.

Avatar, light
Avatar, dark

The main row has no badge or group because those parts didn't exist before this PR.

Changes

  • Sizes follow vega: default 32px, sm 24px, lg 40px. xs (16px) and xl (80px) stay as RubyUI extras.
  • size: :default is the default, as in shadcn, and :md still works as an alias. nil or an unknown size renders as :default. Before this PR, size: :default rendered with no width or height.
  • The root gets vega's 1px border ring (an ::after with mix-blend-darken, lighten in dark mode), select-none, data-slot and data-size. It no longer clips with overflow-hidden, so the badge can sit on the edge. A plain <img> child still comes out sized, round and cropped.
  • AvatarImage uses object-cover and rounded-full. Non-square photos were stretched before.
  • AvatarFallback uses text-sm in muted-foreground (12px on sm). Before, it inherited the text color and a font size set on the root.
  • AvatarFallback takes delay: (ms), like Base UI: it waits before showing the initials, so a fast image doesn't flash them first. With a delay it renders hidden until the controller connects.
  • The controller tracks the image status (loading, loaded, error) and dispatches ruby-ui--avatar:status with detail.status on every change, the counterpart of Base UI's onLoadingStatusChange.
  • New parts: AvatarBadge (bottom-right dot, sized per avatar size), AvatarGroup (overlapping avatars ringed in the background color) and AvatarGroupCount (the "+N" bubble).
  • Every part has a data-slot.
  • The docs page adds Badge, Group and Fallback delay examples, and the Introduction page uses AvatarImage.
  • Avatar is now part of the shadcn audit (audit/component_map.yml, audit/fixtures/avatar/).

Possible breaking changes for apps that copied the component:

  • The default avatar shrinks from 40px to 32px and lg from 56px to 40px. Pass class: "size-10" to keep the old size.
  • Initials are smaller and gray. Pass class: on AvatarFallback for the old look.
  • The root no longer clips its children. Content other than an image or the fallback that relied on overflow-hidden needs its own rounding.

Test steps

  1. cd gem && bundle exec rake
  2. cd docs && bin/dev, open /docs/avatar: check the sizes, badge, group and fallback delay examples in light and dark.
  3. On the same page, block the image URL in DevTools (Network → Block request URL) and reload: the initials show in place of the photo.
shadcn audit, main
shadcn audit  ruby-ui/ruby_ui
  base 8221400d  head 8221400d  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  RubyUI preview: date gem bumped 3.5.1 -> 3.6.0 in the throwaway checkout's Gemfile.lock (local rdoc rubygems plugin activates date 3.6.0); no effect on rendering.
✅ Theme  neutral tokens match shadcn
Avatar  (ref: bases/base/ui/avatar.tsx + style-vega.css @ c0d63c33)
  ❌ Look       border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; border-top-left-radius: 0px; border-top-right-radius: 0px
               vega: border-bottom-left-radius: 3.35544e+07px; border-bottom-right-radius: 3.35544e+07px; border-top-left-radius: 3.35544e+07px; border-top-right-radius: 3.35544e+07px
               AvatarImage — closed/light, closed/dark. RubyUI AvatarImage has no rounded-full (class `aspect-square h-full w-full`) vs vega `aspect-square size-full object-cover rounded-full`; it still looks round because the root clips with overflow-hidden. Also no object-cover: RubyUI computes object-fit: fill, so a non-square photo is stretched instead of cropped.
               ↳ computed styles (shadcn-audit collect)
  ❌ Look       color: oklch(0.145 0 0); color: oklch(0.985 0 0)
               vega: color: oklch(0.556 0 0); color: oklch(0.708 0 0)
               AvatarFallback — closed/light, closed/dark. RubyUI AvatarFallback `flex h-full w-full items-center justify-center rounded-full bg-muted` has no text color (inherits foreground) vs vega `bg-muted text-muted-foreground rounded-full`.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family, font-size, height, line-height, width
               Avatar, AvatarImage, AvatarFallback — explained by environment diff: font. Settled by the DOM: only font-family is environmental (both load Inter Variable; only the fallback stack differs). width/height/font-size/line-height are real size differences, graded on the Avatar size and AvatarFallback text lines below.
  ✅ ARIA       Avatar, AvatarImage, AvatarFallback: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       default (:md) h-10 w-10 = 40px; :lg h-14 w-14 = 56px; :sm h-6 w-6 = 24px
               vega: size-8 = 32px; data-[size=lg]:size-10 = 40px; data-[size=sm]:size-6 = 24px
               Avatar size, light and dark (computed width/height). sm matches; default and lg are bigger in RubyUI. Also AvatarImage inherits the same 40px vs 32px.
               ↳ style-vega.css:71
  ❌ Look       text size on the root: default text-base (16px/24px), :lg text-xl (20px/28px), :sm text-xs (12px/16px); fallback inherits it
               vega: AvatarFallback text-sm (14px/20px), group-data-[size=sm]/avatar:text-xs (12px/16px); nothing on the root
               AvatarFallback text, light and dark (computed font-size/line-height). sm matches; default and lg differ.
               ↳ bases/base/ui/avatar.tsx:47
  ❌ Look       no ::after ring (content: none); overflow-hidden; user-select: auto
               vega: after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten; select-none; overflow visible
               Avatar root, read with getComputedStyle(el, '::after') in the browser: shadcn draws a 1px oklch(0.922 0 0) border ring blended over the image/fallback; RubyUI has none. collect does not read pseudo-elements, so this line is by hand.
               ↳ bases/base/ui/avatar.tsx:19
  ✅ HTML       span > img + span (tags)
               Same tags and nesting in the rendered fixture. Base UI mounts only one of image/fallback (fallback until the image loads, then the img); RubyUI renders both and hides one with the `hidden` class. Display:none keeps the hidden one out of layout and the a11y tree, so the rendered result matches.
               ↳ bases/base/ui/avatar.tsx:15
  ❌ HTML       no data-slot on any part, no data-size on the root
               shadcn: data-slot=avatar|avatar-image|avatar-fallback, data-size=default|sm|lg
               data-size is the styling hook shadcn's group-data-[size=*] classes rely on (fallback text, badge).
               ↳ bases/base/ui/avatar.tsx:16
  ❌ HTML       no AvatarBadge, AvatarGroup or AvatarGroupCount
               shadcn: AvatarBadge (span, absolute bottom-right dot), AvatarGroup (div, -space-x-2, ring-2 ring-background on each avatar), AvatarGroupCount (div, +N bubble)
               Not in the fixture (RubyUI has no counterpart to render). Add them to the map and fixture once implemented.
               ↳ bases/base/ui/avatar.tsx:56-104
  ❌ Props      size: :xs | :sm | :md (default) | :lg | :xl; size: :default not in SIZES, renders no size classes
               shadcn: size?: "default" | "sm" | "lg" (default "default")
               size is mapped, but the shadcn value `default` has no RubyUI key: Avatar(size: :default) yields SIZES[:default] = nil, so the avatar has no width/height (gem/lib/ruby_ui/avatar/avatar.rb:13). RubyUI extras :xs, :md, :xl are a note, not a failure.
               ↳ bases/base/ui/avatar.tsx:9-12
  ⏸ Props      no delay on AvatarFallback, no onLoadingStatusChange on AvatarImage
               shadcn: Base UI primitive props: Fallback delay?: number; Image onLoadingStatusChange
               Inherited from @base-ui/react 1.9.0 (avatar/fallback/AvatarFallback.d.ts:18, avatar/image/AvatarImage.d.ts:22), not declared in avatar.tsx, so not in the map. Map gap if they should be audited.
  ✅ Behavior   loaded image: img shown, fallback display:none; undecodable image: img display:none, fallback shown
               Observed in the browser with the fixture's data-URI image and broken data URI (no network). Not exercised: the instant before Stimulus connects (RubyUI SSR shows both img and fallback until connect) and Base UI's fallback delay.
               ↳ bases/base/ui/avatar.tsx:28-52
  ✅ Animation  none
               No transition/animation classes on either side.
               ↳ style-vega.css:69-88
  ✅ Native     Stimulus controller toggles `hidden` on img load/error
               HTML has no native fallback content for a failed <img> (alt text only); no entry in native-html.md fits, so the small controller is not a native candidate.
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#image_loading_errors
  ✅ Spec       img alt from alt: (default "")
               Both render alt on the img ("@shadcn"). RubyUI's default alt="" marks the image decorative, which is valid; in that case the avatar has no accessible name, same as shadcn without alt.
               ↳ https://html.spec.whatwg.org/multipage/images.html#alt

shadcn audit, this PR
shadcn audit  ruby-ui/ruby_ui
  base 674e56b3  head de123e78  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  Audited a local snapshot commit (de123e78, not on any branch) of the uncommitted fix; RubyUI preview Gemfile.lock: date 3.5.1 -> 3.6.0 (local rdoc plugin), no effect on rendering.
✅ Theme  neutral tokens match shadcn
Avatar  (ref: bases/base/ui/avatar.tsx + style-vega.css @ c0d63c33)
  ⏸ Look       properties: font-family
               Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount — explained by environment diff: font. Only font-family differs: both pages load Inter Variable; only the fallback stack differs (environment). Every other computed property matches on all six parts, light and dark.
  ✅ ARIA       Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       ::after 1px oklch(0.922 0 0) border, mix-blend darken; select-none; overflow visible
               Avatar root pseudo-element read with getComputedStyle(el, '::after') in the browser (collect doesn't read pseudo-elements).
               ↳ bases/base/ui/avatar.tsx:19
  ✅ HTML       span[data-slot=avatar][data-size] > img[data-slot=avatar-image] + span[data-slot=avatar-fallback]; span[data-slot=avatar-badge]; div[data-slot=avatar-group] > … + div[data-slot=avatar-group-count]
               RubyUI keeps the hidden image/fallback in the DOM with display:none; Base UI unmounts it. Same rendered result and a11y tree.
               ↳ bases/base/ui/avatar.tsx:15-104
  ✅ Props      size: :default (default) | :sm | :lg, plus :xs/:xl; :md alias of :default; unknown/nil → :default. AvatarFallback delay:
               RubyUI extras: :xs, :xl, :md alias. onLoadingStatusChange maps to the ruby-ui--avatar:status event.
               ↳ bases/base/ui/avatar.tsx:9-12
  ✅ Behavior   loaded image shows, fallback hidden; failed image: fallback; delay 400ms: fallback hidden at 50ms, shown at 550ms; events loading → error
               Observed in the browser. Without the controller (before connect / no JS) a visible image hides the fallback via group-has CSS.
               ↳ bases/base/ui/avatar.tsx:28-52
  ✅ Animation  none
               ↳ style-vega.css:69-88
  ✅ Native     Stimulus controller tracks img load/error
               No native fallback content for a failed <img>; no native-html.md entry fits.
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#image_loading_errors
  ✅ Spec       img alt from alt: (default "")
               ↳ https://html.spec.whatwg.org/multipage/images.html#alt

🤖 Generated with Claude Code


Summary by cubic

Restyles Avatar to match shadcn's Base UI + vega look and adds AvatarBadge, AvatarGroup and AvatarGroupCount. The shadcn audit now reports no Look, HTML, Props or ARIA differences.

Changes

  • Sizes follow vega: default 32px, sm 24px, lg 40px; xs and xl remain RubyUI extras, and :md still works as an alias of :default.
  • size: :default is now the default and renders 32px, where before it rendered with no width or height.
  • Root gets a 1px border ring, select-none, data-slot and data-size; AvatarImage crops non-square photos with object-cover, AvatarFallback uses text-sm in muted-foreground.
  • AvatarFallback takes delay: (ms) and the controller dispatches ruby-ui--avatar:status with detail.status (loading, loaded, error), mirroring Base UI's onLoadingStatusChange. A delayed fallback needs JavaScript: without the controller it stays hidden.
  • Avatar is now part of the shadcn audit.

Breaking changes

  • The default avatar shrinks from 40px to 32px and lg from 56px to 40px.
  • Initials are smaller and gray, and the root no longer clips its children.