Changelog

Every published version of @garn/ui and what changed. Generated from the package changelog, so it always matches what shipped.

0.3.0

Latest

Minor Changes

  • App Shell: sidebar nav parts + an adaptive layout brain.

    • `AppShellNav` / `AppShellNavItem` — extract the rail-collapse boilerplate into shipped parts. AppShellNav is the <nav> landmark group (optional section heading that collapses on the rail and names the landmark via aria-labelledby); AppShellNavItem composes Button with a leading icon (stays centered on the rail), a trailing count (soft neutral Badge, hidden on the rail), and active (soft fill + aria-current="page" + data-active). variant/tone/asChild pass through, so it also serves a prominent sidebar action.
    • Adaptive layout (default-on) — the shell measures its own width and resolves a size class (compact / medium / expanded, Tailwind-aligned 640/1024): it auto-rails the sidebar at medium and floats the sidebar + aside as overlay Sheets at compact, with no consumer media queries. Auto-rail reconciles with manual ⌘B — only user toggles persist to the cookie, so widening the viewport restores the user's intent. New adaptive / size / defaultSize / onSizeChange props, plus useAppShell().size and a data-size attribute for group-data-[size=…] styling.

    Behavior change: existing shells become adaptive by default — an uncontrolled sidebar now defers to the size class, so tablet widths auto-rail and compact widths use drawers. Pass adaptive={false} to keep the pre-adaptive static desktop layout, or open/defaultOpen to set an explicit intent. isMobile from useAppShell() is now an alias of size === "compact".

  • Badge: add Badge.Separator, a decorative inline divider for status·action pills.

    The "status · action" pattern (e.g. a plan pill — Free plan · Upgrade) previously meant hand-rolling a divider glyph and a link inside a Badge. Badge.Separator is now a first-class compound part, and the recipe ships as an example — all while keeping Badge non-interactive by contract.

    • `Badge.Separator` (also `BadgeSeparator`) — a decorative divider between inline segments. variant="dot" (default) is a typographic middot, overridable via children; variant="line" is a slim currentColor hairline. Both inherit the badge's tone and recede via opacity. Kind axis → data-variant; aria-hidden + select-none so it never enters the badge's accessible name.
    • `PlanBadgeExample` — the Notion-style plan pill plus a line-variant metadata divider. The action is a composed real <a> (its own focus ring), not a new Badge prop — Badge stays a static label.

    No change to Badge root's API or its existing parts.

  • Button: an async-honest, focus-stable control — plus SplitButton in ButtonGroup.

    Loading buttons used to go natively disabled mid-flight, which silently dropped focus to <body> and told assistive tech nothing. This round makes the async lifecycle first-class and focus-stable, adds the layout and compound-content asks that consumers were hand-rolling, and lands the split button as smart parts of button-group rather than a new component.

    • Focusable loading — while loading, the button keeps its place in the tab order: aria-disabled + an internal activation guard, never native disabled. aria-busy rides along, and the shared live region announces the work (politely while pending and on success, assertively on error). Both ARIA attributes OR with any incoming value, so a caller's own aria-disabled survives.
    • Paced spinner — nothing appears for the first ~200ms and, once shown, stays ~300ms. Fast requests never flash a spinner; slow-ish ones never blink. The spinner is mounted from the first pending frame and merely faded, so it never restarts its rotation at the moment it becomes visible.
    • `state` + `onStateChange` — the full lifecycle (idle | loading | success | error), a superset of loading. Resolutions swap the spinner for a check or cross that draws in (motion-safe; the finished glyph, never a half-drawn one, under reduced motion) and announce completion. Wire onStateChange to the setter driving state and the button asks for "idle" once the glyph has been readable — it never mutates state itself. Reflected as data-state, which is omitted when idle so a wrapper (a menu trigger) can own the attribute.
    • `fullWidth` + `justify` — fill the container instead of hugging content, and place the leading/label/trailing run (center | start | between) once the button is wider than its content — replacing hand-rolled className="w-full justify-between", which lost the optical-trim contract.
    • `description` — an optional de-emphasized second line (the compound-button shape) for dense choice UIs and onboarding CTAs. It joins the accessible name after the label by design: the choice a compound button offers is both lines.
    • `SplitButton` + `ButtonGroupText` — a primary action attached to a menu of related ones. SplitButton shares variant/tone/size/disabled with both halves through context and owns the seam, which is derived from the active fill (a generic border token reads as a gray scratch across a brand or danger solid). Both halves stay separate native buttons, so it's two tab stops with the menu ARIA only on the chevron half; an icon-only half derives the square rung matching the wrapper, so the two can't drift in height. ButtonGroupText is a static segment (a count, a unit) cut from the same strip. Dev-time warnings catch a missing or duplicated menu-half name, a half whose axes desync the seam, and the unsupported role="toolbar" case.

    Behavior changes:

    • A `loading` button is no longer natively disabled or dimmed. It stays focusable and keeps its resting fill (disabled:opacity-50 no longer applies). Activation is still blocked, and a plain disabled button is unchanged. Code reading the disabled attribute to detect pending should read data-loading or aria-disabled instead.
    • Controls no longer move on press. The pre-existing active:translate-y-px is gone from Button, along with active:scale-90/95 on the interactive avatar and avatar-group item. Press is carried by color alone — each fill's active: darken plus the sunk bevel on the solids — and hover/focus affordances pick up the rest. A control that shifts under the cursor drags on every click and tears the seam in an attached cluster.
    • `ButtonGroup` no longer stretches in a flex parent. The base is w-fit: as a flex item an inline-flex box is blockified, so align-items: stretch had been sizing a vertical group's buttons edge to edge. w-full in className still wins through twMerge. Its inline-axis utilities are also logical now, so an RTL group merges from the correct end.
    • `button` and `button-group` registry items now pull their hooks. use-announce + use-isomorphic-layout-effect and use-merged-ref were missing from registryDependencies, so a copy-in fetched source that wouldn't compile.

    Metadata is updated for both components — the new props, slots, and reflected attributes, 18 machine-checkable a11y contracts, and 10 new examples.

  • Calendar becomes a smart, self-constraining month-grid substrate. It was a thin restyle of react-day-picker v10; now it owns the smarts the whole date family inherits (DatePicker / DateRangePicker / DateTimePicker), still on the v10 engine.

    • First-class constraintsminDate / maxDate / isDateUnavailable build the single disabled matcher and clamp navigation (startMonth / endMonth). The three pickers pass intent through instead of each rebuilding an identical matcher; typed entry keeps its own guard from the same inputs, so they can't drift.
    • Fast navigation — month + year dropdowns by default (captionLayout="dropdown", year range clamped to min/max or fromYear/toYear), plus an opt-in gridNavigation drill-down (caption → year grid → month grid).
    • Range feel — a preview band from the anchor to the pointed day on hover and keyboard focus (data-preview), resetOnSelect on by default, and minNights / maxNights.
    • garn identity — a size axis (sm / md / lg) driving the new --garn-calendar-cell token, its own cva, a typed CalendarProps (rdp mode/selected union intact — no bare alias), and data-slot / data-size / data-caption-layout reflection on the parts.
    • Day styling — picked day is a brand-solid fill with a soft brand-strong/60 hairline (endpoints only); today is brand text on a neutral fill that stays identifiable even when selected or inside a range.
    • `getDayIndicators` — up to four tone-coloured dots per day (planned / info / success / warning / danger / neutral).
    • Opt-in `showToday` footer (Today jump-and-select + an aria-live selection summary), natural week rows by default (opt-in fixedWeeks for constant height), and opt-in reduced-motion-guarded animate.
    • The nav renders in the flow (rdp navLayout="around") so the arrows sit inside the panel padding and the calendar stays content-width.

    New token: --garn-calendar-cell (sm / md / lg) in @garn/tokens.

    Note: because the pickers embed Calendar, they now show month/year dropdown captions by default; pass captionLayout="label" for a plain label.

  • Chart owns series animation — still under reduced motion. Recharts' entrance animation is JS-driven, so CSS motion-reduce can't stop it; ChartContainer now can:

    • New animate prop (default true), gated by usePrefersReducedMotion() — reduced motion always wins, so the reduced-motion audience gets a still chart with zero consumer wiring. Set animate={false} to force a static chart (screenshots, print, visual-regression suites — no more waiting out the ~1.5s entrance).
    • The container defaults isAnimationActive onto the animatable recharts children (Area/Bar/Line/Pie/Radar/RadialBar/Scatter/Funnel/Tooltip) by type identity, recursing through fragments; a series' own explicit isAnimationActive always wins.
    • The effective flag is exposed as useChart().animate (for custom series and function-as-child charts, which the defaulting pass can't see) and reflected as data-animate on the root.
    • Registry: chart now depends on use-prefers-reduced-motion.
  • Add the date & time picker family — DatePicker, DateRangePicker, TimeField, TimePicker, and DateTimePicker: typed-and-pickable controls on the shared field surface, format-driven via date-fns with native Date values. Also extracts a useControllableState hook and migrates Calendar to react-day-picker v10.

  • Field-surface Round 2 — one shared surface for the whole form-field family.

    • New lib/field-surface.tsx: the <FieldSurface> wrapper host + fieldSurfaceVariants

    (composed from FIELD_SURFACE_BASE + fieldAppearance, so the surface colors exist in exactly one place), plus NAKED_INPUT and AFFORDANCE_BTN exported once. Registered as a registry lib item (field-surface).

    • Input's wrapped path, DatePicker, DateRangePicker, DateTimePicker, and TimePicker all render

    <FieldSurface>; the private inputRootVariants and dateFieldVariants copies are deleted. dateFieldVariants is no longer exported from date-picker (use fieldSurfaceVariants from the lib).

    • Combobox's trigger now consumes the shared fieldVariants directly; comboboxFieldVariants is

    deleted (no longer exported). Visual change: the trigger adopts the shared state values — outline hover border is foreground/25 (was /40), soft fill is foreground/5 → /[0.08] on hover (was /8 → /12), ghost hover /5 (was /8) — so Combobox renders byte-identically to Input/Select at the same props. The open-state ring still mirrors the focus recipe. The popover content (role=dialog) now carries an accessible name.

    • InputOTP slots drop the legacy skin (border-input, shadow-sm, ring-ring/ring-offset) for

    the shared surface colors: border-foreground/15 at rest, active slot border-foreground/40 + ring-foreground/15, and — new — aria-invalid on the input turns slot edges and the active ring danger.

  • Add the FileUpload component — a drag-and-drop intake surface for business documents (résumés, attachments, deal-room docs), accessible-first and transport-agnostic.

    • Accessible-first, drag-second: the dropzone is a real <label> over a focusable (never display:none) <input type="file">, so click / Tab / Enter / Space open the picker with zero JS; drag-and-drop is progressive enhancement, and data-state reflects idle / dragging / reject / disabled.
    • Transport-agnostic: it owns the file queue, validation, and per-item status/progress display — never the network request. Controlled (files + onFilesChange) for real upload progress, or uncontrolled for a plain collect-files-for-a-form field.
    • Typed rejections & limits: accept / maxSize / maxFiles / maxTotalSize / duplicate detection, each onFilesRejected carrying a per-file errors[] (type | size | count | totalSize | duplicate) — validated on both the picker and drag paths.
    • Composed rows: each file renders a List.Item (mime-type icon · name · size · inline Progress · Retry/Remove), inheriting List's a11y and its Sortable drag-reorder; plus folder drop (directory), a running-total Summary, and polite/assertive live-region announcements.

    Zero runtime deps beyond class-variance-authority + lucide-react; all styling is token-driven, no raw values.

  • Add the Gallery component — a masonry / justified / uniform thumbnail grid for large sets of visual records (DAM assets, product shots, moodboard pins). Not a <div> of cards: a real layout engine, a view-agnostic selection surface, and a zero-CLS media pipeline on one stable anatomy.

    • Three-mode layout on one `layout` variant axis: uniform (aspect-cropped auto-fill grid), masonry (column-packed variable heights — reaches for the native grid-template-rows: masonry on Safari 26+ behind an @supports gate, with a DOM-order-preserving computed-span fallback everywhere else), and justified (Flickr / Google-Photos row-fill). Data-driven (items + optional renderItem) so packing math runs from each tile's aspect ratio up front — no measure-render-remeasure thrash.
    • Google-Photos-grade selection: a view-agnostic Set<id> (click · ⇧-range · ⌘/Ctrl-toggle · marquee drag · clear-on-empty), controlled or uncontrolled, plus a full roving-tabindex keyboard model (geometry-aware arrows, Home/End, Space, ⇧+arrow extend, ⌘/Ctrl+A, Esc, Enter). Selectable galleries fork to role=listbox + option + aria-multiselectable (the honest rowless multi-select pattern), read-only stay role=list, activatable-only become role=group of buttons.
    • Zero-CLS lazy media: Gallery.Media reserves each tile's aspect box, defers load until near the viewport (IntersectionObserver + native loading=lazy), blurs up an LQIP / skeletons while pending, and falls back to a broken-image glyph on error.
    • Batteries included: a built-in lightbox composing Dialog + Carousel (or the exported controlled Gallery.Lightbox); FLIP re-layout motion on reorder/filter (animate, reduced-motion-gated); drag-reorder via the sortable primitive (reorderable — pointer + keyboard + touch); and windowed virtualized rendering for large libraries. A Gallery.Overlay slot hosts hover/selected tile metadata.

    Zero runtime deps beyond lucide-react; all styling is token-driven (8 --garn-gallery-* tokens), no raw values.

  • Add InlineEdit — edit a value in place. A text-styled Preview swaps to a real field on click / focus / dblclick and back, with quiet (Enter/blur) or explicit (Save/Cancel) commit on one API. Includes validate + required gating, dirty-check (no-op commits skipped), optimistic updates with rollback, async save (stay-open on failure), a swappable typed control via useInlineEdit (NumberField/Select), multiline autosize, maxLength, form name, a composed accessible name, and ghost | outline | soft appearances on the shared field surface.

  • Add the List / ListItem content-list component — a semantic list of peer item rows (leading media · title/description · trailing meta/actions) for settings, inbox/notification feeds, file lists, search results, and resource lists.

    • Dual-mode `List.Item` (terse leading/title/description/trailing/actions or composed slots) with an ambient-default root (appearance/size/density/selectionMode), mirroring the Timeline idiom.
    • Four a11y-correct interaction models on one anatomy: inert (role=list), navigational (the card-with-primary-action overlay — whole row is one link, List.Actions escape it), a keyboard GridList for selectionMode="multiple"/keyboardNavigation (role=grid with roving focus, type-ahead, Space/Shift+Arrow range, ⌘/Ctrl+A, Esc, plus select-all, range-select, and a live-region bulk-action bar), and a role=radiogroup for selectionMode="single".
    • Surfaces & density: appearance (plain / divided + inset / card), a size ladder including a condensed xs, and a local density override.
    • Depth: grouped sections (List.Group), expandable disclosure rows (List.ItemDetail), per-row status (loading/saving/error) + unread + tone, truncation, a List.Skeleton loading preset, and an List.Empty slot. Composes cleanly with ContextMenu and InlineEdit.

    Drag-reorder and virtualization stay recipes (dnd-kit / TanStack Virtual), never dependencies.

  • Add MultiSelect — a multi-value / tag input on the shared field surface: an editable field holding values as removable chips with a searchable, stay-open listbox. Data-driven options or compound MultiSelect.Item children, per-option chip tone, and persistent checkmarks. Opt into allowCustomValue for a free-text tags mode (a "Create" row, comma/paste tokenizing via splitChars, case-insensitive dedupe) with an optional sync/async validate that marks bad tokens as danger chips. Adds a max cap, a visibleLimit "+N" overflow reveal, match highlighting, an arrow-navigable roving chip strip (Backspace/Delete removal), and multi-value form submit. Composes Tag/TagGroup, the shared FieldSurface, Popover, and cmdk.

    Also: PopoverContent now forwards forceMount to both the Portal and Content (additive), so a consumer can keep content mounted while closed.

  • Add NumberField — a typed numeric control on the shared field surface. It reuses Input and adds a trailing +/− stepper plus number value/format/parse/step logic: a type="text" input with role="spinbutton" (never native type="number"), formatted via Intl.NumberFormat (formatOptions: decimal · currency · percent · unit), with a raw number value, clamp/snap on blur, float-safe keyboard stepping (↑/↓, PageUp/Down, Home/End), min/max bounds, and a hidden input that submits the raw value. Extracts an isolated number-parse format/parse helper.

  • Add the Rating component — a star/score control that is both an input and a display.

    • One anatomy, two modes: interactively a visually-hidden radiogroup of real radios (native Arrow/Home/End keys, focus, and form submission via name + required); read-only it becomes a role="img" that announces a fractional average (e.g. "4.3 of 5") with an exactly-clipped partial symbol.
    • Signature behaviors: cumulative fill, mouse-only hover-preview that reverts on leave, half-star precision, and click-to-clear (clearable, default on).
    • Axes & content: 7-tone color intent (amber warning default), sm|md|lg sizing from control tokens, swappable icon/emptyIcon, any max, and onValueChange + onHoverChange.
    • Accessible by contract: warns in dev when an interactive rating is unnamed; the focus ring frames the whole symbol.

    Zero runtime deps beyond class-variance-authority + lucide-react; fill geometry is token-driven CSS, no raw values.

  • TimePicker's slot list is now fully keyboard-navigable. The role="listbox" of time slots gained the APG roving-tabindex model — previously the slots were pointer-select only, so keyboard users were stuck on the typed field.

    • Keyboard model. Exactly one option is tabbable at a time (roving tabindex). ArrowUp/Down move the roving focus (clamped at the ends, no wrap); Home/End jump to the first/last slot; PageUp/Down move ≈an hour of slots; Enter/Space commit the focused slot, closing the popover and returning focus to the field. On open, focus lands on the selected slot (or the first when there's no value) and scrolls into view — the listbox equivalent of the calendar auto-focusing its day.
    • Robustness. The focus-on-open fires only on the open transition (an external value change while the list is open no longer yanks roving focus back), and the roving index is clamped when a smaller interval or new format shrinks the slot set, so exactly one option always stays tabbable.
    • Metadata caught up too (folds in the earlier metadata-only note): time-picker.info.ts — and the generated docs / registry / llms surfaces — now describe the in-list keyboard model (a11y.keyboard rows, the roving a11y.aria line, editorial.guidelines), and the obsolete "keyboard users can't arrow through the slot list" gotcha was replaced with the 12-hour duplicate-label one (a format without a meridiem token renders AM/PM slots identically — add a).

    TimeField is unchanged — it has no slot list (a typed field with Arrow/Page stepping already).

  • Finish the semantic token rename (audit S2): the shadcn-era primary/destructive names give way to the brand/danger vocabulary already used by the tone axis.

    New tokens + utilities (values unchanged — pixel-identical):

    | Old | New | | ------------------------------------------------------- | --------------------------------------------------------------- | | --garn-primary / bg-primary | --garn-brand-solid / bg-brand-solid | | --garn-primary-foreground / text-primary-foreground | --garn-brand-solid-foreground / text-brand-solid-foreground | | --garn-primary-strong | --garn-brand-strong | | --garn-primary-subtle(-foreground) | --garn-brand-subtle(-foreground) | | text-primary / border-primary (accent) | text-brand / border-brand | | --garn-destructive / bg-destructive | --garn-danger-solid / bg-danger-solid | | --garn-destructive-foreground | --garn-danger-solid-foreground |

    The old tokens and Tailwind utilities remain as deprecated aliases for this release and will be removed in the next minor. The theme builder (/theme) now emits the new names; all components, examples, and metadata reference the new vocabulary.

  • Add the Toolbar component — a semantic, roving-focus strip that puts a whole row of related controls on ONE tab stop and navigates between them with the arrow keys, with measured overflow that folds out-of-space actions into a menu instead of dropping them. It fills a real roster gap: ButtonGroup only visually joins a cluster (role=group, no keyboard model), while Toolbar adds the role=toolbar semantics, the single tab stop with continuous arrow navigation across nested groups, and responsive collapse. It's deliberately thin — a roving + overflow shell that composes garn's Button, Toggle-group, Separator, Dropdown-menu, Popover, and Tooltip rather than re-skinning them.

    • APG-correct roving focus: one tab stop via roving tabindex (real DOM focus, not aria-activedescendant); ArrowRight/Left (or Up/Down when vertical) rove between controls, Home/End jump to the ends, and arrow direction follows dir under RTL. The role=toolbar + roving only switch on at 3+ controls (the APG threshold) — a 1–2 control cluster stays a plain role=group. Nested ToolbarGroup / ToolbarToggleGroup add no second tab stop; their items traverse inline within the single arrow sequence.
    • Measured overflow collapse: overflow=collapse (default) measures the bar against an off-screen mirror layer and moves the out-of-space controls into a more-menu (a real Dropdown-menu, each control re-rendering itself as a menu row) — no action is ever lost, the bar keeps a stable single-row height, and dangling/adjacent separators are trimmed across the cut. Measurement runs synchronously in the layout effect (works in backgrounded tabs), and content changes re-fit via geometry, not a ResizeObserver. scroll / wrap / visible are the pure-CSS alternatives.
    • A full set of roving-aware parts: ToolbarButton (composes Button; tooltip for icon-only names, shortcut<kbd> in the tooltip + a hint in the menu + aria-keyshortcuts), ToolbarToggleGroup/ToolbarToggleItem (single/multiple selection that delegates roving to the bar and survives relocation into the menu via a root-lifted store), ToolbarLink, and ToolbarInput — a first-class text/search member that is pinned in the bar (never collapses), takes over Arrow/Home/End for the caret while focused, and is placed last per APG.
    • `ToolbarSplitButton` — a primary action attached to a menu of related ones as ONE roving stop, the toolbar-native fix for the fact that a standalone SplitButton isn't a roving item (its halves keep tabIndex 0, add stray tab stops, and count nothing toward the APG gate). The menu half is tabIndex=-1, reached on the perpendicular arrow axis with the menu ARIA on it alone, counts once toward the threshold, and collapses to a primary row plus a submenu named by menuLabel.
    • `ToolbarFloating` — the selection / bubble toolbar (Notion / Tiptap / Medium style). It composes Popover to anchor a variant=floating bar against an element or a virtual anchor (a ref exposing getBoundingClientRect), keeps focus on the selection by default (autoFocus off), and strips the popover chrome so the inner bar owns the elevated look.
    • Structural + surface axes: variant = docked | floating (structural — never color, per CLAUDE.md §5); appearance = solid | soft | outline | ghost; size = sm | md | lg (density → gap / padding / radius, control heights from --garn-control-*, never hand-padded); orientation = horizontal | vertical. A soft bar sets data-elevated so its selected toggles lift to a card "thumb" that reads on the tint (WCAG 1.4.11) instead of a same-luminance fill swap. Every enum axis reflects to its data-* attribute.
    • Focusable-when-disabled: a disabled ToolbarButton / ToolbarToggleItem / ToolbarLink / ToolbarSplitButton keeps aria-disabled + a click guard rather than native disabled (which drops it from roving), so it stays arrow-reachable in the sequence but inert to activation.

    Ships with full metadata: props, slots, and reflected attributes; 22 machine-checkable a11y contracts (single tab stop, roving arrows, vertical orientation, Home/End, RTL, nested-group single sequence, disabled-focusable, icon-name, overflow reachability + reduced-motion, pinned input, and the split-button / floating contracts) including the 4 dev-time accessible-name warnings mirrored verbatim; and 10 examples (formatting bar, appearances, sizes, vertical rail, overflow collapse, app action bar, split button, vertical overflow, pinned search, floating selection bubble).

    Adds one dependency, @radix-ui/react-toolbar (^1.1.12), for the roving-focus engine.

  • Add the Tree component — an arbitrary-depth WAI-ARIA tree view: expand/collapse with roving-tabindex keyboard, single/multiple selection, tri-state checkbox cascade, filter-to-reveal with highlighting, scroll-to-node, async lazy children, and inline rename.

  • Enforce the variant doctrine — tone = color intent, appearance = fill, variant = kind/structural (Button's emphasis axis is the named exception). Axis names are now reflected to matching data-* attributes and linted in metadata.

    BREAKING — axis renames (no aliases):

    • Alert: varianttone; the neutral value defaultneutral. <Alert variant="danger"><Alert tone="danger">, <Alert variant="default"><Alert tone="neutral"> (or omit). Reflects data-tone (was data-variant).
    • Toggle / ToggleGroup: variantappearance (values unchanged: default | outline). <Toggle variant="outline"><Toggle appearance="outline">. Reflects data-appearance.
    • Kbd: variantappearance (values unchanged: default | ghost). <Kbd variant="ghost"><Kbd appearance="ghost">. Reflects data-appearance.

    BREAKING — Button splits into orthogonal `variant` (fill) × `tone` (color):

    variant now carries only fill/emphasis (default | soft | outline | ghost | link); the new optional tone (neutral | brand | danger | success | warning, default neutral) carries color intent and reflects to data-tone. Value mapping:

    | before | after | | ------------------------------------------------- | ----------------------------------------------------------- | | variant="brand" | tone="brand" | | variant="danger" | tone="danger" | | variant="danger-outline" | variant="outline" tone="danger" | | variant="danger-ghost" | variant="ghost" tone="danger" | | variant="success" | tone="success" | | variant="warning" | tone="warning" | | variant="secondary" | variant="soft" (it was an indistinguishable neutral soft) | | default / soft / outline / ghost / link | unchanged (tone defaults to neutral) |

    The axis is now complete: every tone combines with default/soft/outline/ghost (e.g. success-outline, warning-ghost now exist). link is tone-inert. secondary is removed — it resolved to the same neutral surface token as soft (tone=neutral), so variant="soft" replaces it.

    Minor behavior change: variant="soft" now renders a neutral soft surface (it previously implied brand). For the old brand-tinted soft, use variant="soft" tone="brand".

Patch Changes

  • App Shell: capture the master–detail / three-pane pattern as a recipe.

    The selection→peek wiring, empty-detail state, and mobile stacking of a master–detail view (email, CRM record, settings, file browser) were re-derived on every app. They're now a documented master-detail recipe on App Shell plus a copy-paste example.

    • Recipe (`master-detail`) — a single-select List (or Table) in Main drives the detail AppShellAside: selecting a row peeks the aside open (controlled asideOpen) and swaps its contents, an Empty fills the aside until a row is chosen, and at the compact tier the aside floats as an overlay Sheet — no consumer media queries. Matchable via garn_match_recipe ("master detail", "three-pane", "split view", "list selects → detail → collapses on mobile") with an enforceable a11y contract.
    • Example (`master-detail`) — a runnable three-pane demo wiring List selectionMode="single"onSelectionChangeasideOpen, with the Empty empty-detail branch and the AppShellAsideTrigger. size is pinned to expanded so the three-pane is deterministic in the lab.
    • New gotcha + `slotAccepts` edges to list (master pane) and empty (empty-detail state), so the wiring is discoverable instead of re-derived.

    No runtime API change — App Shell already exposes the slots and the controlled asideOpen/adaptive size this composes.

  • Combobox now highlights the matched substring in option labels. The run of an option's label matching the current search is wrapped in a <mark data-slot="combobox-item-match"> (case-insensitive first-hit on plain-string labels), so the list shows why each option matched — the same treatment MultiSelect already ships, lifted verbatim so the two cmdk siblings read identically. No-op for non-string children or an empty search, and no API/ARIA change (the <mark> preserves the option's accessible name).

  • Command: give CommandDialog an accessible name. It now renders a default sr-only DialogTitle/DialogDescription ("Command menu" / "Search for a command to run."), satisfying Radix's required accessible name and removing the missing-name console error. Both are overridable via the new title/description props.

  • Drawer now moves focus into the surface on open, matching the Radix modal family (Dialog/Sheet) and the APG modal-dialog contract. vaul ships its open-autofocus off (to keep focus for its drag gesture), which left keyboard and screen-reader users parked on the trigger, outside the modal. garn's Drawer root now defaults autoFocus on, so focus lands on the first focusable inside the content on open (verified in a real browser across Chromium/Firefox/WebKit). A consumer with a drag-first sheet or an input that shouldn't grab focus can opt out with autoFocus={false}.

  • Filters (FilterBar) metadata — the Phase-2 catch-up for the component shipped in `feat(filters): … Phase 1` (`cadcb71`). Authors filters.info.ts from scratch so the roster, docs (/components/filters), registry, cards, and llms surfaces describe the component. No API or behavior change.

    • Roster + surfacesfilters now appears in the CLAUDE.md forms roster and generates out/md/filters.md, out/cards/filters.md, the registry item, and llms.txt entries; versions.json freezes it at 0.2.0.
    • A11y contract — documents the shipped keyboard/ARIA model: the chip row is a role="grid" TagGroup (interactive remove buttons ⇒ grid, not listbox) with roving tabindex, Enter-to-drill / arrow-rove between segments / Esc-exit / Backspace-remove-and-refocus-neighbour, descriptive segment names (Operator: … Change, Remove filter: …), and a role="status" result count. Four contracts (filters-chips-grid-semantics, filters-remove-focus-to-neighbour, filters-segment-accessible-names, filters-result-count-status).
    • Anatomy — 17 data-slot regions (bar · chips · chip + operator/value/remove segments · value-editor · conjunction · nested group · add · result-count · clear · saved-views · ask · empty/loading/error) and the 10 exported FilterBar.* parts; the chip's reflected data-type / data-operator / data-tone / data-state (complete·pending) are described.
    • PropsFilterBar's 8 root props (fields / filters / defaultFilters / onFiltersChange / records / engine / apiRef / size) surface via the FilterBar anatomy part (the id-derived FiltersProps name doesn't exist — the main interface is FilterBarProps), plus FilterBar.Ask / FilterBar.Error own props.
    • Usage + AI hints — whenToUse / whenNotToUse (names the alternatives: the faceted-filter recipe, Input search, MultiSelect, Command), 5 recipes (data-table filter bar, faceted value editor, natural-language Ask, saved views, nested groups), relationships (composes tag/popover/command/input/number-field/date-picker/switch/spinner/multi-select; pairsWith table), and 7 gotchas (the [&_svg]:size-4 !important chip-icon trap, fields vs engine required, pending chips don't filter, rolling-vs-calendar relative dates, controlled-without-onChange, records-drive-the-count, "Ask returns a query not an answer").
    • 8 examples registered (basic · prefilled · live-results · full-matrix · nested-groups · ask · saved-views · url-round-trip).
  • TimePicker slots are now keyed by time-of-day, not by label. Under a 12-hour format without a meridiem token (e.g. "hh:mm"), the AM and PM slots render with the same label (01:00 for both 1 AM and 1 PM), so keying the list by label collided (key={s.label} → duplicate React keys). The slots are keyed by their hour:minute instead, which is always unique.

0.2.0

Minor Changes

  • Badge now renders its count sugar with the real Counter (replacing the placeholder chip). The trailing count gains Counter's behavior — a 99+ overflow cap (so count={128} now reads 99+, with the true count announced to assistive tech), the odometer digit roll, and tabular-nums width stability. Tone and size follow the badge; the count's appearance inverts against the badge's (a solid badge gets a soft count chip and vice versa) so it always contrasts. The data-slot="badge-count" anchor is unchanged. Badge now lists counter as a registry dependency.

  • Add BadgeSelect — a single-value "pick to change" control styled as a Badge (the editable-Lozenge / status-picker pattern). The trigger reads as a tinted lozenge until you interact with it, then opens a single-select list to change the value; its signature behavior is that the trigger's tone follows the selected option (choose "Done" → green, "Blocked" → red), so a table/board cell encodes state in both label and colour. It composes garn's `Select` (Radix Select): single-select state (controlled + uncontrolled), open/close, focus, popper positioning, typeahead, the full keyboard model, and form integration (the hidden native <select> via name) are all inherited. Listbox (not menu) semantics — the collapsed trigger must convey a value, which a menu button can't. The trigger is resolved by walking the option children synchronously at render, so it paints the right tone on first paint (before the portal mounts).

    Styling reuses the badge family's tone × appearance × shape × size vocabulary and the shared --garn-badge-* tokens (mode-aware): seven tones (neutral/brand/info/success/warning/danger/discovery), an appearance of soft (a light tinted lozenge, default), outline, or framed (no solid — a filled trigger reads as a button/CTA), a shape of sharp/rounded/pill, and a badge-sized size (sm=20px / md=28px, fixed across density). The trigger always follows the selected option's tone; a placeholder shows when nothing is selected.

    Options are compound BadgeSelectItem children — each with value, tone (mirrored into the trigger when selected, and the row's leading dot), leading (icon / avatar / badge, mirrored into the trigger), trailing (row-only secondary meta), and count (a tone-matched Counter tally, shown in the row and mirrored into the trigger as a solid contrast-popping pill) — or a data-driven options array. BadgeSelectGroup / BadgeSelectLabel / BadgeSelectSeparator section long lists. A loading prop puts a Spinner in the trigger and disables it for pending async changes.

    Accessibility: the trigger is a combobox with a listbox of options (single-select, aria-selected + a trailing check); it requires an accessible name (aria-label / aria-labelledby, or a <label htmlFor> matching the trigger id) — a dev-time warning fires when one is missing, and the placeholder doesn't count. Ships with metadata, a 14-example docs page, and a behavior/ARIA test suite. Lists select, counter, and spinner as registry dependencies.

  • Add Counter — a standalone numeric count pill (the productized BadgeCount). It caps overflow at 99+ (max), hides at zero (showZero), can abbreviate large counts (format="compact"1.2K), and rolls its digits on change with a dependency-free CSS odometer that degrades to an instant swap under prefers-reduced-motion. The visual value is aria-hidden while the true count rides an sr-only aria-live="polite" region, so assistive tech always hears the real number and changes are announced without moving focus. Tone × appearance × size × shape come from the matrix and resolve through the shared --garn-badge-* tokens (mode-aware): seven tones (neutral/brand/info/success/warning/danger/discovery — discovery is a new violet/purple badge-token family), an appearance of solid (filled bubble, default) or soft (light tinted fill + darker tinted text), and a shape of sharp (rectangle), rounded, or pill (default). An optional pulse adds a reduced-motion-safe "new activity" halo.

    Also adds `Counter.Indicator` — a wrapper that anchors a marker onto a child element (a notification bell, an avatar): a bare presence dot or a composed Counter at the corner. Logical, RTL-safe positioning (position 9-cell grid, overlap rectangular/circular for round targets, offset), a surface-colored ring (withBorder), hide-at-zero / invisible, and pulse. It composes Counter for the count case, so the cap/roll/announce all come for free; the bare dot is decorative (aria-hidden) and the target carries the meaning in its label.

  • Shared field surface. Input, the Select trigger, and Textarea now render one identical control surface (border · --garn-control-* height/padding · hover · focus-visible ring · aria-invalid · disabled) from a single exported fieldVariants cva (appearance × size), ending the drift where each control re-declared its own near-duplicate styling. The surface lives in a new copy-in lib, field-variants (a registry dependency of input/select/textarea), exporting fieldVariants, fieldAppearance, and FIELD_SURFACE_BASE.

    • BREAKING — `Input`'s `variant` prop is renamed to `appearance` (no alias): <Input variant="soft" /><Input appearance="soft" />. Values are unchanged (outline | soft | ghost), and the reflected attribute is now data-appearance (was data-variant). The internal inputVariants export was removed in favour of the shared fieldVariants.
    • `Select` and `Textarea` gain the `appearance` axis (outline | soft | ghost) they previously lacked, and their size scale is unified to xs–xl (was sm | md | lg). Both also drop the legacy shadow-sm/border-input/ring-offset look in favour of the shared flat surface, and reflect data-appearance.
    • Fix — `SelectItem` highlight on hover. Options used focus-visible: for the highlight, which only matched keyboard focus, so hovering options showed no highlight; switched to focus: (matching the rest of the Radix-menu family) so pointer-hover and keyboard both highlight.
  • Modernize every component to the React 19 shape — ref-as-prop: drop forwardRef, manual displayName, and the React.ElementRef/ComponentPropsWithoutRef typing in favour of plain functions typed with React.ComponentProps. Components now **reflect their cva axes onto data-*** (data-variant, data-size, data-tone, data-side, …) alongside the existing data-slot, so consumers can style variants via group-data-[…] without prop drilling. "use client" added to button. No public prop or behaviour changes.

  • Add Stepper — a compound, accessibility-first wizard for ordered, often-gated processes (checkout, onboarding, multi-section forms). The root owns the active-step index (controlled value / uncontrolled defaultValue / onValueChange, via an in-house useControllableState) and runs an async forward-gate; siblings read it from context.

    Anatomy: Stepper · StepperList (nav > ol) · Step (<li>, carries title / description / icon / completedIcon / optional / error / disabled / loading / subStep) · StepIndicator · StepSeparator · StepTitle / StepDescription · StepContent (position-matched panel) · StepperCompleted · StepperPrevTrigger / StepperNextTrigger (asChild). Public cva: stepperIndicatorVariants.

    Axes: orientation (horizontal / vertical) × variant (numbered / dot — indicator shape) × look (filled / Carbon outline — a bundled visual language, numbered-only; dots are look-agnostic) × size (sm / md / lg) × labelPlacement (end / bottom, horizontal-only) × linear (gated forward via Next, completed steps revisitable) vs non-linear (any step clickable). Step state (upcoming / current / complete / error / disabled) is derived from the active index and surfaced as data-state; data-orientation / data-variant / data-look / data-size reflect onto the root, every Step, and StepIndicator for group-data-[…] styling. Named look (not appearance) to keep it distinct from Badge/Tag/Counter's orthogonal tone × appearance fill vocabulary — there is no tone axis because a stepper's colours are state-derived, not user-chosen.

    Accessibility (the differentiator — APG has no stepper pattern): a real nav > ol > li with aria-current="step" on the active step, aria-hidden indicators, and a single role="status" polite live region announcing "Step N of M: <title>". StepContent is a role="region" labelled by its step title, hidden when inactive; an interactive step aria-controls its panel only when one exists (no dangling reference). The tablist model is deliberately avoided (it implies free navigation that conflicts with linear gating). onBeforeStepChange({ from, to, direction }) gates forward moves (Back is always free), disables Next while pending, and shows a spinner on the target indicator; the progress connector animates via scaleX(--stepper-progress) and collapses under prefers-reduced-motion. Semantic tokens only; React-19 ref-as-prop throughout. Ships a 27-test suite (structure, a11y contract, data-\* reflection, state derivation, linear/non-linear navigation, controlled/uncontrolled + Prev/Next, async gate, content panels).

  • Add Tag / TagGroup — interactive, removable + selectable chips, modelled on React Aria's TagGroup. A standalone <Tag> is a lone filter token: dismissable via onRemove (a real, named "Remove {label}" button) or clickable / a link via asChild. A <TagGroup> coordinates a set of Tags with selectionMode (none | single | multiple, controlled selectedKeys / uncontrolled defaultSelectedKeys + onSelectionChange, disabledKeys, disallowEmptySelection) and per-chip removal — all keyed off each Tag's value. A selected chip reads as a leading check + a stronger solid fill.

    Built in-house on the grid interaction pattern: the group is one tab stop (role="grid", aria-multiselectable in multiple mode), arrows + Home/End rove between chips (each role="row", logical so they mirror under RTL), Enter/Space toggle selection, and Backspace/Delete removes the focused chip and moves focus to its neighbour — a row is not an interactive role, which is what lets a chip host a focusable remove button without a nested-interactive violation. A dev-time probe warns when a TagGroup has no accessible name (label auto-wires aria-labelledby); empty renders a slot when all chips are removed.

    Styling reuses Badge's vocabulary verbatim — tone (seven: neutral/brand/info/success/warning/danger/discovery) × appearance (solid/soft/outline/framed) × shape (sharp/rounded/pill) × size (sm = 20px / md = 28px, with a flat 6px padding-x), resolved through the shared --garn-badge-* tokens (mode-aware in the token layer) — so the badge family (Badge / Counter / Tag) reads as one system. State changes (hover, selection, focus ring, disabled) animate via a reduced-motion-safe transition. Ambient tone/appearance/shape/size flow from the group via context; any Tag may override.

    A chip can also carry a `count` (composes <Counter> — tone/size matched, appearance contrast-inverted) and a `trailing` slot, rendered before the remove button. Compound parts Tag.Leading / Tag.Label / Tag.Remove are exported as the escape hatch. Long labels can truncate (+ maxWidth).

Patch Changes

  • BadgeSelect: remove the tinted prop (and its tinted={false} "tinted off" mode). The trigger now always follows the selected option's tone — the component's signature behavior is non-optional. A fixed-tone trigger defeated that behavior and was broken in practice.

    Removing a public prop is a breaking change by nature; it lands as a patch only because BadgeSelect has not shipped a release yet (its Add BadgeSelect changeset is still pending), so there is no published API to break. To pin a fixed trigger tone, set a single-tone option set instead.

  • Accessibility fixes surfaced by the new Phase 4 test suite:

    • Progress — forward value to the Radix Progress.Root (not only the indicator transform) so it wires aria-valuenow / data-state. The bar previously filled visually but screen readers announced no value.
    • Slider — forward aria-label / aria-labelledby to each Slider.Thumb (the role="slider" element). They were landing on the Root wrapper, so the slider handle shipped with no accessible name (affecting every documented example).

0.1.0

Minor Changes

  • Initial 0.1.0 release — the 48-component garn surface, documented end-to-end via ComponentInfo (schema → registry/markdown/Fumadocs/llms.txt/MCP) with lifecycle (version/since/deprecation) wired through every channel.

Versioned with Changesets. Per-component history lives on each component page under its Changelog tab.