@bearmenu/ui 0.7.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/{chunk-4JOV7FXW.js → chunk-2DEIXTBN.js} +1 -1
  2. package/dist/{chunk-UUJVXN2O.js → chunk-66MAPLXS.js} +3 -1
  3. package/dist/{chunk-Y5D5LP4A.js → chunk-7BWTYWM3.js} +14 -3
  4. package/dist/{chunk-ZFNOCP6W.js → chunk-H3BV4VAG.js} +15 -1
  5. package/dist/{chunk-TL4YU2O5.js → chunk-UKBNQO6Q.js} +2 -2
  6. package/dist/{chunk-UX6SWAKH.js → chunk-UZ3PCIE5.js} +119 -28
  7. package/dist/{chunk-YLNYXPIH.js → chunk-VBA45GLP.js} +1 -1
  8. package/dist/{chunk-A5ONAJCE.js → chunk-VG3PND3D.js} +1 -1
  9. package/dist/{chunk-ZBBUKXM5.js → chunk-X3WMRKOW.js} +1 -1
  10. package/dist/{chunk-HCM6LKM5.js → chunk-XNA2INVQ.js} +2 -2
  11. package/dist/{chunk-AV777R4G.js → chunk-YYGB4G4Q.js} +1 -1
  12. package/dist/components/accordion.js +1 -1
  13. package/dist/components/alert-dialog.js +1 -1
  14. package/dist/components/alert.js +1 -1
  15. package/dist/components/avatar.d.ts +10 -1
  16. package/dist/components/avatar.js +41 -14
  17. package/dist/components/badge.d.ts +11 -2
  18. package/dist/components/badge.js +1 -1
  19. package/dist/components/breadcrumb.js +1 -1
  20. package/dist/components/button.d.ts +1 -1
  21. package/dist/components/button.js +1 -1
  22. package/dist/components/calendar.js +3 -3
  23. package/dist/components/card.js +1 -1
  24. package/dist/components/carousel.js +1 -1
  25. package/dist/components/circular-progress.js +1 -1
  26. package/dist/components/combobox.js +3 -3
  27. package/dist/components/command.js +1 -1
  28. package/dist/components/currency-input.js +2 -2
  29. package/dist/components/date-input.js +1 -1
  30. package/dist/components/description-list.js +1 -1
  31. package/dist/components/drawer.js +5 -1
  32. package/dist/components/dropdown-menu.js +1 -1
  33. package/dist/components/empty-state.js +2 -2
  34. package/dist/components/field.js +2 -2
  35. package/dist/components/filter-category-row.js +2 -2
  36. package/dist/components/form.js +1 -1
  37. package/dist/components/haptic-button.js +1 -1
  38. package/dist/components/input-group.js +1 -1
  39. package/dist/components/input.js +1 -1
  40. package/dist/components/item.js +1 -1
  41. package/dist/components/kbd.js +1 -1
  42. package/dist/components/label.js +1 -1
  43. package/dist/components/link-button.js +1 -1
  44. package/dist/components/multi-select-combobox.js +4 -4
  45. package/dist/components/pagination.js +1 -1
  46. package/dist/components/place-about-tab.d.ts +17 -1
  47. package/dist/components/place-about-tab.js +4 -4
  48. package/dist/components/place-card.js +2 -2
  49. package/dist/components/place-details-mobile.js +7 -7
  50. package/dist/components/place-schedule-week.js +2 -2
  51. package/dist/components/popover.js +1 -1
  52. package/dist/components/searchable-select.js +3 -3
  53. package/dist/components/select.js +2 -2
  54. package/dist/components/selection-bar.js +1 -1
  55. package/dist/components/sheet.d.ts +23 -0
  56. package/dist/components/sheet.js +51 -14
  57. package/dist/components/sliding-panels.js +5 -1
  58. package/dist/components/sonner.js +2 -2
  59. package/dist/components/stepper.js +4 -1
  60. package/dist/components/table.js +1 -1
  61. package/dist/components/tabs.js +1 -1
  62. package/dist/components/tag-group.js +1 -1
  63. package/dist/components/tag.js +1 -1
  64. package/dist/components/text.d.ts +18 -0
  65. package/dist/components/text.js +1 -1
  66. package/dist/components/toggle-group.js +1 -1
  67. package/dist/components/toggle.js +1 -1
  68. package/dist/lib/card-hover.d.ts +75 -0
  69. package/dist/lib/card-hover.js +18 -0
  70. package/package.json +1 -1
  71. package/src/components/accordion.tsx +1 -1
  72. package/src/components/avatar.stories.tsx +59 -26
  73. package/src/components/avatar.test.tsx +129 -0
  74. package/src/components/avatar.tsx +93 -37
  75. package/src/components/badge.tsx +15 -3
  76. package/src/components/breadcrumb.tsx +1 -1
  77. package/src/components/button.tsx +16 -1
  78. package/src/components/calendar.tsx +2 -2
  79. package/src/components/circular-progress.tsx +1 -1
  80. package/src/components/command.tsx +2 -2
  81. package/src/components/currency-input.tsx +1 -1
  82. package/src/components/description-list.tsx +1 -1
  83. package/src/components/drawer.tsx +5 -1
  84. package/src/components/dropdown-menu.tsx +1 -1
  85. package/src/components/field.tsx +1 -1
  86. package/src/components/input-group.tsx +1 -1
  87. package/src/components/input.tsx +1 -1
  88. package/src/components/item.tsx +1 -1
  89. package/src/components/kbd.tsx +1 -1
  90. package/src/components/label.tsx +1 -1
  91. package/src/components/place-about-tab.tsx +131 -18
  92. package/src/components/place-card.tsx +1 -1
  93. package/src/components/place-details-mobile.tsx +2 -2
  94. package/src/components/popover.tsx +9 -1
  95. package/src/components/select.tsx +2 -2
  96. package/src/components/selection-bar.tsx +1 -1
  97. package/src/components/sheet.tsx +72 -12
  98. package/src/components/sliding-panels.stories.tsx +70 -0
  99. package/src/components/sliding-panels.test.tsx +116 -0
  100. package/src/components/sliding-panels.tsx +14 -0
  101. package/src/components/sonner.tsx +2 -2
  102. package/src/components/stepper.tsx +4 -1
  103. package/src/components/table.tsx +1 -1
  104. package/src/components/tabs.tsx +1 -1
  105. package/src/components/tag-group.tsx +1 -1
  106. package/src/components/tag.tsx +1 -1
  107. package/src/components/text.test.tsx +37 -3
  108. package/src/components/text.tsx +32 -3
  109. package/src/components/toggle.tsx +1 -1
  110. package/src/globals.css +53 -11
  111. package/src/lib/card-hover.ts +87 -0
  112. package/dist/chunk-EBAMCZDW.js +0 -112
  113. package/dist/chunk-VQNDZVI7.js +0 -106
  114. package/dist/components/toast.d.ts +0 -18
  115. package/dist/components/toast.js +0 -3
  116. package/dist/components/toaster.d.ts +0 -5
  117. package/dist/components/toaster.js +0 -25
  118. package/dist/components/use-toast.d.ts +0 -42
  119. package/dist/components/use-toast.js +0 -2
  120. package/src/components/toast.stories.tsx +0 -63
  121. package/src/components/toast.tsx +0 -130
  122. package/src/components/toaster.tsx +0 -31
  123. package/src/components/use-toast.ts +0 -180
@@ -3,6 +3,24 @@ import * as class_variance_authority_types from 'class-variance-authority/types'
3
3
  import * as React from 'react';
4
4
  import { VariantProps } from 'class-variance-authority';
5
5
 
6
+ /**
7
+ * The type scale is a CLOSED set of roles. Seven carry every surface:
8
+ *
9
+ * h1 page title 30/700
10
+ * h3 section heading 20/600
11
+ * body running prose 16/400 relaxed
12
+ * label row / item title 14/600
13
+ * body-sm secondary fact 14/400
14
+ * overline eyebrow 12/600 uppercase
15
+ * caption fine print 12/400
16
+ *
17
+ * Emphasis, colour and tabular figures are ORTHOGONAL modifiers (`weight`,
18
+ * `color`, className) — they must never be expressed by changing SIZE. A
19
+ * paragraph that steps 16 → 14 mid-sentence to mark its lead reads as two
20
+ * paragraphs jammed together; that is what the modifiers exist for.
21
+ *
22
+ * Weights are 400 / 600 / 700. 500 is not in the system.
23
+ */
6
24
  declare const textVariants: (props?: ({
7
25
  variant?: "h2" | "h3" | "label" | "body" | "caption" | "h1" | "h4" | "small" | "display" | "body-lg" | "body-sm" | "overline" | null | undefined;
8
26
  color?: "inherit" | "default" | "destructive" | "muted" | "primary" | "primary-foreground" | null | undefined;
@@ -1,3 +1,3 @@
1
- export { Text, textVariants } from '../chunk-Y5D5LP4A.js';
1
+ export { Text, textVariants } from '../chunk-7BWTYWM3.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-3GWWZKX7.js';
@@ -1,4 +1,4 @@
1
- import { toggleVariants } from '../chunk-ZBBUKXM5.js';
1
+ import { toggleVariants } from '../chunk-X3WMRKOW.js';
2
2
  import { cn } from '../chunk-FEK5XGZW.js';
3
3
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-3GWWZKX7.js';
4
4
  import * as React from 'react';
@@ -1,3 +1,3 @@
1
- export { Toggle, toggleVariants } from '../chunk-ZBBUKXM5.js';
1
+ export { Toggle, toggleVariants } from '../chunk-X3WMRKOW.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-3GWWZKX7.js';
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Card hover motion tokens — the shared recipe for any card with a cover image.
3
+ *
4
+ * Every card type animates the same set of properties (transform on the card
5
+ * root, transform on the image, box-shadow on the image wrapper) so the timings
6
+ * must stay in lockstep. Defining the tokens in one place is the only way to
7
+ * guarantee a hover that feels identical across a mixed grid.
8
+ *
9
+ * Prefer the COMPOSED tokens (`HOVER_IMAGE_WRAPPER`, `HOVER_IMAGE_MOTION`) over
10
+ * re-assembling a recipe from `HOVER_DURATION` + `HOVER_EASE` + a shadow at the
11
+ * call site. Consumers that did the latter silently shipped without a hover
12
+ * shadow — the transition and the resting shadow were wired up, the hover state
13
+ * simply never was, and nothing failed loudly. One class per element is what
14
+ * stops that.
15
+ *
16
+ * Companion CSS custom properties live in `./globals.css`:
17
+ * --shadow-card-image / --shadow-card-image-hover / --shadow-menu-cover-inset
18
+ * They carry the dark-mode override, so none of these tokens needs a `dark:`
19
+ * twin of every state. A consumer app must NOT redefine them locally — app
20
+ * stylesheets are imported after this one and would silently win.
21
+ *
22
+ * Why we don't animate filter / mix-blend-mode opacity:
23
+ * - filter animations (`saturate`, `brightness`) on <img> are CPU-heavy and
24
+ * can hitch on lower-end devices.
25
+ * - mix-blend-multiply opacity transitions composite at unpredictable frame
26
+ * rates so they never sync with transform/opacity transitions.
27
+ * Restricting hover to transform + box-shadow keeps every element on the same
28
+ * GPU-composited frame, which is what makes the in/out feel perfectly synced.
29
+ */
30
+ declare const HOVER_DURATION = "duration-[320ms]";
31
+ declare const HOVER_EASE = "ease-[cubic-bezier(0.4,0,0.2,1)]";
32
+ /** Apply to the card root — composes the base transform transition. */
33
+ declare const HOVER_MOTION = "transition-transform duration-[320ms] ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none will-change-transform";
34
+ /** Apply to elements that animate box-shadow on hover (image wrappers). */
35
+ declare const HOVER_SHADOW_MOTION = "transition-shadow duration-[320ms] ease-[cubic-bezier(0.4,0,0.2,1)]";
36
+ /** Grid/standard/hero card variants — full lift. */
37
+ declare const HOVER_LIFT_GRID = "[@media(hover:hover)]:hover:-translate-y-1";
38
+ /**
39
+ * Same lift as `HOVER_LIFT_GRID`, but driven by a NAMED `group/card` ancestor
40
+ * instead of the element's own `:hover`.
41
+ *
42
+ * For overlays that must render as SIBLINGS of the interactive card rather than
43
+ * children — e.g. a pill that is itself a button or link, which would be
44
+ * invalid ARIA nested inside the card's own `role="button"` / `<a>`. A sibling
45
+ * can't inherit the card's transform, so it stays pinned while the card lifts
46
+ * out from under it. Putting `group/card` on the shared wrapper and this token
47
+ * on BOTH the card and the overlay moves them as one, and means hovering the
48
+ * overlay lifts the card too (its own `:hover` never fires for a sibling).
49
+ */
50
+ declare const HOVER_LIFT_GRID_GROUP = "[@media(hover:hover)]:group-hover/card:-translate-y-1";
51
+ /** List/compact row variants — half lift since rows are denser. */
52
+ declare const HOVER_LIFT_LIST = "[@media(hover:hover)]:hover:-translate-y-0.5";
53
+ /** Image inside a card — scales subtly on group hover. */
54
+ declare const HOVER_IMAGE_SCALE = "group-hover:scale-[1.05]";
55
+ /** Drop-in className for the `<img>` element inside card image wrappers. */
56
+ declare const HOVER_IMAGE_MOTION = "transition-transform duration-[320ms] ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none will-change-transform group-hover:scale-[1.05]";
57
+ /** Warm image-wrapper shadow that deepens on group hover. */
58
+ declare const HOVER_IMAGE_SHADOW = "shadow-[var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-card-image-hover)]";
59
+ /**
60
+ * Same lift, for covers that ALSO carry an inset "well" edge on a generated
61
+ * illustration rather than a photo. `box-shadow` is a single property, so the
62
+ * inset can't be a second `shadow-*` class stacked on `HOVER_IMAGE_SHADOW`; it
63
+ * has to be part of the same value, which is why both states are spelled out.
64
+ */
65
+ declare const HOVER_IMAGE_SHADOW_INSET = "shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image-hover)]";
66
+ /**
67
+ * The whole cover-wrapper recipe: shadow transition + the warm lift. Pair with
68
+ * `HOVER_IMAGE_MOTION` on the image inside (covers that render a generated
69
+ * illustration have no image to scale and use this alone).
70
+ */
71
+ declare const HOVER_IMAGE_WRAPPER = "transition-shadow duration-[320ms] ease-[cubic-bezier(0.4,0,0.2,1)] shadow-[var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-card-image-hover)]";
72
+ /** `HOVER_IMAGE_WRAPPER` for the inset-well covers. */
73
+ declare const HOVER_IMAGE_WRAPPER_INSET = "transition-shadow duration-[320ms] ease-[cubic-bezier(0.4,0,0.2,1)] shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image-hover)]";
74
+
75
+ export { HOVER_DURATION, HOVER_EASE, HOVER_IMAGE_MOTION, HOVER_IMAGE_SCALE, HOVER_IMAGE_SHADOW, HOVER_IMAGE_SHADOW_INSET, HOVER_IMAGE_WRAPPER, HOVER_IMAGE_WRAPPER_INSET, HOVER_LIFT_GRID, HOVER_LIFT_GRID_GROUP, HOVER_LIFT_LIST, HOVER_MOTION, HOVER_SHADOW_MOTION };
@@ -0,0 +1,18 @@
1
+ import '../chunk-3GWWZKX7.js';
2
+
3
+ // src/lib/card-hover.ts
4
+ var HOVER_DURATION = "duration-[320ms]";
5
+ var HOVER_EASE = "ease-[cubic-bezier(0.4,0,0.2,1)]";
6
+ var HOVER_MOTION = `transition-transform ${HOVER_DURATION} ${HOVER_EASE} motion-reduce:transition-none will-change-transform`;
7
+ var HOVER_SHADOW_MOTION = `transition-shadow ${HOVER_DURATION} ${HOVER_EASE}`;
8
+ var HOVER_LIFT_GRID = "[@media(hover:hover)]:hover:-translate-y-1";
9
+ var HOVER_LIFT_GRID_GROUP = "[@media(hover:hover)]:group-hover/card:-translate-y-1";
10
+ var HOVER_LIFT_LIST = "[@media(hover:hover)]:hover:-translate-y-0.5";
11
+ var HOVER_IMAGE_SCALE = "group-hover:scale-[1.05]";
12
+ var HOVER_IMAGE_MOTION = `${HOVER_MOTION} ${HOVER_IMAGE_SCALE}`;
13
+ var HOVER_IMAGE_SHADOW = "shadow-[var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-card-image-hover)]";
14
+ var HOVER_IMAGE_SHADOW_INSET = "shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image-hover)]";
15
+ var HOVER_IMAGE_WRAPPER = `${HOVER_SHADOW_MOTION} ${HOVER_IMAGE_SHADOW}`;
16
+ var HOVER_IMAGE_WRAPPER_INSET = `${HOVER_SHADOW_MOTION} ${HOVER_IMAGE_SHADOW_INSET}`;
17
+
18
+ export { HOVER_DURATION, HOVER_EASE, HOVER_IMAGE_MOTION, HOVER_IMAGE_SCALE, HOVER_IMAGE_SHADOW, HOVER_IMAGE_SHADOW_INSET, HOVER_IMAGE_WRAPPER, HOVER_IMAGE_WRAPPER_INSET, HOVER_LIFT_GRID, HOVER_LIFT_GRID_GROUP, HOVER_LIFT_LIST, HOVER_MOTION, HOVER_SHADOW_MOTION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.7.1",
3
+ "version": "0.8.2",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -29,7 +29,7 @@ const AccordionTrigger = React.forwardRef<
29
29
  ref={ref}
30
30
  data-slot="accordion-trigger"
31
31
  className={cn(
32
- "flex flex-1 cursor-pointer items-center justify-between py-4 text-sm font-medium text-foreground transition-colors hover:text-foreground/80",
32
+ "flex flex-1 cursor-pointer items-center justify-between py-4 text-sm font-semibold text-foreground transition-colors hover:text-foreground/80",
33
33
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm",
34
34
  "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
35
35
  "[&[data-state=open]>svg]:rotate-180",
@@ -1,5 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
2
+ import { Avatar, AvatarFallback, AvatarGroup, AvatarImage, type AvatarSize } from "./avatar";
3
+
4
+ const sizes: AvatarSize[] = ["xs", "sm", "default", "lg", "xl"];
5
+
6
+ const people = ["AB", "CD", "EF", "GH", "IJ", "KL", "MN"];
3
7
 
4
8
  const meta = {
5
9
  title: "Components/Avatar",
@@ -30,37 +34,66 @@ export const Fallback: Story = {
30
34
  export const Sizes: Story = {
31
35
  render: () => (
32
36
  <div className="flex items-center gap-4">
33
- <Avatar className="h-8 w-8">
34
- <AvatarFallback className="text-xs">S</AvatarFallback>
35
- </Avatar>
36
- <Avatar>
37
- <AvatarFallback>MD</AvatarFallback>
38
- </Avatar>
39
- <Avatar className="h-14 w-14">
40
- <AvatarFallback className="text-lg">LG</AvatarFallback>
41
- </Avatar>
42
- <Avatar className="h-20 w-20">
43
- <AvatarFallback className="text-xl">XL</AvatarFallback>
44
- </Avatar>
37
+ {sizes.map((size) => (
38
+ <Avatar key={size} size={size}>
39
+ <AvatarFallback>{size.slice(0, 2).toUpperCase()}</AvatarFallback>
40
+ </Avatar>
41
+ ))}
45
42
  </div>
46
43
  ),
47
44
  };
48
45
 
49
46
  export const Group: Story = {
50
47
  render: () => (
51
- <div className="flex -space-x-3">
52
- <Avatar className="border-2 border-background">
53
- <AvatarFallback>A</AvatarFallback>
54
- </Avatar>
55
- <Avatar className="border-2 border-background">
56
- <AvatarFallback>B</AvatarFallback>
57
- </Avatar>
58
- <Avatar className="border-2 border-background">
59
- <AvatarFallback>C</AvatarFallback>
60
- </Avatar>
61
- <Avatar className="border-2 border-background">
62
- <AvatarFallback className="text-xs">+3</AvatarFallback>
63
- </Avatar>
48
+ <AvatarGroup max={4} spacing="tight">
49
+ {people.map((initials) => (
50
+ <Avatar key={initials}>
51
+ <AvatarFallback>{initials}</AvatarFallback>
52
+ </Avatar>
53
+ ))}
54
+ </AvatarGroup>
55
+ ),
56
+ };
57
+
58
+ /**
59
+ * `AvatarGroup` takes the same `size` union as `Avatar`. It sizes the "+N"
60
+ * overflow chip and is inherited by child avatars, so the row stays one
61
+ * consistent height without per-call-site class overrides.
62
+ */
63
+ export const GroupSizes: Story = {
64
+ render: () => (
65
+ <div className="flex flex-col gap-6">
66
+ {sizes.map((size) => (
67
+ <div key={size} className="flex items-center gap-4">
68
+ <span className="w-16 text-xs text-muted-foreground">{size}</span>
69
+ <AvatarGroup max={3} spacing="tight" size={size}>
70
+ {people.map((initials) => (
71
+ <Avatar key={initials}>
72
+ <AvatarFallback>{initials}</AvatarFallback>
73
+ </Avatar>
74
+ ))}
75
+ </AvatarGroup>
76
+ </div>
77
+ ))}
64
78
  </div>
65
79
  ),
66
80
  };
81
+
82
+ /**
83
+ * An individual `Avatar` can still opt out of the group size — useful for
84
+ * highlighting "you" inside a roster.
85
+ */
86
+ export const GroupSizeOverride: Story = {
87
+ render: () => (
88
+ <AvatarGroup max={4} spacing="tight" size="sm">
89
+ <Avatar size="lg">
90
+ <AvatarFallback>ME</AvatarFallback>
91
+ </Avatar>
92
+ {people.map((initials) => (
93
+ <Avatar key={initials}>
94
+ <AvatarFallback>{initials}</AvatarFallback>
95
+ </Avatar>
96
+ ))}
97
+ </AvatarGroup>
98
+ ),
99
+ };
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from "./avatar";
4
+
5
+ // Returned as an array, not a fragment: AvatarGroup counts its direct children,
6
+ // and a fragment would collapse the whole set into one child.
7
+ const members = (count: number) =>
8
+ Array.from({ length: count }, (_, i) => (
9
+ <Avatar key={i}>
10
+ <AvatarFallback>{String.fromCharCode(65 + i)}</AvatarFallback>
11
+ </Avatar>
12
+ ));
13
+
14
+ const overflowChip = () => document.querySelector('[data-slot="avatar-group-overflow"]');
15
+ const avatars = () => Array.from(document.querySelectorAll('[data-slot="avatar"]'));
16
+
17
+ describe("Avatar", () => {
18
+ it("applies the default size scale when no size is given", () => {
19
+ render(
20
+ <Avatar>
21
+ <AvatarFallback>JD</AvatarFallback>
22
+ </Avatar>
23
+ );
24
+ expect(avatars()[0]).toHaveClass("size-10", "text-sm");
25
+ });
26
+
27
+ it("applies an explicit size variant", () => {
28
+ render(
29
+ <Avatar size="xs">
30
+ <AvatarFallback>JD</AvatarFallback>
31
+ </Avatar>
32
+ );
33
+ expect(avatars()[0]).toHaveClass("size-6", "text-[10px]");
34
+ });
35
+
36
+ it("scales AvatarBadge off the avatar size via context", () => {
37
+ render(
38
+ <Avatar size="xl">
39
+ <AvatarFallback>JD</AvatarFallback>
40
+ <AvatarBadge />
41
+ </Avatar>
42
+ );
43
+ expect(document.querySelector('[data-slot="avatar-badge"]')).toHaveClass("size-4");
44
+ });
45
+ });
46
+
47
+ describe("AvatarGroup", () => {
48
+ it("renders no overflow chip when the item count fits within max", () => {
49
+ render(
50
+ <AvatarGroup max={3}>
51
+ {members(3)}
52
+ </AvatarGroup>
53
+ );
54
+ expect(overflowChip()).toBeNull();
55
+ });
56
+
57
+ it("renders a +N overflow chip for the hidden remainder", () => {
58
+ render(
59
+ <AvatarGroup max={2}>
60
+ {members(5)}
61
+ </AvatarGroup>
62
+ );
63
+ expect(screen.getByText("+3")).toBeInTheDocument();
64
+ expect(avatars()).toHaveLength(2);
65
+ });
66
+
67
+ it("keeps the pre-existing chip appearance when no size is passed", () => {
68
+ render(
69
+ <AvatarGroup max={1}>
70
+ {members(3)}
71
+ </AvatarGroup>
72
+ );
73
+ expect(overflowChip()).toHaveClass("size-10", "text-xs");
74
+ });
75
+
76
+ it.each([
77
+ ["xs", "size-6", "text-[9px]"],
78
+ ["sm", "size-8", "text-[10px]"],
79
+ ["default", "size-10", "text-xs"],
80
+ ["lg", "size-12", "text-sm"],
81
+ ["xl", "size-16", "text-base"],
82
+ ] as const)("sizes the overflow chip to match a %s group", (size, sizeClass, textClass) => {
83
+ render(
84
+ <AvatarGroup max={1} size={size}>
85
+ {members(4)}
86
+ </AvatarGroup>
87
+ );
88
+ expect(overflowChip()).toHaveClass(sizeClass, textClass);
89
+ });
90
+
91
+ it("passes the group size down to child avatars", () => {
92
+ render(
93
+ <AvatarGroup max={2} size="sm">
94
+ {members(4)}
95
+ </AvatarGroup>
96
+ );
97
+ for (const avatar of avatars()) {
98
+ expect(avatar).toHaveClass("size-8");
99
+ }
100
+ });
101
+
102
+ it("lets an individual avatar override the group size", () => {
103
+ render(
104
+ <AvatarGroup size="sm">
105
+ <Avatar>
106
+ <AvatarFallback>A</AvatarFallback>
107
+ </Avatar>
108
+ <Avatar size="lg">
109
+ <AvatarFallback>B</AvatarFallback>
110
+ </Avatar>
111
+ </AvatarGroup>
112
+ );
113
+ const [inherited, overridden] = avatars();
114
+ expect(inherited).toHaveClass("size-8");
115
+ expect(overridden).toHaveClass("size-12");
116
+ });
117
+
118
+ it("scales avatar and chip together so the group reads as one row", () => {
119
+ render(
120
+ <AvatarGroup max={2} size="xs">
121
+ {members(9)}
122
+ </AvatarGroup>
123
+ );
124
+ for (const avatar of avatars()) {
125
+ expect(avatar).toHaveClass("size-6");
126
+ }
127
+ expect(overflowChip()).toHaveClass("size-6");
128
+ });
129
+ });
@@ -23,22 +23,32 @@ const avatarVariants = cva(
23
23
  }
24
24
  );
25
25
 
26
- const AvatarContext = React.createContext<VariantProps<typeof avatarVariants>["size"]>("default");
26
+ type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>;
27
+
28
+ const AvatarContext = React.createContext<AvatarSize>("default");
27
29
 
28
30
  const Avatar = React.forwardRef<
29
31
  React.ElementRef<typeof AvatarPrimitive.Root>,
30
32
  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &
31
33
  VariantProps<typeof avatarVariants>
32
- >(({ className, size = "default", ...props }, ref) => (
33
- <AvatarContext.Provider value={size}>
34
- <AvatarPrimitive.Root
35
- ref={ref}
36
- data-slot="avatar"
37
- className={cn(avatarVariants({ size }), className)}
38
- {...props}
39
- />
40
- </AvatarContext.Provider>
41
- ));
34
+ >(({ className, size, ...props }, ref) => {
35
+ // An explicit `size` always wins; otherwise inherit from the nearest
36
+ // AvatarGroup (context defaults to "default", so a standalone Avatar with no
37
+ // size behaves exactly as before).
38
+ const inheritedSize = React.useContext(AvatarContext);
39
+ const resolvedSize = size ?? inheritedSize;
40
+
41
+ return (
42
+ <AvatarContext.Provider value={resolvedSize}>
43
+ <AvatarPrimitive.Root
44
+ ref={ref}
45
+ data-slot="avatar"
46
+ className={cn(avatarVariants({ size: resolvedSize }), className)}
47
+ {...props}
48
+ />
49
+ </AvatarContext.Provider>
50
+ );
51
+ });
42
52
  Avatar.displayName = AvatarPrimitive.Root.displayName;
43
53
 
44
54
  const AvatarImage = React.forwardRef<
@@ -62,7 +72,7 @@ const AvatarFallback = React.forwardRef<
62
72
  ref={ref}
63
73
  data-slot="avatar-fallback"
64
74
  className={cn(
65
- "flex size-full items-center justify-center rounded-full bg-muted font-medium text-muted-foreground",
75
+ "flex size-full items-center justify-center rounded-full bg-muted font-semibold text-muted-foreground",
66
76
  className
67
77
  )}
68
78
  {...props}
@@ -85,14 +95,14 @@ const AvatarBadge = React.forwardRef<
85
95
  tone?: "success" | "warning" | "destructive" | "muted";
86
96
  }
87
97
  >(({ className, placement = "bottom-right", tone = "success", ...props }, ref) => {
88
- const size = React.useContext(AvatarContext) ?? "default";
98
+ const size = React.useContext(AvatarContext);
89
99
  return (
90
100
  <span
91
101
  ref={ref}
92
102
  data-slot="avatar-badge"
93
103
  className={cn(
94
104
  "absolute block rounded-full ring-background",
95
- badgeOffset[size as keyof typeof badgeOffset],
105
+ badgeOffset[size],
96
106
  placement === "top-right" ? "right-0 top-0" : "bottom-0 right-0",
97
107
  tone === "success" && "bg-success",
98
108
  tone === "warning" && "bg-warning",
@@ -106,37 +116,83 @@ const AvatarBadge = React.forwardRef<
106
116
  });
107
117
  AvatarBadge.displayName = "AvatarBadge";
108
118
 
119
+ // The "+N" chip tracks the group's avatar size. Its label sits one step below
120
+ // the matching Avatar text rung — a two-glyph "+12" needs more room inside the
121
+ // same circle than a one-glyph initial. `default` reproduces the pre-0.8
122
+ // hardcoded appearance (size-10 text-xs) exactly.
123
+ const avatarGroupOverflowVariants = cva(
124
+ "relative inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-muted-foreground ring-2 ring-background",
125
+ {
126
+ variants: {
127
+ size: {
128
+ xs: "size-6 text-[9px]",
129
+ sm: "size-8 text-[10px]",
130
+ default: "size-10 text-xs",
131
+ lg: "size-12 text-sm",
132
+ xl: "size-16 text-base",
133
+ },
134
+ },
135
+ defaultVariants: {
136
+ size: "default",
137
+ },
138
+ }
139
+ );
140
+
109
141
  const AvatarGroup = React.forwardRef<
110
142
  HTMLDivElement,
111
- React.HTMLAttributes<HTMLDivElement> & { max?: number; spacing?: "tight" | "default" | "loose" }
112
- >(({ className, children, max, spacing = "default", ...props }, ref) => {
143
+ React.HTMLAttributes<HTMLDivElement> & {
144
+ max?: number;
145
+ spacing?: "tight" | "default" | "loose";
146
+ /**
147
+ * Size for the whole group: sets the "+N" overflow chip and is inherited by
148
+ * child `Avatar`s that don't set their own `size`. Defaults to "default".
149
+ */
150
+ size?: AvatarSize;
151
+ }
152
+ >(({ className, children, max, spacing = "default", size, ...props }, ref) => {
153
+ const inheritedSize = React.useContext(AvatarContext);
154
+ const resolvedSize = size ?? inheritedSize;
113
155
  const items = React.Children.toArray(children);
114
156
  const visible = max ? items.slice(0, max) : items;
115
157
  const overflow = max ? items.length - max : 0;
116
158
 
117
159
  return (
118
- <div
119
- ref={ref}
120
- data-slot="avatar-group"
121
- className={cn(
122
- "flex items-center",
123
- spacing === "tight" && "-space-x-3",
124
- spacing === "default" && "-space-x-2",
125
- spacing === "loose" && "-space-x-1",
126
- "[&>*]:ring-2 [&>*]:ring-background",
127
- className
128
- )}
129
- {...props}
130
- >
131
- {visible}
132
- {overflow > 0 && (
133
- <span className="relative inline-flex size-10 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground ring-2 ring-background">
134
- +{overflow}
135
- </span>
136
- )}
137
- </div>
160
+ <AvatarContext.Provider value={resolvedSize}>
161
+ <div
162
+ ref={ref}
163
+ data-slot="avatar-group"
164
+ className={cn(
165
+ "flex items-center",
166
+ spacing === "tight" && "-space-x-3",
167
+ spacing === "default" && "-space-x-2",
168
+ spacing === "loose" && "-space-x-1",
169
+ "[&>*]:ring-2 [&>*]:ring-background",
170
+ className
171
+ )}
172
+ {...props}
173
+ >
174
+ {visible}
175
+ {overflow > 0 && (
176
+ <span
177
+ data-slot="avatar-group-overflow"
178
+ className={avatarGroupOverflowVariants({ size: resolvedSize })}
179
+ >
180
+ +{overflow}
181
+ </span>
182
+ )}
183
+ </div>
184
+ </AvatarContext.Provider>
138
185
  );
139
186
  });
140
187
  AvatarGroup.displayName = "AvatarGroup";
141
188
 
142
- export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, avatarVariants };
189
+ export {
190
+ Avatar,
191
+ AvatarImage,
192
+ AvatarFallback,
193
+ AvatarBadge,
194
+ AvatarGroup,
195
+ avatarVariants,
196
+ avatarGroupOverflowVariants,
197
+ };
198
+ export type { AvatarSize };
@@ -37,10 +37,22 @@ const badgeVariants = cva(
37
37
  );
38
38
 
39
39
  export interface BadgeProps
40
- extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
40
+ extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
41
+ /**
42
+ * Element to render. Defaults to `div`.
43
+ *
44
+ * Use `"span"` when the badge sits inside a `<button>` or a `<p>` — their
45
+ * content model is phrasing content, so a `div` in there is invalid HTML.
46
+ * That is the common case for a badge sitting inline with a title on a
47
+ * clickable row.
48
+ */
49
+ as?: "div" | "span";
50
+ }
41
51
 
42
- function Badge({ className, variant, live, ...props }: BadgeProps) {
43
- return <div data-slot="badge" className={cn(badgeVariants({ variant, live }), className)} {...props} />;
52
+ function Badge({ className, variant, live, as: Tag = "div", ...props }: BadgeProps) {
53
+ return (
54
+ <Tag data-slot="badge" className={cn(badgeVariants({ variant, live }), className)} {...props} />
55
+ );
44
56
  }
45
57
 
46
58
  export { Badge, badgeVariants };
@@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
59
59
  role="link"
60
60
  aria-disabled="true"
61
61
  aria-current="page"
62
- className={cn("font-medium text-foreground", className)}
62
+ className={cn("font-semibold text-foreground", className)}
63
63
  {...props}
64
64
  />
65
65
  );
@@ -6,7 +6,7 @@ import { cn } from "../lib/utils";
6
6
  import { useHaptic } from "../hooks/use-haptic";
7
7
 
8
8
  const buttonVariants = cva(
9
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer [@media(pointer:coarse)]:active:scale-95",
9
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer [@media(pointer:coarse)]:active:scale-95",
10
10
  {
11
11
  variants: {
12
12
  variant: {
@@ -21,6 +21,21 @@ const buttonVariants = cva(
21
21
  ghost: "hover:bg-accent hover:text-accent-foreground",
22
22
  link: "text-primary underline-offset-4 hover:underline",
23
23
  glass: "glass glass-hover text-foreground",
24
+ /**
25
+ * Chrome sitting ON a photo — share, close, favourite over a hero or
26
+ * card image. `glass` is the wrong tool there: it resolves
27
+ * `text-foreground`, which is near-black in light mode and disappears
28
+ * into a dark photo.
29
+ *
30
+ * Deliberately NOT theme-aware, and so deliberately different from
31
+ * `Badge`'s `overlay` (`bg-foreground/70 text-background`, which
32
+ * inverts). A photo is equally a photo in both themes — this chrome has
33
+ * to contrast with the image beneath it, not with the app canvas, so it
34
+ * stays dark-on-light-glyph either way. Reconcile Badge to this, not the
35
+ * reverse, if the two are ever unified.
36
+ */
37
+ overlay:
38
+ "bg-black/40 text-white backdrop-blur-sm hover:bg-black/60 focus-visible:ring-white/70",
24
39
  brown:
25
40
  "bg-brown text-brown-foreground shadow-sm hover:bg-brown/85 hover:shadow",
26
41
  antagonist:
@@ -58,7 +58,7 @@ function Calendar({
58
58
  defaultClassNames.month_caption
59
59
  ),
60
60
  dropdowns: cn(
61
- "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
61
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-semibold",
62
62
  defaultClassNames.dropdowns
63
63
  ),
64
64
  dropdown_root: cn(
@@ -67,7 +67,7 @@ function Calendar({
67
67
  ),
68
68
  dropdown: cn("absolute inset-0 bg-popover opacity-0", defaultClassNames.dropdown),
69
69
  caption_label: cn(
70
- "font-medium select-none",
70
+ "font-semibold select-none",
71
71
  captionLayout === "label"
72
72
  ? "text-sm"
73
73
  : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",