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

Merged
CI
3d ago
+446-111

Badge now follows shadcn's Base UI build with the vega style. The shadcn audit finds no Look, HTML, Props or ARIA differences; the one ⏸ left is the font stack, which differs between the docs app and the sandbox.

Badge, light
Badge, dark

What changes

  • :default is solid (bg-primary text-primary-foreground) and is the default. :primary keeps working as an alias, and nil or unknown variants render as :default. Before, variant: :default rendered with no colors.
  • :secondary uses bg-secondary text-secondary-foreground. Before, the text used the background token and was nearly invisible in both themes (see the main column above).
  • The badge is a pill: h-5, a 1px border instead of the ring, gap-1, whitespace-nowrap and shrink-0, so a long label no longer wraps or shrinks in a flex row. Icons render at 12px, with tighter padding next to an icon marked data-icon="inline-start" or "inline-end".
  • New :ghost and :link variants.
  • href: renders an <a>, which turns on each variant's hover color and the focus ring. shadcn does this with the render prop; Phlex has no render prop, so the difference is registered in audit/divergences.yml.
  • success, warning and the palette colors follow the destructive pattern: tinted background, no ring.
  • size stays as a RubyUI extra: :sm is h-4, :md matches shadcn, :lg is h-6 text-sm.
  • data-slot="badge" and data-variant on the element.
  • Docs: Secondary, Destructive, Ghost, Link, With Icon and As Link examples. The icons are inline in the snippets, so they work when copied.
  • Badge joins the shadcn audit (map entry, fixtures).

A Badge with no variant now looks different: it was a pale chip and is now solid. The docs pages that use Badge (home, tabs, context menu, tables) pick this up.

Not in this PR

  • The docs theme has no --radius-2xl to --radius-4xl, so Tailwind uses its defaults (rounded-4xl is 32px here, 26px in shadcn). It doesn't show on the badge, where both clamp to the 10px pill radius, but it does affect Empty and Bubble.
  • The Alert and Tooltip docs snippets call private icon helpers, so they raise NoMethodError when copied. Same fix as the Badge docs here.

How to test

  • cd gem && bundle exec rake
  • cd docs && bin/dev, open /docs/badge and check each example in light and dark. Tab to the As Link badge to see the focus ring.
  • cd audit and run the shadcn audit for badge (/shadcn-audit badge in Claude Code).
shadcn audit on 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)
✅ Theme  neutral tokens match shadcn
Badge  (ref: bases/base/ui/badge.tsx + style-vega.css @ c0d63c33)
  ❌ Look       background-color: oklab(0.205 0 0 / 0.05); background-color: oklab(0.269 0 0 / 0.1); background-color: oklab(0.704 0.176821 0.072217 / 0.1); background-color: oklab(0.922 0 0 / 0.05); background-color: oklab(0.97 0 0 / 0.1); background-color: oklch(0.145 0 0); background-color: oklch(1 0 0); border-bottom-color: oklch(0.922 0 0); border-bottom-color: oklch(1 0 0 / 0.1); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; 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-left-radius: 8px; border-top-right-radius: 8px; 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.205 0 0 / 0.2) 0px 0px 0px 1px inset, 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.269 0 0 / 0.2) 0px 0px 0px 1px inset, 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.577 0.217662 0.112464 / 0.2) 0px 0px 0px 1px inset, 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.704 0.176821 0.072217 / 0.2) 0px 0px 0px 1px inset, 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.922 0 0 / 0.2) 0px 0px 0px 1px inset, 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.97 0 0 / 0.2) 0px 0px 0px 1px inset, 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, oklch(0.922 0 0) 0px 0px 0px 1px inset, 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, oklch(1 0 0 / 0.1) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0) 0px 0px 0px 0px; color: oklch(0.205 0 0); color: oklch(0.269 0 0); color: oklch(0.922 0 0); color: oklch(0.97 0 0); column-gap: normal; gap: normal; padding-bottom: 4px; padding-top: 4px; row-gap: normal
               vega: background-color: oklab(0.704 0.176821 0.072217 / 0.2); background-color: oklch(0.205 0 0); background-color: oklch(0.269 0 0); background-color: oklch(0.922 0 0); background-color: oklch(0.97 0 0); background-color: rgba(0, 0, 0, 0); border-bottom-color: rgba(0, 0, 0, 0); border-bottom-left-radius: 26px; border-bottom-right-radius: 26px; 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-left-radius: 26px; border-top-right-radius: 26px; border-top-width: 1px; box-shadow: none; color: oklch(0.205 0 0); color: oklch(0.985 0 0); column-gap: 4px; gap: 4px; padding-bottom: 2px; padding-top: 2px; row-gap: 4px
               Badge — closed/light, closed/dark. Tokens: RubyUI base `inline-flex items-center rounded-md font-medium ring-1 ring-inset` + :md `px-2 py-1 text-xs`; vega `.cn-badge` `h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all` (style-vega.css:92). Shape: radius 8px vs 26px (pill), 0px border + 1px inset ring vs 1px transparent border, py 4px vs 2px, gap normal vs 4px. Variant colors (style-vega.css:95-109): default = RubyUI :primary `text-primary bg-primary/5 ring-primary/20` (pale chip, dark text) vs `bg-primary text-primary-foreground` (solid dark chip, white text); secondary `text-secondary bg-secondary/10 ring-secondary/20` vs `bg-secondary text-secondary-foreground` — RubyUI renders oklch(0.97) text on a near-white chip (light) and oklch(0.269) text on a near-black chip (dark), practically invisible in both screenshots; destructive `bg-destructive/10 ring-destructive/20` vs `bg-destructive/10 dark:bg-destructive/20`, no ring (shadcn uses /20 bg in dark); outline `bg-background ring-border` vs `border-border` with transparent bg.
               ↳ style-vega.css:92
  ❌ Look       height 24px (py-1 + 16px line-height), width +2px narrower per badge
               vega: height 20px (h-5), 1px border on each side
               Badge — collect attributed height/width to the font environment diff, but both sides render the same Inter Variable at 12px/16px: height 24 vs 20px comes from py-1 vs h-5 + py-0.5, and the 2px width gap is the 1px border shadcn adds on each side (RubyUI uses a ring, 0px border). font-family itself is the docs vs sandbox stack (env diff), not a component difference.
               ↳ style-vega.css:92
  ✅ ARIA       Badge: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       white-space: normal; overflow: visible; justify-content: normal; flex-shrink: 1; transition-duration: 0s (no w-fit, shrink-0, justify-center, overflow-hidden, whitespace-nowrap, transition-all)
               vega: white-space: nowrap; overflow: hidden; justify-content: center; flex-shrink: 0; transition: all 0.15s
               Badge — computed via getComputedStyle on all four badges (not in collect property set). A long label wraps and the badge shrinks in a flex row on RubyUI; shadcn keeps it on one line. Also missing: `[&>svg]:size-3! [&>svg]:pointer-events-none` and `has-data-[icon=inline-start|inline-end]` padding for icons (style-vega.css:92), and focus-visible / aria-invalid ring styles (badge.tsx:7).
               ↳ bases/base/ui/badge.tsx:7
  ❌ HTML       <span class=…> (no data-slot / data-variant)
               shadcn: <span data-slot="badge" data-variant="default|secondary|destructive|outline">
               Same tag (span, from useRender defaultTagName) and no nesting on either side; RubyUI lacks the data-slot and data-variant attributes shadcn styles and selects on (DOM read from both fixtures).
               ↳ bases/base/ui/badge.tsx:41
  ❌ Props      variant: :primary (default), :secondary, :destructive, :outline (+ :success, :warning and 22 palette colors); no :default, :ghost, :link; no render
               shadcn: variant: default | secondary | destructive | outline | ghost | link; render (useRender)
               Map: variant → variant, render → null. shadcn `default` corresponds to RubyUI `:primary` (different name; `variant: :default` hits COLORS[nil] and renders with no colors); `ghost` and `link` (badge.tsx:15-16) have no RubyUI counterpart; `render` (badge.tsx:28, e.g. render={<a href>} for a link badge with [a]:hover styles) has none either. rubyui_only: size (:sm/:md/:lg) — note only.
               ↳ bases/base/ui/badge.tsx:15
  ✅ Behavior   not focusable (tabIndex -1, Tab goes to body), no controller
               Static kind: no interactive parts on either side; verified in Chromium (Tab from load leaves activeElement = BODY on both).
               ↳ bases/base/ui/badge.tsx:31
  ✅ Animation  none
               No enter/exit animation; the transition difference is on the Look line.
               ↳ style-vega.css:92
  ✅ Native     plain <span>, no JavaScript
               No hand-rolled behavior to replace.
               ↳ gem/lib/ruby_ui/badge/badge.rb:55
  ✅ Spec       <span> text, generic role
               ARIA in HTML: span has implicit role generic and any role/aria-* is allowed; shadcn adds none, nothing required. No spec clause applies. Contrast of RubyUI :secondary (Look line) is a WCAG matter outside the sources in spec-sources.md, so it is not graded here.
               ↳ https://www.w3.org/TR/html-aria/#el-span

shadcn audit on this PR
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)
✅ Theme  neutral tokens match shadcn
Badge  (ref: bases/base/ui/badge.tsx + style-vega.css @ c0d63c33)
  ✅ Look       border-bottom-left-radius: 32px; border-bottom-right-radius: 32px; border-top-left-radius: 32px; border-top-right-radius: 32px
               Badge — closed/light, closed/dark. Both sides use rounded-4xl; the token differs (docs theme has no --radius-4xl, so Tailwind default 2rem = 32px; the shadcn sandbox defines calc(var(--radius) * 2.6) = 26px). On a 20px-high badge both clamp to a 10px pill radius: pixel diff 0 px in light and dark. Theme-scale gap (radius-2xl..4xl) is outside this component; it also touches Empty and Bubble.
               ↳ style-vega.css:92
  ⏸ Look       properties: font-family
               Badge — font-family is the docs vs sandbox stack (environment diff); height and width now match (20px, same widths).
  ✅ ARIA       Badge: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       white-space nowrap; overflow hidden; justify-content center; flex-shrink 0; transition 0.15s; hover: ghost bg-muted, link underline, <a> bg-primary/80
               Badge — getComputedStyle on all 7 badges plus hover on Ghost, Link and the <a> badge, light and dark: identical values.
               ↳ bases/base/ui/badge.tsx:7
  ✅ HTML       <span data-slot="badge" data-variant=…> ×6, <a href data-slot="badge" data-variant="default">
               DOM read from both fixtures: same tags, attributes and order (only attribute order inside the <a> differs).
               ↳ bases/base/ui/badge.tsx:41
  ✅ Props      variant: default (alias :primary) | secondary | destructive | outline | ghost | link (+ success, warning, palette colors)
               All shadcn variants present; nil/unknown fall back to default. rubyui_only: size (:sm/:md/:lg) — note only.
               ↳ bases/base/ui/badge.tsx:15
  ✅ Props      href: renders <a href> — intentional
               Phlex has no render prop; a link is the only other element a badge needs, and href: matches Link and PaginationItem.
               ↳ audit/divergences.yml
  ✅ Behavior   spans not focusable; <a> badge is the first Tab stop and shows a 3px ring-ring/50 with border-ring
               Tab from load focuses the Open Link <a> on both sides; computed border and box-shadow identical in light and dark.
               ↳ bases/base/ui/badge.tsx:7
  ✅ Animation  transition-all 0.15s (hover/focus only)
               No enter/exit animation.
               ↳ style-vega.css:92
  ✅ Native     plain <span>/<a>, no JavaScript
               No hand-rolled behavior to replace.
               ↳ gem/lib/ruby_ui/badge/badge.rb:75
  ✅ Spec       <span> generic; <a href> link
               ARIA in HTML: span is generic and a[href] is a link; neither side adds ARIA, nothing required.
               ↳ https://www.w3.org/TR/html-aria/#el-span

🤖 Generated with Claude Code


Summary by cubic

Badge now matches shadcn's vega build (Base UI), and the shadcn audit reports no Look, HTML, Props, or ARIA differences. The only ⏸ is the docs-vs-sandbox font stack; the docs theme's missing --radius-2xl..4xl tokens don't show on a 20px-high badge.

  • :default becomes solid (bg-primary text-primary-foreground); :primary stays as an alias and nil/unknown variants fall back to :default. Previously variant: :default had no colors.
  • :secondary now uses bg-secondary text-secondary-foreground; before, text matched the background and was nearly invisible.
  • The badge is now a pill (h-5, 1px border, gap-1, whitespace-nowrap, shrink-0) with icons at 12px and tighter padding beside icons marked data-icon="inline-start" or "inline-end".
  • Adds :ghost and :link variants; href: renders an <a> so hover and focus styles apply, recorded in audit/divergences.yml since Phlex has no render prop.
  • Adds data-slot="badge" and data-variant, and registers Badge in the shadcn audit (map, fixtures, tests, docs). size stays a RubyUI extra (:sm is h-4, :md matches shadcn, :lg is h-6 text-sm).
  • Tests now also verify that merged user class:/data: don't replace the defaults, and that href: badges keep their anchor hover and focus classes.

Docs gain Secondary, Destructive, Ghost, Link, With Icon, and As Link examples. A Badge with no variant now renders solid instead of pale; docs pages that use Badge pick this up.