@bearmenu/ui 0.8.7 → 0.8.9

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 (179) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-62ES6N32.js +27 -0
  3. package/dist/chunk-AHL45DD2.js +145 -0
  4. package/dist/chunk-AXV5O2IZ.js +58 -0
  5. package/dist/chunk-IYDH5IU6.js +66 -0
  6. package/dist/chunk-OPVKL4VS.js +18 -0
  7. package/dist/chunk-Q6QH52LJ.js +38 -0
  8. package/dist/chunk-TH6W2PWG.js +213 -0
  9. package/dist/chunk-THOVPZNN.js +37 -0
  10. package/dist/chunk-YS6BHKZO.js +92 -0
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/category-deck.js +10 -1
  14. package/dist/components/command.d.ts +7 -7
  15. package/dist/components/concept-compare.d.ts +73 -0
  16. package/dist/components/concept-compare.js +162 -0
  17. package/dist/components/coverflow.d.ts +11 -3
  18. package/dist/components/coverflow.js +34 -51
  19. package/dist/components/date-input.d.ts +1 -1
  20. package/dist/components/drift-rail.d.ts +24 -10
  21. package/dist/components/drift-rail.js +1 -1
  22. package/dist/components/event-card.d.ts +8 -0
  23. package/dist/components/event-card.js +64 -34
  24. package/dist/components/event-compact-row.d.ts +33 -6
  25. package/dist/components/event-compact-row.js +120 -39
  26. package/dist/components/event-day-header.js +3 -1
  27. package/dist/components/event-day-separator.d.ts +7 -1
  28. package/dist/components/event-day-separator.js +3 -2
  29. package/dist/components/event-detail-panel.d.ts +171 -25
  30. package/dist/components/event-detail-panel.js +471 -174
  31. package/dist/components/event-hub-hero.d.ts +25 -4
  32. package/dist/components/event-hub-hero.js +89 -19
  33. package/dist/components/event-list-row-wide.js +1 -1
  34. package/dist/components/event-list-row.d.ts +19 -6
  35. package/dist/components/event-list-row.js +79 -35
  36. package/dist/components/hour-band.d.ts +38 -40
  37. package/dist/components/hour-band.js +55 -27
  38. package/dist/components/hour-group.js +1 -1
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +20 -0
  41. package/dist/components/menu-family-card.d.ts +59 -0
  42. package/dist/components/menu-family-card.js +121 -0
  43. package/dist/components/menu-field.d.ts +57 -0
  44. package/dist/components/menu-field.js +3 -0
  45. package/dist/components/menu-hub-hero.d.ts +65 -0
  46. package/dist/components/menu-hub-hero.js +103 -0
  47. package/dist/components/menu-intent-tiles.d.ts +31 -0
  48. package/dist/components/menu-intent-tiles.js +50 -0
  49. package/dist/components/menu-item.d.ts +127 -0
  50. package/dist/components/menu-item.js +5 -0
  51. package/dist/components/menu-mocks.d.ts +55 -0
  52. package/dist/components/menu-mocks.js +379 -0
  53. package/dist/components/menu-types.d.ts +245 -0
  54. package/dist/components/menu-types.js +1 -0
  55. package/dist/components/motion-icon-set.js +15 -3
  56. package/dist/components/open-now-list.d.ts +40 -0
  57. package/dist/components/open-now-list.js +69 -0
  58. package/dist/components/place-types.d.ts +112 -1
  59. package/dist/components/poster-tape.js +1 -1
  60. package/dist/components/poster-wall.d.ts +3 -3
  61. package/dist/components/poster-wall.js +73 -17
  62. package/dist/components/ranked-venue-list.d.ts +49 -0
  63. package/dist/components/ranked-venue-list.js +87 -0
  64. package/dist/components/section-header.d.ts +16 -1
  65. package/dist/components/section-header.js +26 -7
  66. package/dist/components/signature-chip.d.ts +37 -0
  67. package/dist/components/signature-chip.js +4 -0
  68. package/dist/components/signature-line-list.d.ts +45 -0
  69. package/dist/components/signature-line-list.js +99 -0
  70. package/dist/components/snap-rail.d.ts +54 -0
  71. package/dist/components/snap-rail.js +109 -0
  72. package/dist/components/spinner.d.ts +2 -2
  73. package/dist/components/stat-banner.d.ts +47 -0
  74. package/dist/components/stat-banner.js +61 -0
  75. package/dist/components/text.d.ts +3 -3
  76. package/dist/components/timeline-band.d.ts +20 -2
  77. package/dist/components/timeline-band.js +1 -1
  78. package/dist/components/timeline-playhead.d.ts +26 -0
  79. package/dist/components/timeline-playhead.js +25 -0
  80. package/dist/components/venue-busyness-strip.d.ts +35 -0
  81. package/dist/components/venue-busyness-strip.js +37 -0
  82. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  83. package/dist/components/venue-grade-dimensions.js +3 -0
  84. package/dist/components/venue-quotes.d.ts +32 -0
  85. package/dist/components/venue-quotes.js +3 -0
  86. package/dist/components/venue-signature-block.d.ts +39 -0
  87. package/dist/components/venue-signature-block.js +6 -0
  88. package/dist/components/venue-spotlight.d.ts +74 -0
  89. package/dist/components/venue-spotlight.js +138 -0
  90. package/dist/components/venue-wall.d.ts +41 -0
  91. package/dist/components/venue-wall.js +118 -0
  92. package/dist/components/weekday-theme-banner.d.ts +70 -0
  93. package/dist/components/weekday-theme-banner.js +127 -0
  94. package/dist/components/weekend-panel.d.ts +5 -4
  95. package/dist/components/weekend-panel.js +85 -71
  96. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  97. package/dist/hooks/use-drag-swipe.js +2 -0
  98. package/dist/hooks/use-media-query.d.ts +17 -0
  99. package/dist/hooks/use-media-query.js +2 -0
  100. package/package.json +1 -1
  101. package/src/components/category-deck.tsx +19 -8
  102. package/src/components/colour-dictionary.stories.tsx +227 -0
  103. package/src/components/concept-compare.stories.tsx +104 -0
  104. package/src/components/concept-compare.tsx +276 -0
  105. package/src/components/coverflow.test.tsx +28 -1
  106. package/src/components/coverflow.tsx +37 -43
  107. package/src/components/drift-rail.tsx +167 -42
  108. package/src/components/event-card.tsx +55 -10
  109. package/src/components/event-compact-row.stories.tsx +30 -0
  110. package/src/components/event-compact-row.test.tsx +49 -0
  111. package/src/components/event-compact-row.tsx +162 -38
  112. package/src/components/event-day-header.tsx +3 -1
  113. package/src/components/event-day-separator.tsx +8 -1
  114. package/src/components/event-detail-panel.stories.tsx +205 -97
  115. package/src/components/event-detail-panel.test.tsx +181 -0
  116. package/src/components/event-detail-panel.tsx +703 -185
  117. package/src/components/event-hub-hero.stories.tsx +30 -0
  118. package/src/components/event-hub-hero.tsx +102 -30
  119. package/src/components/event-list-row.stories.tsx +34 -0
  120. package/src/components/event-list-row.test.tsx +53 -0
  121. package/src/components/event-list-row.tsx +135 -87
  122. package/src/components/hour-band.stories.tsx +28 -5
  123. package/src/components/hour-band.tsx +90 -70
  124. package/src/components/hub-surface.stories.tsx +68 -0
  125. package/src/components/hub-surface.tsx +40 -0
  126. package/src/components/menu-family-card.stories.tsx +112 -0
  127. package/src/components/menu-family-card.tsx +211 -0
  128. package/src/components/menu-field.stories.tsx +74 -0
  129. package/src/components/menu-field.tsx +199 -0
  130. package/src/components/menu-hub-hero.stories.tsx +92 -0
  131. package/src/components/menu-hub-hero.tsx +187 -0
  132. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  133. package/src/components/menu-intent-tiles.tsx +76 -0
  134. package/src/components/menu-item.tsx +416 -0
  135. package/src/components/menu-mocks.ts +417 -0
  136. package/src/components/menu-types.ts +259 -0
  137. package/src/components/motion-icon-set.tsx +30 -3
  138. package/src/components/open-now-list.stories.tsx +76 -0
  139. package/src/components/open-now-list.tsx +127 -0
  140. package/src/components/place-types.ts +112 -0
  141. package/src/components/poster-wall.test.tsx +28 -0
  142. package/src/components/poster-wall.tsx +96 -23
  143. package/src/components/ranked-venue-list.stories.tsx +80 -0
  144. package/src/components/ranked-venue-list.tsx +177 -0
  145. package/src/components/section-header.stories.tsx +24 -0
  146. package/src/components/section-header.test.tsx +28 -0
  147. package/src/components/section-header.tsx +45 -7
  148. package/src/components/signature-chip.tsx +77 -0
  149. package/src/components/signature-line-list.stories.tsx +85 -0
  150. package/src/components/signature-line-list.tsx +136 -0
  151. package/src/components/snap-rail.stories.tsx +79 -0
  152. package/src/components/snap-rail.test.tsx +18 -0
  153. package/src/components/snap-rail.tsx +148 -0
  154. package/src/components/stat-banner.stories.tsx +100 -0
  155. package/src/components/stat-banner.tsx +117 -0
  156. package/src/components/timeline-band.tsx +52 -12
  157. package/src/components/timeline-playhead.stories.tsx +30 -0
  158. package/src/components/timeline-playhead.test.tsx +11 -0
  159. package/src/components/timeline-playhead.tsx +46 -0
  160. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  161. package/src/components/venue-busyness-strip.tsx +64 -0
  162. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  163. package/src/components/venue-grade-dimensions.tsx +91 -0
  164. package/src/components/venue-quotes.stories.tsx +59 -0
  165. package/src/components/venue-quotes.tsx +64 -0
  166. package/src/components/venue-signature-block.stories.tsx +68 -0
  167. package/src/components/venue-signature-block.tsx +71 -0
  168. package/src/components/venue-spotlight.stories.tsx +141 -0
  169. package/src/components/venue-spotlight.tsx +236 -0
  170. package/src/components/venue-wall.stories.tsx +78 -0
  171. package/src/components/venue-wall.tsx +185 -0
  172. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  173. package/src/components/weekday-theme-banner.tsx +231 -0
  174. package/src/components/weekend-panel.test.tsx +43 -0
  175. package/src/components/weekend-panel.tsx +142 -108
  176. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  177. package/src/hooks/use-drag-swipe.ts +105 -0
  178. package/src/hooks/use-media-query.ts +31 -0
  179. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -1,17 +1,18 @@
1
+ import { useMediaQuery, DESKTOP_MEDIA_QUERY } from '../chunk-OPVKL4VS.js';
1
2
  import { MotionPlayPause } from '../chunk-TT3WFS6S.js';
2
3
  import '../chunk-2WDJR6K7.js';
4
+ import { useDragSwipe } from '../chunk-IYDH5IU6.js';
3
5
  import { Skeleton } from '../chunk-QXHMPJ35.js';
4
6
  import { cn } from '../chunk-FEK5XGZW.js';
5
- import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
7
+ import { __spreadProps, __spreadValues, __objRest } from '../chunk-3GWWZKX7.js';
6
8
  import * as React from 'react';
7
9
  import { ChevronRight } from 'lucide-react';
8
10
  import { jsxs, jsx } from 'react/jsx-runtime';
9
11
 
10
12
  var WEEKEND_PANEL_TRACK_GAP_PX = 14;
11
13
  var WEEKEND_PANEL_DEFAULT_DURATION_SECONDS = 7;
12
- var WEEKEND_PANEL_SLIDE_KEYFRAMES_ID = "ds-weekend-panel-slide";
13
- var WEEKEND_PANEL_DOT_A_KEYFRAMES_ID = "ds-weekend-panel-dot-a";
14
- var WEEKEND_PANEL_DOT_B_KEYFRAMES_ID = "ds-weekend-panel-dot-b";
14
+ var WEEKEND_PANEL_PAGE_COUNT = 2;
15
+ var WEEKEND_PANEL_CARDS_PER_PAGE = 2;
15
16
  function usePrefersReducedMotion() {
16
17
  const [reduced, setReduced] = React.useState(false);
17
18
  React.useEffect(() => {
@@ -30,9 +31,12 @@ function useInViewport() {
30
31
  React.useEffect(() => {
31
32
  const node = ref.current;
32
33
  if (!node || typeof IntersectionObserver === "undefined") return;
33
- const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
34
- threshold: 0
35
- });
34
+ const observer = new IntersectionObserver(
35
+ ([entry]) => setInView(entry.isIntersecting),
36
+ {
37
+ threshold: 0
38
+ }
39
+ );
36
40
  observer.observe(node);
37
41
  return () => observer.disconnect();
38
42
  }, []);
@@ -56,16 +60,36 @@ function WeekendPanel({
56
60
  className
57
61
  }) {
58
62
  const prefersReducedMotion = usePrefersReducedMotion();
63
+ const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
59
64
  const [panelRef, inView] = useInViewport();
60
65
  const [isHovered, setIsHovered] = React.useState(false);
61
66
  const [isFocused, setIsFocused] = React.useState(false);
62
67
  const [isManuallyPaused, setIsManuallyPaused] = React.useState(false);
63
68
  const [playPauseReplayToken, setPlayPauseReplayToken] = React.useState(0);
64
- const isPaused = isManuallyPaused || isHovered || isFocused || !inView;
69
+ const [page, setPage] = React.useState(0);
70
+ const autoplays = isDesktop && !prefersReducedMotion;
71
+ const isPaused = !autoplays || isManuallyPaused || isHovered || isFocused || !inView;
72
+ React.useEffect(() => {
73
+ if (isPaused) return void 0;
74
+ const interval = window.setInterval(
75
+ () => setPage((current) => (current + 1) % WEEKEND_PANEL_PAGE_COUNT),
76
+ durationSeconds * 1e3 / WEEKEND_PANEL_PAGE_COUNT
77
+ );
78
+ return () => window.clearInterval(interval);
79
+ }, [durationSeconds, isPaused]);
80
+ const turnTo = (next) => {
81
+ setPage(Math.max(0, Math.min(WEEKEND_PANEL_PAGE_COUNT - 1, next)));
82
+ setIsManuallyPaused(true);
83
+ };
84
+ const { dragX, isDragging, handlers } = useDragSwipe({
85
+ onSwipe: (direction) => turnTo(page + direction),
86
+ disabled: prefersReducedMotion
87
+ });
65
88
  const action = /* @__PURE__ */ jsxs("span", { className: "ml-auto flex items-center gap-0.5 text-[12.5px] text-muted-foreground", children: [
66
89
  actionLabel,
67
90
  /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true })
68
91
  ] });
92
+ const cards = React.Children.toArray(children);
69
93
  return /* @__PURE__ */ jsxs(
70
94
  "div",
71
95
  {
@@ -80,25 +104,6 @@ function WeekendPanel({
80
104
  className
81
105
  ),
82
106
  children: [
83
- /* @__PURE__ */ jsx("style", { children: `
84
- @keyframes ${WEEKEND_PANEL_SLIDE_KEYFRAMES_ID} {
85
- 0%, 45% { transform: translateX(0); }
86
- 50%, 95% { transform: translateX(calc(-100% - ${WEEKEND_PANEL_TRACK_GAP_PX}px)); }
87
- 100% { transform: translateX(0); }
88
- }
89
- @keyframes ${WEEKEND_PANEL_DOT_A_KEYFRAMES_ID} {
90
- 0% { background: var(--primary); width: 18px; }
91
- 45% { background: var(--primary); width: 18px; }
92
- 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
93
- 100% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
94
- }
95
- @keyframes ${WEEKEND_PANEL_DOT_B_KEYFRAMES_ID} {
96
- 0% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
97
- 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
98
- 95% { background: var(--primary); width: 18px; }
99
- 100% { background: var(--primary); width: 18px; }
100
- }
101
- ` }),
102
107
  /* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-[9px] px-4 pb-3", children: [
103
108
  /* @__PURE__ */ jsx(
104
109
  "span",
@@ -130,51 +135,57 @@ function WeekendPanel({
130
135
  }
131
136
  ) : action
132
137
  ] }),
133
- prefersReducedMotion ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3.5 px-4", children }) : /* @__PURE__ */ jsx("div", { className: "overflow-hidden px-4", children: /* @__PURE__ */ jsx(
134
- "div",
135
- {
136
- className: "flex gap-3.5",
137
- style: {
138
- animationName: WEEKEND_PANEL_SLIDE_KEYFRAMES_ID,
139
- animationDuration: `${durationSeconds}s`,
140
- animationTimingFunction: "ease-in-out",
141
- animationIterationCount: "infinite",
142
- animationPlayState: isPaused ? "paused" : "running"
143
- },
144
- children: React.Children.map(children, (child) => /* @__PURE__ */ jsx("div", { className: "w-[calc((100%-14px)/2)] flex-none", children: child }))
145
- }
146
- ) }),
138
+ prefersReducedMotion ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3.5 px-4", children }) : (
139
+ // Padding OUTSIDE the clip. `overflow-hidden` clips at the padding
140
+ // edge, so with the padding on the clipping element the next page's
141
+ // first card showed through the 16px inset — a sliver of the slide
142
+ // cutting in. The reader should see exactly the two cards of a page.
143
+ /* @__PURE__ */ jsx("div", { className: "px-4", children: /* @__PURE__ */ jsx(
144
+ "div",
145
+ __spreadProps(__spreadValues({}, handlers), {
146
+ className: "overflow-hidden select-none [touch-action:pan-y]",
147
+ children: /* @__PURE__ */ jsx(
148
+ "div",
149
+ {
150
+ className: cn(
151
+ "flex gap-3.5",
152
+ !isDragging && "transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
153
+ ),
154
+ style: {
155
+ transform: `translateX(calc(${-page} * (100% + ${WEEKEND_PANEL_TRACK_GAP_PX}px) + ${dragX}px))`
156
+ },
157
+ children: cards.map((child, index) => /* @__PURE__ */ jsx(
158
+ "div",
159
+ {
160
+ className: "w-[calc((100%-14px)/2)] flex-none",
161
+ onFocus: () => {
162
+ const cardPage = Math.floor(
163
+ index / WEEKEND_PANEL_CARDS_PER_PAGE
164
+ );
165
+ if (cardPage !== page) turnTo(cardPage);
166
+ },
167
+ children: child
168
+ },
169
+ index
170
+ ))
171
+ }
172
+ )
173
+ })
174
+ ) })
175
+ ),
147
176
  !prefersReducedMotion && /* @__PURE__ */ jsxs("div", { className: "mt-3.5 grid grid-cols-[1fr_auto_1fr] items-center px-4", children: [
148
177
  /* @__PURE__ */ jsx("span", { "aria-hidden": true }),
149
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-1.5", "aria-hidden": true, children: [
150
- /* @__PURE__ */ jsx(
151
- "span",
152
- {
153
- className: "block h-1.5 w-[18px] rounded-full bg-primary",
154
- style: {
155
- animationName: WEEKEND_PANEL_DOT_A_KEYFRAMES_ID,
156
- animationDuration: `${durationSeconds}s`,
157
- animationTimingFunction: "ease-in-out",
158
- animationIterationCount: "infinite",
159
- animationPlayState: isPaused ? "paused" : "running"
160
- }
161
- }
162
- ),
163
- /* @__PURE__ */ jsx(
164
- "span",
165
- {
166
- className: "block h-1.5 w-1.5 rounded-full bg-muted-foreground/35",
167
- style: {
168
- animationName: WEEKEND_PANEL_DOT_B_KEYFRAMES_ID,
169
- animationDuration: `${durationSeconds}s`,
170
- animationTimingFunction: "ease-in-out",
171
- animationIterationCount: "infinite",
172
- animationPlayState: isPaused ? "paused" : "running"
173
- }
174
- }
175
- )
176
- ] }),
177
- /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
178
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center gap-1.5", "aria-hidden": true, children: Array.from({ length: WEEKEND_PANEL_PAGE_COUNT }, (_, index) => /* @__PURE__ */ jsx(
179
+ "span",
180
+ {
181
+ className: cn(
182
+ "block h-1.5 rounded-full transition-[width,background-color] duration-300 motion-reduce:transition-none",
183
+ index === page ? "w-[18px] bg-primary" : "w-1.5 bg-muted-foreground/35"
184
+ )
185
+ },
186
+ index
187
+ )) }),
188
+ /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: autoplays && /* @__PURE__ */ jsx(
178
189
  "button",
179
190
  {
180
191
  type: "button",
@@ -198,7 +209,10 @@ function WeekendPanel({
198
209
  }
199
210
  );
200
211
  }
201
- function WeekendPanelSkeleton({ isHighlighted, className }) {
212
+ function WeekendPanelSkeleton({
213
+ isHighlighted,
214
+ className
215
+ }) {
202
216
  return /* @__PURE__ */ jsxs(
203
217
  "div",
204
218
  {
@@ -0,0 +1,44 @@
1
+ import { PointerEvent } from 'react';
2
+
3
+ /**
4
+ * useDragSwipe — horizontal drag-to-step for a stage that is not a native
5
+ * scroller (a coverflow ring, a paged track). Pointer events, so one code path
6
+ * covers touch, pen and mouse.
7
+ *
8
+ * Returns the live drag distance (`dragX`, for the stage to follow the finger
9
+ * a little while dragging) and the handlers to spread onto the stage. On
10
+ * release past `threshold` it calls `onSwipe(-1 | 1)` — `1` for a leftward
11
+ * drag, i.e. "next". `didDrag` is set for the tick after a real drag so the
12
+ * stage's `onClickCapture` can swallow the click a drag would otherwise
13
+ * become on a card underneath.
14
+ *
15
+ * The stage keeps vertical scrolling: pair these with `touch-action: pan-y`
16
+ * so the browser owns vertical gestures and hands only horizontal ones here.
17
+ * Pointer capture is taken on move, not on down, so a tap still reaches the
18
+ * card underneath as a plain click.
19
+ *
20
+ * `onDragStart` is part of the handlers for a reason: the cards under a stage
21
+ * are usually links wrapping images, and a mouse drag on either starts a
22
+ * NATIVE HTML5 drag, which cancels the pointer stream mid-gesture — the swipe
23
+ * silently never completes. Preventing it keeps the pointer events flowing.
24
+ */
25
+ type DragSwipeOptions = {
26
+ /** Distance in px that counts as a swipe. Default 40. */
27
+ threshold?: number;
28
+ onSwipe: (direction: -1 | 1) => void;
29
+ disabled?: boolean;
30
+ };
31
+ declare function useDragSwipe({ threshold, onSwipe, disabled }: DragSwipeOptions): {
32
+ dragX: number;
33
+ isDragging: boolean;
34
+ handlers: {
35
+ onPointerDown: (event: PointerEvent<HTMLElement>) => void;
36
+ onPointerMove: (event: PointerEvent<HTMLElement>) => void;
37
+ onPointerUp: (event: PointerEvent<HTMLElement>) => void;
38
+ onPointerCancel: (event: PointerEvent<HTMLElement>) => void;
39
+ onClickCapture: (event: PointerEvent<HTMLElement> | React.MouseEvent) => void;
40
+ onDragStart: (event: React.DragEvent<HTMLElement>) => void;
41
+ };
42
+ };
43
+
44
+ export { type DragSwipeOptions, useDragSwipe };
@@ -0,0 +1,2 @@
1
+ export { useDragSwipe } from '../chunk-IYDH5IU6.js';
2
+ import '../chunk-3GWWZKX7.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * useMediaQuery — a boolean for a CSS media query that is hydration-safe.
3
+ *
4
+ * `useSyncExternalStore` with a fixed server snapshot (`false`) means the
5
+ * server and the hydrating client render the same tree; the real value
6
+ * arrives on the next render. Components that switch behaviour per breakpoint
7
+ * (not just classes) need this rather than `window.matchMedia` in an effect,
8
+ * which flashes the wrong variant for a frame.
9
+ *
10
+ * Mobile-first: `false` on the server and until measured, so the phone
11
+ * variant is the one that prerenders.
12
+ */
13
+ declare function useMediaQuery(query: string): boolean;
14
+ /** The design system's desktop breakpoint, Tailwind's `lg`. */
15
+ declare const DESKTOP_MEDIA_QUERY = "(min-width: 1024px)";
16
+
17
+ export { DESKTOP_MEDIA_QUERY, useMediaQuery };
@@ -0,0 +1,2 @@
1
+ export { DESKTOP_MEDIA_QUERY, useMediaQuery } from '../chunk-OPVKL4VS.js';
2
+ import '../chunk-3GWWZKX7.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -31,7 +31,11 @@ const DECK_POSTER_COUNT = 4;
31
31
  const DECK_TRANSITION =
32
32
  "transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none";
33
33
 
34
- const DECK_CARD_TRANSFORMS: ReadonlyArray<{ rest: string; spread: string; z: string }> = [
34
+ const DECK_CARD_TRANSFORMS: ReadonlyArray<{
35
+ rest: string;
36
+ spread: string;
37
+ z: string;
38
+ }> = [
35
39
  {
36
40
  rest: "[transform:translateX(-50%)_rotate(-9deg)_translateX(-26px)]",
37
41
  spread:
@@ -115,13 +119,12 @@ export function CategoryDeck({
115
119
  }: CategoryDeckProps) {
116
120
  const slots = Array.from(
117
121
  { length: DECK_POSTER_COUNT },
118
- (_, index): CategoryDeckPoster => posters[index] ?? {}
122
+ (_, index): CategoryDeckPoster => posters[index] ?? {},
119
123
  );
120
124
 
121
125
  const body = (
122
126
  <>
123
127
  <div className="relative h-[150px] sm:h-[186px]" aria-hidden>
124
-
125
128
  {slots.map((poster, index) => {
126
129
  const transform = DECK_CARD_TRANSFORMS[index];
127
130
  return (
@@ -129,12 +132,15 @@ export function CategoryDeck({
129
132
  key={index}
130
133
  className={cn(
131
134
  "absolute left-1/2 top-0 aspect-[4/5] w-24 overflow-hidden rounded-[10px] bg-muted",
135
+ // Keyed to --foreground so the shadow is ink in light mode; in
136
+ // dark mode that same 18% is a light halo, so it drops to 7%.
132
137
  "shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_18%,transparent)]",
138
+ "dark:shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_7%,transparent)]",
133
139
  "sm:w-[120px]",
134
140
  transform.rest,
135
141
  transform.spread,
136
142
  transform.z,
137
- DECK_TRANSITION
143
+ DECK_TRANSITION,
138
144
  )}
139
145
  >
140
146
  {poster.content ? (
@@ -161,9 +167,14 @@ export function CategoryDeck({
161
167
  <div className="truncate text-[14.5px] font-extrabold tracking-[-0.015em] sm:text-base">
162
168
  {name}
163
169
  </div>
164
- <div className="truncate font-mono text-[11.5px] text-muted-foreground">{meta}</div>
170
+ <div className="truncate font-mono text-[11.5px] text-muted-foreground">
171
+ {meta}
172
+ </div>
165
173
  </div>
166
- <ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
174
+ <ChevronRight
175
+ className="ml-auto h-4 w-4 shrink-0 text-muted-foreground"
176
+ aria-hidden
177
+ />
167
178
  </div>
168
179
  </>
169
180
  );
@@ -172,7 +183,7 @@ export function CategoryDeck({
172
183
  "group flex min-w-0 flex-col text-left",
173
184
  (href || onClick) &&
174
185
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
175
- className
186
+ className,
176
187
  );
177
188
 
178
189
  if (href) {
@@ -209,7 +220,7 @@ export function CategoryDeckSkeleton({ className }: CategoryDeckSkeletonProps) {
209
220
  className={cn(
210
221
  "absolute left-1/2 top-0 aspect-[4/5] w-24 rounded-[10px] sm:w-[120px]",
211
222
  transform.rest,
212
- transform.z
223
+ transform.z,
213
224
  )}
214
225
  />
215
226
  ))}
@@ -0,0 +1,227 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+
3
+ /**
4
+ * Foundations / Colour dictionary — every colour a menu surface paints, in one
5
+ * table grouped by the forest element it is, with the reason beside it. A page,
6
+ * not a component: the values live in the consuming app (see the notes under
7
+ * the table), this is where a person goes to understand them.
8
+ */
9
+
10
+ type Chip = { label: string; value: string };
11
+
12
+ type Row = {
13
+ element: string;
14
+ why: string;
15
+ /** The three `--menu-field-<family>-<n>` tints, when this element is a card-field family. */
16
+ field?: { family: string; tints: [string, string, string]; draws: string };
17
+ /** `FAMILY_HUES` entries painted by the hub's family cards (chips, price band, photo tint). */
18
+ familyCards: Chip[];
19
+ /** `INTENT_GRADIENTS` entries painted by the hub's intent tiles. */
20
+ intents: Chip[];
21
+ };
22
+
23
+ const ROWS: Row[] = [
24
+ {
25
+ element: "Wheat and honey",
26
+ why: "Grain and what goes on it — crust, pasta, pizza base, pastry, yolk — is this gold before it is cooked. Honey in the comb is the same colour, and so is morning light on toast, which is why brunch and beer (roasted malt) sit here too.",
27
+ field: { family: "larder", tints: ["oklch(0.505 0.11 74)", "oklch(0.555 0.105 80)", "oklch(0.465 0.115 68)"], draws: "bread, pasta, pizza, eggs, sides, sauces, spirits" },
28
+ familyCards: [{ label: "Breakfast & brunch", value: "oklch(0.62 0.14 82)" }, { label: "Bakeries", value: "oklch(0.58 0.11 72)" }, { label: "Beer", value: "oklch(0.62 0.13 86)" }],
29
+ intents: [{ label: "Brunch", value: "oklch(0.62 0.14 82)" }],
30
+ },
31
+ {
32
+ element: "Lake water",
33
+ why: "Fish, shellfish and soup come out of water, and a cocktail is a glass of it. A forest has a lake, not a sea: dark, blue-green, in shade — so the hue stays on the green side (198–220) and never reads as sky or as a pool.",
34
+ field: { family: "sea", tints: ["oklch(0.485 0.095 205)", "oklch(0.535 0.09 198)", "oklch(0.445 0.095 212)"], draws: "fish, shellfish, soups, cocktails" },
35
+ familyCards: [{ label: "Cocktails", value: "oklch(0.47 0.10 205)" }],
36
+ intents: [{ label: "Cocktail bars", value: "oklch(0.47 0.10 205)" }],
37
+ },
38
+ {
39
+ element: "Ember and terracotta",
40
+ why: "Meat is the family cooked over fire: the red of the coals, the crust on beef, cured and smoked meat. Red wine shares the glass and the colour. Terracotta is the same fire one step warmer — the oven's brick and the tomato — which is why Italian sits beside Romanian here.",
41
+ field: { family: "meat", tints: ["oklch(0.49 0.13 28)", "oklch(0.54 0.13 34)", "oklch(0.45 0.13 22)"], draws: "beef, game, cured meat, burgers, red wine" },
42
+ familyCards: [{ label: "Romanian", value: "oklch(0.48 0.16 25)" }, { label: "Italian", value: "oklch(0.56 0.15 38)" }],
43
+ intents: [{ label: "Burgers", value: "oklch(0.48 0.16 25)" }, { label: "Italian tonight", value: "oklch(0.56 0.15 38)" }],
44
+ },
45
+ {
46
+ element: "Maple and amber",
47
+ why: "Sugar turns this colour when it meets heat: caramel, syrup, the top of a crème brûlée, the maple leaf in autumn — and a pizza crust out of the oven, which is why the Pizza card wears it. Beer's amber is the same chemistry, malt roasted, so beer items draw this field even though the beer card wears honey.",
48
+ field: { family: "sweet", tints: ["oklch(0.505 0.14 50)", "oklch(0.555 0.135 58)", "oklch(0.465 0.14 44)"], draws: "desserts, pastry, beer" },
49
+ familyCards: [{ label: "Pizza", value: "oklch(0.52 0.14 58)" }],
50
+ intents: [],
51
+ },
52
+ {
53
+ element: "Fern and pine",
54
+ why: "The leaf itself. Salads, vegetables and the non-alcoholic glass — juice, lemonade, the herb in it — are the green things on the menu and take the forest's own green: pine-deep rather than lime, so it stays a forest and not a lawn.",
55
+ field: { family: "verdant", tints: ["oklch(0.505 0.115 148)", "oklch(0.555 0.11 138)", "oklch(0.465 0.115 156)"], draws: "salads, vegetables, non-alcoholic" },
56
+ familyCards: [{ label: "Vegetarian", value: "oklch(0.52 0.14 148)" }, { label: "Vegan", value: "oklch(0.55 0.12 135)" }],
57
+ intents: [{ label: "Vegan", value: "oklch(0.52 0.14 148)" }],
58
+ },
59
+ {
60
+ element: "Bark and walnut",
61
+ why: "The roasted bean is the colour of tree bark, and a coffee house is made of both — wood and roast. Walnut is the same brown darker: a lit doorway at night, which is what \"open right now\" looks like from the street. Coffee items themselves draw the non-alcoholic field (pine), not this.",
62
+ familyCards: [{ label: "Coffee", value: "oklch(0.44 0.10 52)" }],
63
+ intents: [{ label: "Coffee", value: "oklch(0.44 0.10 52)" }, { label: "Open right now", value: "oklch(0.44 0.08 62)" }],
64
+ },
65
+ {
66
+ element: "Rosehip",
67
+ why: "The forest's berry: the jam in the tart, the fruit on the cake, the glass of red. A red with no purple in it — the magenta and violet it replaced belong to no place a bear has been.",
68
+ familyCards: [{ label: "Wine by the glass", value: "oklch(0.48 0.14 12)" }],
69
+ intents: [{ label: "Patisseries", value: "oklch(0.52 0.15 12)" }],
70
+ },
71
+ {
72
+ element: "Moss in sun",
73
+ why: "The sunlit forest floor. Yellow-green because a terrace is the one intent about being outdoors in daylight; deeper than lime so it is ground, not a highlighter.",
74
+ familyCards: [],
75
+ intents: [{ label: "On a terrace", value: "oklch(0.60 0.13 122)" }],
76
+ },
77
+ {
78
+ element: "Stone",
79
+ why: "No colour of its own. An untagged dish gets it because there is no ingredient to be coloured by; Centru gets it because the town is the one intent that is a place and not a taste. Never a section mark — as a mark, grey reads as absent.",
80
+ field: { family: "neutral", tints: ["oklch(0.505 0.02 60)", "oklch(0.555 0.022 66)", "oklch(0.465 0.018 54)"], draws: "an untagged dish only" },
81
+ familyCards: [],
82
+ intents: [{ label: "In Centru", value: "oklch(0.50 0.03 70)" }],
83
+ },
84
+ ];
85
+
86
+ const NOT_THESE: Chip[] = [
87
+ { label: "sky blue", value: "oklch(0.5 0.13 240)" },
88
+ { label: "teal", value: "oklch(0.55 0.12 190)" },
89
+ { label: "violet", value: "oklch(0.5 0.11 300)" },
90
+ { label: "magenta", value: "oklch(0.52 0.13 340)" },
91
+ { label: "olive-yellow", value: "oklch(0.58 0.12 95)" },
92
+ ];
93
+
94
+ const darker = (value: string) =>
95
+ value.replace(/oklch\(([\d.]+) /, (_m, l: string) => `oklch(${(Number(l) - 0.09).toFixed(3)} `);
96
+
97
+ function Swatch({ label, value, angle = 135, tall }: Chip & { angle?: number; tall?: boolean }) {
98
+ return (
99
+ <div className="flex w-[104px] shrink-0 flex-col gap-1">
100
+ <span
101
+ aria-hidden
102
+ className={tall ? "block h-14 rounded-lg" : "block h-9 rounded-lg"}
103
+ style={{ backgroundImage: `linear-gradient(${angle}deg, ${value}, ${darker(value)})` }}
104
+ />
105
+ <span className="text-[11px] font-semibold leading-tight">{label}</span>
106
+ <code className="text-[9.5px] leading-tight text-muted-foreground">{value.replace("oklch(", "").replace(")", "")}</code>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ const TH = "px-3 py-2 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted-foreground";
112
+ const TD = "px-3 py-3 align-top";
113
+
114
+ function ColourDictionaryPage() {
115
+ return (
116
+ <article className="mx-auto max-w-[1440px] px-6 py-7 text-foreground">
117
+ <p className="text-[11px] font-bold uppercase tracking-[0.09em] text-muted-foreground">Foundations</p>
118
+ <h1 className="mt-0.5 text-[26px] font-extrabold tracking-[-0.02em]">The menu colour dictionary</h1>
119
+ <p className="mt-1 max-w-[90ch] text-[14px] leading-[1.55] text-foreground/85">
120
+ Every hue is a forest object, at full strength — the floor and canopy of a forest and what a bear eats off it.
121
+ Every colour a menu surface paints is in this table, grouped by the element it is, with why it belongs.
122
+ <span className="ml-2 inline-flex items-center gap-2 align-middle text-[12px] text-muted-foreground">
123
+ Not in the forest, so not in the palette:
124
+ {NOT_THESE.map((c) => (
125
+ <span key={c.label} className="inline-flex items-center gap-1 line-through">
126
+ <span aria-hidden className="inline-block h-3.5 w-5 rounded-sm" style={{ background: c.value }} />
127
+ {c.label}
128
+ </span>
129
+ ))}
130
+ </span>
131
+ </p>
132
+
133
+ <div className="mt-5 overflow-x-auto rounded-xl border border-border">
134
+ <table className="w-full min-w-[1180px] border-collapse text-[13px]">
135
+ <thead className="bg-muted/60">
136
+ <tr>
137
+ <th className={`${TH} w-[290px]`}>Element · why it belongs</th>
138
+ <th className={TH}>Card fields · the three tints</th>
139
+ <th className={TH}>Family cards · chips, price band, photo tint</th>
140
+ <th className={TH}>Intent tiles</th>
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ {ROWS.map((row) => (
145
+ <tr key={row.element} className="border-t border-border">
146
+ <td className={TD}>
147
+ <div className="text-[15px] font-extrabold tracking-[-0.01em]">{row.element}</div>
148
+ <p className="mt-1 text-[12.5px] leading-[1.5] text-foreground/85">{row.why}</p>
149
+ </td>
150
+ <td className={TD}>
151
+ {row.field ? (
152
+ <div className="flex flex-col gap-2">
153
+ <div className="flex gap-2">
154
+ {row.field.tints.map((tint, index) => (
155
+ <Swatch key={tint} label={`${row.field!.family}-${index}`} value={tint} tall />
156
+ ))}
157
+ </div>
158
+ <div className="text-[11.5px] text-muted-foreground">
159
+ <span className="font-semibold text-foreground/80">draws</span> {row.field.draws}
160
+ </div>
161
+ </div>
162
+ ) : (
163
+ <span className="text-[12px] text-muted-foreground">—</span>
164
+ )}
165
+ </td>
166
+ <td className={TD}>
167
+ {row.familyCards.length ? (
168
+ <div className="flex gap-2">
169
+ {row.familyCards.map((c) => (
170
+ <Swatch key={c.label} {...c} angle={150} />
171
+ ))}
172
+ </div>
173
+ ) : (
174
+ <span className="text-[12px] text-muted-foreground">—</span>
175
+ )}
176
+ </td>
177
+ <td className={TD}>
178
+ {row.intents.length ? (
179
+ <div className="flex gap-2">
180
+ {row.intents.map((c) => (
181
+ <Swatch key={c.label} {...c} angle={150} />
182
+ ))}
183
+ </div>
184
+ ) : (
185
+ <span className="text-[12px] text-muted-foreground">—</span>
186
+ )}
187
+ </td>
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+
194
+ <div className="mt-8 grid gap-8 md:grid-cols-3">
195
+ <section>
196
+ <h2 className="text-[15px] font-extrabold">How a colour is chosen</h2>
197
+ <ol className="mt-2 list-decimal space-y-1.5 pl-4 text-[13px] leading-[1.55] text-foreground/85">
198
+ <li><b>What is the family made of, or where is it consumed?</b> Grain, fire, water, sugar, leaf; a night out, a morning, the town.</li>
199
+ <li><b>Which forest element already has that colour — literally?</b> Wheat is gold, embers are red, lake water is blue-green. If nothing in a forest has it, it does not go in; that is how violet and magenta left.</li>
200
+ <li><b>Take it at full strength.</b> An early pass pulled every chroma down to feel earthy and read as faded. Keep lightness 0.44–0.62 so white type at 0.26–0.30 alpha stays readable.</li>
201
+ </ol>
202
+ </section>
203
+ <section>
204
+ <h2 className="text-[15px] font-extrabold">The colour names a family, never an item</h2>
205
+ <p className="mt-2 text-[13px] leading-[1.55] text-foreground/85">
206
+ Sixteen appetizers draw one colour. Two dishes in one family differ only by one of the three tints, keyed on the parent concept so the same dish is the same tint on every menu and after every re-sort. A category's section mark always uses tint <code>-0</code>. Where a family has both a card and an intent tile, they share the value so the two doors to one page agree.
207
+ </p>
208
+ </section>
209
+ <section>
210
+ <h2 className="text-[15px] font-extrabold">Where the values live</h2>
211
+ <p className="mt-2 text-[13px] leading-[1.55] text-foreground/85">
212
+ The design system owns the typography, the falloff and the layering (<code>MenuField</code>, <code>MenuFamilyCard</code>, <code>MenuIntentTiles</code>) and takes a finished colour as a prop. The palette lives in the fe app: the eighteen <code>--menu-field-*</code> tokens in <code>app/globals.css</code>, <code>FAMILY_HUES</code> and <code>INTENT_GRADIENTS</code> in <code>hub-gradients.ts</code>, category → family in <code>category-colors.ts</code>. The Storybook mocks carry the same values so post-generator films what the app paints.
213
+ </p>
214
+ </section>
215
+ </div>
216
+ </article>
217
+ );
218
+ }
219
+
220
+ const meta: Meta<typeof ColourDictionaryPage> = {
221
+ title: "Foundations/Colour dictionary",
222
+ component: ColourDictionaryPage,
223
+ parameters: { layout: "fullscreen" },
224
+ };
225
+ export default meta;
226
+
227
+ export const Page: StoryObj<typeof ColourDictionaryPage> = {};