@bearmenu/ui 0.6.1 → 0.7.1

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 (72) hide show
  1. package/dist/chunk-A5ONAJCE.js +102 -0
  2. package/dist/chunk-AV777R4G.js +43 -0
  3. package/dist/{chunk-WKMLZDUT.js → chunk-FRPPIXMY.js} +1 -1
  4. package/dist/{chunk-PSR7N36A.js → chunk-HCM6LKM5.js} +1 -1
  5. package/dist/chunk-LEAYBICI.js +84 -0
  6. package/dist/{chunk-YJ6OBKZJ.js → chunk-TL4YU2O5.js} +10 -3
  7. package/dist/chunk-UX6SWAKH.js +320 -0
  8. package/dist/{chunk-7PPCH42H.js → chunk-VQNDZVI7.js} +1 -1
  9. package/dist/chunk-WSRWFA6K.js +26 -0
  10. package/dist/chunk-YLNYXPIH.js +61 -0
  11. package/dist/components/accordion.js +1 -1
  12. package/dist/components/badge.d.ts +2 -1
  13. package/dist/components/badge.js +1 -1
  14. package/dist/components/card.js +127 -4
  15. package/dist/components/combobox.js +2 -2
  16. package/dist/components/command.js +2 -2
  17. package/dist/components/currency-input.js +1 -1
  18. package/dist/components/date-input.js +1 -1
  19. package/dist/components/dialog.js +1 -1
  20. package/dist/components/filter-category-row.d.ts +13 -0
  21. package/dist/components/filter-category-row.js +44 -0
  22. package/dist/components/input-group.d.ts +2 -0
  23. package/dist/components/input-group.js +10 -5
  24. package/dist/components/input.d.ts +1 -0
  25. package/dist/components/input.js +1 -1
  26. package/dist/components/multi-select-combobox.js +3 -3
  27. package/dist/components/phone-frame.js +1 -1
  28. package/dist/components/place-about-tab.d.ts +2 -2
  29. package/dist/components/place-about-tab.js +4 -3
  30. package/dist/components/place-details-mobile.js +5 -3
  31. package/dist/components/place-schedule-week.d.ts +19 -0
  32. package/dist/components/place-schedule-week.js +4 -0
  33. package/dist/components/place-types.d.ts +18 -9
  34. package/dist/components/scroll-fade.js +7 -2
  35. package/dist/components/searchable-select.js +2 -2
  36. package/dist/components/separator.js +3 -26
  37. package/dist/components/sheet.js +1 -1
  38. package/dist/components/sliding-panels.d.ts +1 -0
  39. package/dist/components/sliding-panels.js +22 -6
  40. package/dist/components/sort-option-list.d.ts +16 -0
  41. package/dist/components/sort-option-list.js +46 -0
  42. package/dist/components/tabs.js +74 -16
  43. package/dist/components/tag.js +3 -61
  44. package/dist/components/toast.js +1 -1
  45. package/dist/components/toaster.js +1 -1
  46. package/dist/lib/motion.d.ts +83 -0
  47. package/dist/lib/motion.js +2 -0
  48. package/package.json +23 -113
  49. package/src/components/accordion.tsx +1 -1
  50. package/src/components/badge.tsx +9 -2
  51. package/src/components/card.tsx +22 -18
  52. package/src/components/dialog.tsx +1 -1
  53. package/src/components/filter-category-row.tsx +52 -0
  54. package/src/components/input-group.tsx +9 -4
  55. package/src/components/input.tsx +8 -3
  56. package/src/components/phone-frame.tsx +1 -1
  57. package/src/components/place-about-tab.stories.tsx +24 -13
  58. package/src/components/place-about-tab.test.tsx +92 -38
  59. package/src/components/place-about-tab.tsx +310 -344
  60. package/src/components/place-schedule-week.tsx +128 -0
  61. package/src/components/place-types.ts +27 -8
  62. package/src/components/scroll-fade.tsx +9 -4
  63. package/src/components/sheet.tsx +1 -1
  64. package/src/components/sliding-panels.tsx +33 -6
  65. package/src/components/sort-option-list.tsx +57 -0
  66. package/src/components/tabs.tsx +76 -11
  67. package/src/components/toast.tsx +1 -1
  68. package/src/globals.css +77 -19
  69. package/src/lib/motion.ts +137 -0
  70. package/dist/chunk-BAZFVSSG.js +0 -279
  71. package/dist/chunk-RIDXFY6M.js +0 -38
  72. package/dist/chunk-XK2ZDQVE.js +0 -122
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Text } from "./text";
6
+ import type {
7
+ PlaceSchedule,
8
+ PlaceScheduleWeekLabels,
9
+ WeekdayKey,
10
+ } from "./place-types";
11
+
12
+ const WEEKDAY_KEYS: readonly WeekdayKey[] = [
13
+ "monday",
14
+ "tuesday",
15
+ "wednesday",
16
+ "thursday",
17
+ "friday",
18
+ "saturday",
19
+ "sunday",
20
+ ] as const;
21
+
22
+ function formatHour(h: number): string {
23
+ return `${h.toString().padStart(2, "0")}:00`;
24
+ }
25
+
26
+ /** JS Date.getDay(): Sunday=0 ... Saturday=6 → WEEKDAY_KEYS index where Monday=0 */
27
+ function todayWeekdayIndex(): number {
28
+ const d = new Date().getDay();
29
+ return (d + 6) % 7;
30
+ }
31
+
32
+ export interface PlaceScheduleWeekProps {
33
+ schedule?: PlaceSchedule;
34
+ labels: PlaceScheduleWeekLabels;
35
+ /**
36
+ * When true, scrolls the today row into view on mount. Use inside a drawer/dialog
37
+ * where the schedule is the only content — auto-scrolling avoids weekend rows
38
+ * being clipped at the bottom of small viewports. Default false: do not auto-scroll
39
+ * when the schedule sits inline on a longer page, where the behaviour disorients
40
+ * the reader.
41
+ */
42
+ autoScrollToToday?: boolean;
43
+ className?: string;
44
+ }
45
+
46
+ export function PlaceScheduleWeek({
47
+ schedule,
48
+ labels,
49
+ autoScrollToToday = false,
50
+ className,
51
+ }: PlaceScheduleWeekProps) {
52
+ const todayIdx = todayWeekdayIndex();
53
+ const todayRowRef = useRef<HTMLDivElement | null>(null);
54
+
55
+ useEffect(() => {
56
+ if (autoScrollToToday) {
57
+ todayRowRef.current?.scrollIntoView({ block: "nearest" });
58
+ }
59
+ }, [autoScrollToToday]);
60
+
61
+ if (!schedule) {
62
+ return (
63
+ <Text as="p" variant="body-sm" color="muted">
64
+ {labels.noHoursAvailable}
65
+ </Text>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div className={cn(className)}>
71
+ {WEEKDAY_KEYS.map((dayKey, idx) => {
72
+ const slots = schedule[dayKey] ?? [];
73
+ const isToday = idx === todayIdx;
74
+ const isClosed = slots.length === 0;
75
+ return (
76
+ <div
77
+ key={dayKey}
78
+ ref={isToday ? todayRowRef : null}
79
+ className={cn(
80
+ "relative flex items-start justify-between gap-3 rounded-lg px-3 py-1.5 overflow-hidden",
81
+ isToday && "bg-primary/8"
82
+ )}
83
+ >
84
+ {isToday && (
85
+ <span
86
+ aria-hidden
87
+ className="absolute left-0 top-0 bottom-0 w-1 bg-primary"
88
+ />
89
+ )}
90
+ <Text
91
+ as="span"
92
+ variant="body-sm"
93
+ weight={isToday ? "semibold" : "medium"}
94
+ className={isToday ? "text-foreground" : "text-muted-foreground"}
95
+ >
96
+ {labels.days[dayKey]}
97
+ </Text>
98
+ <div className="flex flex-col items-end gap-0.5">
99
+ {isClosed ? (
100
+ <Text
101
+ as="span"
102
+ variant="body-sm"
103
+ className="line-through text-muted-foreground/50"
104
+ >
105
+ {labels.closed}
106
+ </Text>
107
+ ) : (
108
+ slots.map((slot, i) => (
109
+ <Text
110
+ key={i}
111
+ as="span"
112
+ variant="body-sm"
113
+ className={cn(
114
+ "tabular-nums",
115
+ isToday ? "text-foreground" : "text-muted-foreground"
116
+ )}
117
+ >
118
+ {formatHour(slot.opens_at)} – {formatHour(slot.closes_at)}
119
+ </Text>
120
+ ))
121
+ )}
122
+ </div>
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+ }
@@ -15,6 +15,7 @@ export type PlacePreview = {
15
15
  review_count?: number
16
16
  hours?: PlaceHourBlock[]
17
17
  today_schedule?: PlaceHourBlock[]
18
+ schedule?: PlaceSchedule
18
19
  location?: {
19
20
  address?: string
20
21
  geolocation?: { lat: number | string; lon: number | string }
@@ -45,6 +46,17 @@ export type PlacePreview = {
45
46
 
46
47
  export type PlaceHourBlock = { opens_at: number; closes_at: number }
47
48
 
49
+ export type WeekdayKey =
50
+ | 'monday'
51
+ | 'tuesday'
52
+ | 'wednesday'
53
+ | 'thursday'
54
+ | 'friday'
55
+ | 'saturday'
56
+ | 'sunday'
57
+
58
+ export type PlaceSchedule = Record<WeekdayKey, PlaceHourBlock[]>
59
+
48
60
  export type PlacePriceRange = {
49
61
  price_range_low?: number
50
62
  price_range_high?: number
@@ -67,15 +79,14 @@ export type PlaceCardLabels = {
67
79
  * to the raw enum value (with underscores → spaces).
68
80
  */
69
81
  export type PlaceAboutTabLabels = {
70
- overview: string
82
+ /** Section heading for the editorial prose block (generative summary + review summary + description). */
71
83
  description: string
72
- whatPeopleSay: string
73
84
  contactAndLocation: string
74
- address: string
75
85
  instagram: string
76
86
  facebook: string
87
+ /** Section heading for the inline weekly schedule. */
77
88
  todayHours: string
78
- statusOpen: string
89
+ /** Used by the inline schedule's closed-day line-through. */
79
90
  statusClosed: string
80
91
  typicalBusyness: string
81
92
  cuisines: string
@@ -84,10 +95,10 @@ export type PlaceAboutTabLabels = {
84
95
  accessibility: string
85
96
  parking: string
86
97
  nearbyLandmarks: string
87
- neighborhood: string
88
- businessInformation: string
89
- type: string
90
- status: string
98
+ /** Weekday key → translated day name, consumed by the inline `<PlaceScheduleWeek>`. */
99
+ scheduleDays: Record<WeekdayKey, string>
100
+ /** Fallback shown by `<PlaceScheduleWeek>` when no schedule data is present. */
101
+ noHoursAvailable: string
91
102
  amenityLabels: Record<string, string>
92
103
  paymentLabels: Record<string, string>
93
104
  accessibilityLabels: Record<string, string>
@@ -99,6 +110,14 @@ export type PlaceAboutTabLabels = {
99
110
  /** Discriminator for `<PlaceAboutTab>`'s `onLinkClick` callback. */
100
111
  export type PlaceLinkKind = 'phone' | 'website' | 'instagram' | 'facebook' | 'address'
101
112
 
113
+ /** Labels passed to `<PlaceScheduleWeek>`. */
114
+ export type PlaceScheduleWeekLabels = {
115
+ days: Record<WeekdayKey, string>
116
+ closed: string
117
+ /** Shown when the `schedule` prop is missing or empty. */
118
+ noHoursAvailable: string
119
+ }
120
+
102
121
  /**
103
122
  * Labels passed to `<PlaceDetailsMobile>`. Extends `PlaceAboutTabLabels` with
104
123
  * the header/tab-strip strings unique to the mobile place-details surface.
@@ -99,9 +99,14 @@ const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
99
99
  aria-hidden
100
100
  className={cn(
101
101
  "pointer-events-none absolute z-10 transition-opacity duration-300",
102
+ // `from-30%` adds a solid plateau before the gradient starts
103
+ // fading — without it the gradient is a pure linear ramp and only
104
+ // the very top pixel reads as solid, leaving the cut edge visible.
105
+ // -4px inset pulls the fade past the container edge so the very
106
+ // edge of clipped content is covered by the solid plateau.
102
107
  orientation === "vertical"
103
- ? `inset-x-0 top-0 ${fadeSize} bg-gradient-to-b`
104
- : `inset-y-0 left-0 ${fadeSize} bg-gradient-to-r`,
108
+ ? `inset-x-0 -top-1 ${fadeSize} bg-gradient-to-b from-30%`
109
+ : `inset-y-0 -left-1 ${fadeSize} bg-gradient-to-r from-30%`,
105
110
  fromClass,
106
111
  atStart ? "opacity-0" : "opacity-100"
107
112
  )}
@@ -111,8 +116,8 @@ const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
111
116
  className={cn(
112
117
  "pointer-events-none absolute z-10 transition-opacity duration-300",
113
118
  orientation === "vertical"
114
- ? `inset-x-0 bottom-0 ${fadeSize} bg-gradient-to-t`
115
- : `inset-y-0 right-0 ${fadeSize} bg-gradient-to-l`,
119
+ ? `inset-x-0 -bottom-1 ${fadeSize} bg-gradient-to-t from-30%`
120
+ : `inset-y-0 -right-1 ${fadeSize} bg-gradient-to-l from-30%`,
116
121
  fromClass,
117
122
  atEnd ? "opacity-0" : "opacity-100"
118
123
  )}
@@ -30,7 +30,7 @@ const SheetOverlay = React.forwardRef<
30
30
  SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
31
31
 
32
32
  const sheetVariants = cva(
33
- "fixed z-[1000] gap-4 bg-background border-border p-6 shadow-2xl transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
33
+ "fixed z-[1000] gap-4 bg-background border-border p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-[var(--duration-deliberate)] data-[state=closed]:duration-[var(--duration-exit-standard)] data-[state=open]:ease-[var(--ease-enter)] data-[state=closed]:ease-[var(--ease-exit)]",
34
34
  {
35
35
  variants: {
36
36
  side: {
@@ -12,6 +12,11 @@ export interface Panel {
12
12
  showBackButton?: boolean;
13
13
  backButtonLabel?: string;
14
14
  onBack?: () => void;
15
+ // When set, the panel is treated as a child of `parentId`. Siblings (panels
16
+ // sharing the same parent) stay off-screen on the right when not active —
17
+ // they no longer slide through during non-adjacent transitions. The default
18
+ // back button navigates to `parentId` instead of the previous array index.
19
+ parentId?: string;
15
20
  }
16
21
 
17
22
  export interface SlidingPanelsProps {
@@ -32,6 +37,23 @@ export const SlidingPanels = ({
32
37
  const panelRefs = useRef<(HTMLDivElement | null)[]>([]);
33
38
  const [containerHeight, setContainerHeight] = useState<number | undefined>();
34
39
 
40
+ // Tree mode: if any panel declares parentId, position based on hierarchy
41
+ // (ancestors slide left, everything else stays right). Otherwise fall back
42
+ // to the legacy index-based stack.
43
+ const usesTreeModel = panels.some((p) => p.parentId);
44
+
45
+ const activeAncestors = React.useMemo(() => {
46
+ if (!usesTreeModel) return new Set<string>();
47
+ const ancestors = new Set<string>();
48
+ let cursor = panels.find((p) => p.id === currentPanel);
49
+ while (cursor?.parentId) {
50
+ ancestors.add(cursor.parentId);
51
+ const nextId: string = cursor.parentId;
52
+ cursor = panels.find((p) => p.id === nextId);
53
+ }
54
+ return ancestors;
55
+ }, [panels, currentPanel, usesTreeModel]);
56
+
35
57
  // Measure active panel height on panel change and whenever content resizes
36
58
  useLayoutEffect(() => {
37
59
  const activeEl = panelRefs.current[currentIndex];
@@ -46,13 +68,16 @@ export const SlidingPanels = ({
46
68
  }, [currentIndex]);
47
69
 
48
70
  const handleBack = () => {
49
- const previousPanel = panels[currentIndex - 1];
50
- if (!previousPanel) return;
51
71
  if (currentPanelData?.onBack) {
52
72
  currentPanelData.onBack();
53
- } else {
54
- onPanelChange(previousPanel.id);
73
+ return;
74
+ }
75
+ if (currentPanelData?.parentId) {
76
+ onPanelChange(currentPanelData.parentId);
77
+ return;
55
78
  }
79
+ const previousPanel = panels[currentIndex - 1];
80
+ if (previousPanel) onPanelChange(previousPanel.id);
56
81
  };
57
82
 
58
83
  return (
@@ -65,8 +90,10 @@ export const SlidingPanels = ({
65
90
  >
66
91
  {panels.map((panel, index) => {
67
92
  const isActive = panel.id === currentPanel;
68
- const isLeft = index < currentIndex;
69
- const isRight = index > currentIndex;
93
+ const isLeft = usesTreeModel
94
+ ? activeAncestors.has(panel.id)
95
+ : index < currentIndex;
96
+ const isRight = !isActive && !isLeft;
70
97
 
71
98
  return (
72
99
  <div
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { Check } from "lucide-react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ export type SortOptionListItem<T extends string> = {
7
+ value: T;
8
+ label: string;
9
+ };
10
+
11
+ interface SortOptionListProps<T extends string> {
12
+ options: SortOptionListItem<T>[];
13
+ value: T;
14
+ onChange: (value: T) => void;
15
+ ariaLabel: string;
16
+ className?: string;
17
+ }
18
+
19
+ export function SortOptionList<T extends string>({
20
+ options,
21
+ value,
22
+ onChange,
23
+ ariaLabel,
24
+ className,
25
+ }: SortOptionListProps<T>) {
26
+ return (
27
+ <div
28
+ role="radiogroup"
29
+ aria-label={ariaLabel}
30
+ className={cn("px-2 pb-6", className)}
31
+ >
32
+ {options.map((option) => {
33
+ const isActive = option.value === value;
34
+ return (
35
+ <button
36
+ key={option.value}
37
+ type="button"
38
+ role="radio"
39
+ aria-checked={isActive}
40
+ onClick={() => onChange(option.value)}
41
+ className={cn(
42
+ "flex w-full items-center justify-between rounded-lg px-3 py-3 text-left text-sm cursor-pointer",
43
+ "transition-colors duration-150 motion-reduce:transition-none",
44
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
45
+ isActive
46
+ ? "bg-accent/10 text-accent font-semibold"
47
+ : "text-foreground hover:bg-muted/50"
48
+ )}
49
+ >
50
+ <span>{option.label}</span>
51
+ {isActive && <Check className="h-4 w-4" aria-hidden />}
52
+ </button>
53
+ );
54
+ })}
55
+ </div>
56
+ );
57
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import * as TabsPrimitive from "@radix-ui/react-tabs";
5
+ import { motion } from "framer-motion";
6
+ import { archetype } from "../lib/motion";
5
7
  import { cn } from "../lib/utils";
6
8
 
7
9
  type TabsVariant = "pill" | "underline";
@@ -10,11 +12,15 @@ type TabsOrientation = "horizontal" | "vertical";
10
12
  type TabsContextValue = {
11
13
  variant: TabsVariant;
12
14
  orientation: TabsOrientation;
15
+ /** Unique id used by Framer Motion `layoutId` to morph the active indicator
16
+ * between triggers. Generated once per <TabsList>. */
17
+ indicatorId: string;
13
18
  };
14
19
 
15
20
  const TabsContext = React.createContext<TabsContextValue>({
16
21
  variant: "pill",
17
22
  orientation: "horizontal",
23
+ indicatorId: "tabs-indicator",
18
24
  });
19
25
 
20
26
  const Tabs = React.forwardRef<
@@ -45,9 +51,10 @@ const TabsList = React.forwardRef<
45
51
  >(({ className, variant = "pill", scrollable, children, ...props }, ref) => {
46
52
  const orientation: TabsOrientation = props["aria-orientation"] === "vertical" ? "vertical" : "horizontal";
47
53
  const allowScroll = scrollable ?? orientation === "horizontal";
54
+ const indicatorId = React.useId();
48
55
 
49
56
  return (
50
- <TabsContext.Provider value={{ variant, orientation }}>
57
+ <TabsContext.Provider value={{ variant, orientation, indicatorId }}>
51
58
  <TabsPrimitive.List
52
59
  ref={ref}
53
60
  data-slot="tabs-list"
@@ -75,27 +82,80 @@ TabsList.displayName = TabsPrimitive.List.displayName;
75
82
  const TabsTrigger = React.forwardRef<
76
83
  React.ElementRef<typeof TabsPrimitive.Trigger>,
77
84
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
78
- >(({ className, ...props }, ref) => {
79
- const { variant, orientation } = React.useContext(TabsContext);
85
+ >(({ className, children, ...props }, ref) => {
86
+ const { variant, orientation, indicatorId } = React.useContext(TabsContext);
87
+ // Inspect data-state at render to know whether to render the indicator.
88
+ // Radix sets data-state="active" | "inactive" on the trigger via its primitive.
89
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
90
+ const [isActive, setIsActive] = React.useState(false);
91
+
92
+ // Sync isActive from Radix's data-state. Tracking via mutation observer
93
+ // keeps the indicator in sync without needing controlled state from consumers.
94
+ React.useEffect(() => {
95
+ const node = triggerRef.current;
96
+ if (!node) return;
97
+ const sync = () => setIsActive(node.getAttribute("data-state") === "active");
98
+ sync();
99
+ const observer = new MutationObserver(sync);
100
+ observer.observe(node, { attributes: true, attributeFilter: ["data-state"] });
101
+ return () => observer.disconnect();
102
+ }, []);
103
+
104
+ const composedRef = React.useCallback(
105
+ (node: HTMLButtonElement | null) => {
106
+ triggerRef.current = node;
107
+ if (typeof ref === "function") ref(node);
108
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
109
+ },
110
+ [ref]
111
+ );
80
112
 
81
113
  return (
82
114
  <TabsPrimitive.Trigger
83
- ref={ref}
115
+ ref={composedRef}
84
116
  data-slot="tabs-trigger"
85
117
  className={cn(
86
- "inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
118
+ "relative inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
87
119
  variant === "pill" &&
88
- "rounded-xl px-4 py-1.5 text-muted-foreground hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
120
+ "rounded-xl px-4 py-1.5 text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
89
121
  variant === "underline" &&
90
122
  orientation === "horizontal" &&
91
- "relative h-11 rounded-none border-b-2 border-transparent px-4 text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground",
123
+ "h-11 rounded-none px-4 text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
92
124
  variant === "underline" &&
93
125
  orientation === "vertical" &&
94
- "relative rounded-none border-r-2 border-transparent px-4 py-2 -mr-px text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground justify-start",
126
+ "rounded-none px-4 py-2 -mr-px text-muted-foreground hover:text-foreground data-[state=active]:text-foreground justify-start",
95
127
  className
96
128
  )}
97
129
  {...props}
98
- />
130
+ >
131
+ {/* Active indicator: shared-layout element morphs between triggers */}
132
+ {isActive && (
133
+ <motion.span
134
+ aria-hidden
135
+ layoutId={indicatorId}
136
+ transition={archetype.indicatorMorph}
137
+ className={cn(
138
+ "absolute inset-0 -z-0 motion-reduce:hidden",
139
+ variant === "pill" && "rounded-xl bg-background shadow-sm",
140
+ variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
141
+ variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
142
+ )}
143
+ />
144
+ )}
145
+ {/* Static fallback for reduced-motion: keep the legacy active styling */}
146
+ {isActive && (
147
+ <span
148
+ aria-hidden
149
+ className={cn(
150
+ "absolute inset-0 -z-0 hidden motion-reduce:block",
151
+ variant === "pill" && "rounded-xl bg-background shadow-sm",
152
+ variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
153
+ variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
154
+ )}
155
+ />
156
+ )}
157
+ <span className="relative z-10 flex items-center gap-2">{children}</span>
158
+ </TabsPrimitive.Trigger>
99
159
  );
100
160
  });
101
161
  TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
@@ -103,16 +163,21 @@ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
103
163
  const TabsContent = React.forwardRef<
104
164
  React.ElementRef<typeof TabsPrimitive.Content>,
105
165
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
106
- >(({ className, ...props }, ref) => (
166
+ >(({ className, children, ...props }, ref) => (
107
167
  <TabsPrimitive.Content
108
168
  ref={ref}
109
169
  data-slot="tabs-content"
110
170
  className={cn(
111
171
  "flex-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md",
172
+ // v1.0 motion: content-swap on activation. CSS animation so this works
173
+ // even outside a Framer-Motion-wrapped layout. Disabled for reduced-motion.
174
+ "data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-[var(--duration-quick)] data-[state=active]:ease-[var(--ease-standard)] motion-reduce:data-[state=active]:animate-none",
112
175
  className
113
176
  )}
114
177
  {...props}
115
- />
178
+ >
179
+ {children}
180
+ </TabsPrimitive.Content>
116
181
  ));
117
182
  TabsContent.displayName = TabsPrimitive.Content.displayName;
118
183
 
@@ -24,7 +24,7 @@ const ToastViewport = React.forwardRef<
24
24
  ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
25
25
 
26
26
  const toastVariants = cva(
27
- "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl p-6 pr-8 shadow-2xl transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl p-6 pr-8 shadow-2xl transition-[transform,opacity] duration-[var(--duration-standard)] ease-[var(--ease-standard)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=open]:duration-[var(--duration-standard)] data-[state=closed]:duration-[var(--duration-exit-standard)] data-[state=open]:ease-[var(--ease-enter)] data-[state=closed]:ease-[var(--ease-exit)] data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
28
28
  {
29
29
  variants: {
30
30
  variant: {
package/src/globals.css CHANGED
@@ -89,18 +89,30 @@ h6 {
89
89
  --shadow-alpha-xl: 0.18;
90
90
  --shadow-alpha-2xl: 0.25;
91
91
 
92
- /* Motion */
93
- --duration-instant: 0ms;
94
- --duration-fast: 150ms;
95
- --duration-base: 200ms;
96
- --duration-slow: 300ms;
97
- --duration-slower: 500ms;
98
- --duration-drawer: 350ms;
99
- --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
100
- --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
101
- --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
102
- --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
103
- --ease-spring: cubic-bezier(0.5, 1.5, 0.5, 1);
92
+ /* ─── Motion System v1.0 ─────────────────────────────────────────────
93
+ Three layers of tokens. Feature code reads only `--duration-*` and
94
+ `--ease-*` semantic names; raw values live here.
95
+
96
+ JS twin: @bearmenu/ui/lib/motion (duration / ease / archetype). */
97
+
98
+ /* Durations */
99
+ --duration-none: 0ms; /* literal "no animation" */
100
+ --duration-instant: 120ms; /* tap feedback, icon swap */
101
+ --duration-quick: 180ms; /* chip toggle, tab indicator */
102
+ --duration-standard: 260ms; /* component entrance — workhorse */
103
+ --duration-deliberate: 360ms; /* drawer / sheet open */
104
+ --duration-narrative: 500ms; /* editorial hero (sparingly) */
105
+ --duration-exit-quick: 110ms; /* content exit inside a surface */
106
+ --duration-exit-standard: 210ms; /* surface / overlay exit */
107
+
108
+ /* Easings */
109
+ --ease-enter: cubic-bezier(0, 0, 0.2, 1);
110
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
111
+ --ease-exit: cubic-bezier(0.55, 0, 1, 0.45);
112
+ /* CSS equivalents for the JS spring presets — for keyframe / transition use */
113
+ --ease-spring-snappy: cubic-bezier(0.34, 1.56, 0.64, 1);
114
+ --ease-spring-smooth: cubic-bezier(0.22, 1, 0.36, 1);
115
+ --ease-spring-soft: cubic-bezier(0.16, 1, 0.3, 1);
104
116
 
105
117
  /* Z-index scale */
106
118
  --z-base: 0;
@@ -246,8 +258,8 @@ h6 {
246
258
  --shadow-2xl: 0 25px 50px -12px hsl(var(--shadow-color) / var(--shadow-alpha-2xl));
247
259
 
248
260
  /* Default transition tokens — consumed by Tailwind's `transition-*` utilities */
249
- --default-transition-duration: var(--duration-base);
250
- --default-transition-timing-function: var(--ease-out);
261
+ --default-transition-duration: var(--duration-standard);
262
+ --default-transition-timing-function: var(--ease-standard);
251
263
 
252
264
  --animate-highlight-ring: highlight-ring 5s ease-out forwards;
253
265
  }
@@ -296,8 +308,8 @@ h6 {
296
308
 
297
309
  .glass-hover {
298
310
  transition:
299
- background-color var(--duration-base) var(--ease-out),
300
- border-color var(--duration-base) var(--ease-out);
311
+ background-color var(--duration-standard) var(--ease-standard),
312
+ border-color var(--duration-standard) var(--ease-standard);
301
313
  }
302
314
 
303
315
  .glass-hover:hover {
@@ -407,11 +419,11 @@ h6 {
407
419
  }
408
420
 
409
421
  .animate-collapsible-down {
410
- animation: collapsible-down var(--duration-slow) var(--ease-out);
422
+ animation: collapsible-down var(--duration-deliberate) var(--ease-enter);
411
423
  }
412
424
 
413
425
  .animate-collapsible-up {
414
- animation: collapsible-up var(--duration-base) var(--ease-in);
426
+ animation: collapsible-up var(--duration-exit-standard) var(--ease-exit);
415
427
  }
416
428
 
417
429
  /* Shimmer — referenced by Skeleton (was undefined → animation was dead) */
@@ -421,13 +433,59 @@ h6 {
421
433
  }
422
434
  }
423
435
 
436
+ /* Live-pulse — referenced by Badge `live` variant, EventBanner live indicator,
437
+ and any "this is happening right now" surface. Subtle to avoid distraction. */
438
+ @keyframes live-pulse {
439
+ 0%, 100% {
440
+ transform: scale(1);
441
+ opacity: 1;
442
+ }
443
+ 50% {
444
+ transform: scale(1.08);
445
+ opacity: 0.7;
446
+ }
447
+ }
448
+
449
+ .motion-live-pulse {
450
+ animation: live-pulse 1.8s ease-in-out infinite;
451
+ transform-origin: center;
452
+ }
453
+
454
+ /* Image-enter — gallery / hero photographs. Slight scale-down + fade so images
455
+ read as "arriving" rather than popping. Standalone CSS so consumers don't
456
+ need framer-motion on every photo. */
457
+ @keyframes image-enter {
458
+ from {
459
+ opacity: 0;
460
+ transform: scale(1.04);
461
+ }
462
+ to {
463
+ opacity: 1;
464
+ transform: scale(1);
465
+ }
466
+ }
467
+
468
+ .motion-image-enter {
469
+ animation: image-enter var(--duration-standard) var(--ease-standard) both;
470
+ }
471
+
472
+ /* Reduced-motion: instant — no scale, no fade. */
473
+ @media (prefers-reduced-motion: reduce) {
474
+ .motion-image-enter {
475
+ animation: none;
476
+ }
477
+ .motion-live-pulse {
478
+ animation: none;
479
+ }
480
+ }
481
+
424
482
  /* Swiper pagination styling for experience cards */
425
483
  .swiper-pagination-bullet {
426
484
  background: color-mix(in oklch, var(--foreground) 30%, transparent) !important;
427
485
  opacity: 1 !important;
428
486
  width: 6px !important;
429
487
  height: 6px !important;
430
- transition: all var(--duration-slow) var(--ease-out) !important;
488
+ transition: width var(--duration-standard) var(--ease-standard), background var(--duration-standard) var(--ease-standard) !important;
431
489
  }
432
490
 
433
491
  .swiper-pagination-bullet-active {