@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,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { HourBand, HourBandSkeleton } from "./hour-band";
3
3
  import { EventCard } from "./event-card";
4
+ import { EventCompactRow } from "./event-compact-row";
4
5
  import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
6
 
6
7
  const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
@@ -35,7 +36,7 @@ const meta: Meta<typeof HourBand> = {
35
36
  docs: {
36
37
  description: {
37
38
  component:
38
- "One hour of the timeline: a 52px gutter with a hairline and an hour puck, and that hour's events beside it. The cards inside carry no timestamp of their own — the band is the timestamp, which is why they are rendered with `hideWhen`.",
39
+ "One hour of the mobile timeline: a 58px gutter with a filled hour chip and a hairline dropping from beneath it, and that hour's rows beside it, ruled between. The rows carry no timestamp of their own — the band is the timestamp, which is why cards render with `hideWhen` and compact rows with `hideTime`.",
39
40
  },
40
41
  },
41
42
  },
@@ -66,7 +67,7 @@ export const Default: Story = {
66
67
  },
67
68
  };
68
69
 
69
- /** The live hour: green-bordered puck, NOW label, and the content column shifted to clear it. */
70
+ /** The live hour: tinted chip and hairline. The live label is screen-reader only in this variant. */
70
71
  export const Live: Story = {
71
72
  args: {
72
73
  time: "20:00",
@@ -84,6 +85,28 @@ export const Live: Story = {
84
85
  },
85
86
  };
86
87
 
88
+ /** The shipped mobile shape: compact rows, ruled between, inside one band. */
89
+ export const CompactRows: Story = {
90
+ render: () => (
91
+ <HourBand time="20:00" isLive liveLabel="Now on">
92
+ <EventCompactRow
93
+ event={event("r1", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", 20)}
94
+ labels={labels}
95
+ formatters={formatters}
96
+ hideTime
97
+ tags={["Drinks", "Games"]}
98
+ />
99
+ <EventCompactRow
100
+ event={event("r2", "Quiz Night at Café del Mars", "Del Mars", 10)}
101
+ labels={labels}
102
+ formatters={formatters}
103
+ hideTime
104
+ tags={["Games"]}
105
+ />
106
+ </HourBand>
107
+ ),
108
+ };
109
+
87
110
  /** A single event in a quiet hour. */
88
111
  export const OneEvent: Story = {
89
112
  args: {
@@ -133,9 +156,9 @@ export const StackedHours: Story = {
133
156
  };
134
157
 
135
158
  /**
136
- * Loading. The puck is omitted rather than blocked outthe hour isn't known
137
- * until data lands, and a grey lozenge sitting on the axis reads as a real but
138
- * unreadable timestamp. The hairline alone still says "time axis".
159
+ * Loading. The chip is blocked out at its fixed 58x24 size a grey lozenge
160
+ * of that exact shape reads as "a time goes here" without inventing one and
161
+ * the rows mirror `EventCompactRowSkeleton`.
139
162
  */
140
163
  export const Loading: Story = {
141
164
  render: () => <HourBandSkeleton />,
@@ -4,41 +4,39 @@ import * as React from "react";
4
4
  import { cn } from "../lib/utils";
5
5
  import { Separator } from "./separator";
6
6
  import { Skeleton } from "./skeleton";
7
- import { TimelineHourPuck } from "./timeline-band";
7
+ import {
8
+ TIMELINE_MOBILE_GUTTER_CLASS,
9
+ TimelineHourPuck,
10
+ } from "./timeline-band";
8
11
 
9
12
  /**
10
13
  * HourBand — one hour on the timeline's axis, with that hour's events beside it.
11
14
  *
12
- * The design's mobile timeline is deliberately plain: a 52px gutter carrying a
13
- * hairline and an hour puck, and full-width cards to its right. Nothing else.
14
- * The cards inside carry no timestamp of their own this band IS the
15
- * timestamp, which is why `EventCard` has `hideWhen`.
15
+ * The mobile timeline is an app list, not a page: a 58px gutter carrying a
16
+ * filled hour chip with a hairline dropping from beneath it, and the hour's
17
+ * rows to the right, separated by hairlines of their own. The rows carry no
18
+ * timestamp — this band IS the timestamp, which is why `EventCard` has
19
+ * `hideWhen` and `EventCompactRow` has `hideTime`.
16
20
  *
17
- * The puck is the shared `TimelineHourPuck`, sitting ON the line with a
18
- * background fill punching through it so the hairline reads as continuous
19
- * behind the label. It used to be a second, hand-rolled copy of the desktop
20
- * band's puck, and the two had drifted apart this one borderless and
21
- * `rounded-md px-1.5`, the desktop one bordered and `rounded-[7px] px-[7px]`.
22
- * Sharing the object changes this gutter's pixels: the mobile puck now carries
23
- * a border, which is what makes it read as clipped onto the rail rather than
24
- * text floating over it, and matches the station on the desktop spine.
21
+ * The chip is the shared `TimelineHourPuck` in its `chip` variant, so the
22
+ * desktop spine's station and this gutter's label are one object with two
23
+ * looks rather than two objects that drift. The hairline starts BELOW the chip
24
+ * rather than running behind it: a chip is a label at the top of its hour, a
25
+ * station is a tick on a continuous rail, and the two read differently.
25
26
  *
26
- * `isLive` turns the puck green-bordered and reveals the NOW label beneath it,
27
- * which is why live bands need extra top padding on the content column the
28
- * label occupies space the cards would otherwise start in.
27
+ * `isLive` tints the chip and the hairline with `--success`; the live label is
28
+ * kept for screen readers (the chip variant hides it visually). The playhead
29
+ * the exact current time is a separate `TimelinePlayhead` rendered between
30
+ * bands by the caller, because an hour spans sixty minutes and "now" is one of
31
+ * them.
29
32
  *
30
- * ── Why the gap is 24px and not 12px ──────────────────────────────────────
31
- * THE PUCK IS WIDER THAN THE GUTTER IT SITS IN, and the gap is what absorbs
32
- * the difference. A 12-hour locale renders "09:00 AM" 8 monospace glyphs at
33
- * 12.5px (~60px) plus `px-[7px]` twice and a border twice, so ~76px of puck
34
- * centred on a 52px gutter, overflowing ~12px on each side. At the original
35
- * `gap-3` that right-hand overflow exactly consumed the gap and the puck's
36
- * edge landed ON the content column, which read as the hairline colliding
37
- * with the rows beside it.
38
- *
39
- * 24px leaves ~12px of real clearance at the widest label. Do not tighten it
40
- * back without re-deriving against a 12-hour locale: a 24-hour one ("09:00")
41
- * is ~30px narrower and hides the problem completely.
33
+ * ROWS ARE SEPARATED HERE, NOT IN THE ROW. `EventCompactRow` deliberately
34
+ * draws no divider (its container does see its docblock), so this band wraps
35
+ * each child and rules between them. The wrapper, not the child, carries the
36
+ * rule, so the row's own negative-margin hover wash is untouched. Children are
37
+ * read with `Children.toArray`, which drops `null`/`false` so a conditional
38
+ * first row never inherits a rule meant for a second; pass rows as siblings,
39
+ * not inside a fragment a fragment is one child and gets one wrapper.
42
40
  */
43
41
 
44
42
  export type HourBandProps = {
@@ -46,80 +44,102 @@ export type HourBandProps = {
46
44
  time: string;
47
45
  /** Marks this as the hour currently in progress. */
48
46
  isLive?: boolean;
49
- /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
47
+ /** Screen-reader text for the live hour, e.g. "Now on". Required to render it. */
50
48
  liveLabel?: string;
51
- /** The hour's events. */
49
+ /** The hour's events. Each direct child is one row or card. */
52
50
  children: React.ReactNode;
51
+ /**
52
+ * `rows` (default): compact rows, ruled between with a Separator. `cards`:
53
+ * poster cards, stacked with a 16px gap and no rules — a card is its own
54
+ * frame, and a hairline between two posters reads as a third object.
55
+ */
56
+ layout?: "rows" | "cards";
53
57
  className?: string;
54
58
  };
55
59
 
56
- export function HourBand({ time, isLive, liveLabel, children, className }: HourBandProps) {
57
- const showLiveLabel = isLive && !!liveLabel;
58
-
60
+ export function HourBand({
61
+ time,
62
+ isLive,
63
+ liveLabel,
64
+ children,
65
+ layout = "rows",
66
+ className,
67
+ }: HourBandProps) {
68
+ const isCards = layout === "cards";
59
69
  return (
60
- <div className={cn("flex gap-6 pt-[18px]", className)}>
61
- <div className="relative flex w-[52px] shrink-0 justify-center">
62
- <Separator orientation="vertical" className="h-full" />
70
+ <div className={cn("flex gap-3 pt-2.5", className)}>
71
+ <div className={cn("relative shrink-0", TIMELINE_MOBILE_GUTTER_CLASS)}>
63
72
  <TimelineHourPuck
64
73
  time={time}
65
74
  isLive={isLive}
66
75
  liveLabel={liveLabel}
67
- className="absolute left-1/2 top-0 w-0"
76
+ variant="chip"
77
+ />
78
+ <Separator
79
+ orientation="vertical"
80
+ className={cn(
81
+ "absolute bottom-[-10px] left-1/2 top-[30px] h-auto -translate-x-1/2",
82
+ isLive && "bg-success/30",
83
+ )}
68
84
  />
69
85
  </div>
70
86
 
71
87
  <div
72
- className={cn(
73
- "flex min-w-0 flex-1 flex-col gap-5 pb-2",
74
- showLiveLabel ? "pt-[34px]" : "pt-1"
75
- )}
88
+ className={cn("flex min-w-0 flex-1 flex-col", isCards && "gap-4 pb-2")}
76
89
  >
77
- {children}
90
+ {React.Children.toArray(children).map((child, index) => (
91
+ <div key={index}>
92
+ {!isCards && index > 0 && <Separator />}
93
+ {child}
94
+ </div>
95
+ ))}
78
96
  </div>
79
97
  </div>
80
98
  );
81
99
  }
82
100
 
83
101
  export type HourBandSkeletonProps = {
84
- /** Cards to reserve space for. The design's loading frame shows two. */
102
+ /** Rows to reserve space for. */
85
103
  cardCount?: number;
86
104
  className?: string;
87
105
  };
88
106
 
89
107
  /**
90
- * The mobile timeline's loading state, mirroring `HourBand` exactly — same 52px
91
- * gutter, same hairline, same content gap — so nothing jumps when data lands.
108
+ * The mobile timeline's loading state, mirroring `HourBand` exactly — same
109
+ * gutter, same hairline, same row rhythm — so nothing jumps when data lands.
92
110
  *
93
- * The puck is omitted rather than blocked out: the hour is not known until the
94
- * data arrives, and a grey lozenge on the axis reads as a real, unreadable
95
- * timestamp. The hairline alone still says "this is a time axis".
96
- *
97
- * Card footprints come from the design's own loading frame: a 220px poster with
98
- * an 16px title bar and a 12px meta bar, then a 170px poster with a 15px title.
111
+ * The chip is blocked out at its real size rather than omitted: it is a fixed
112
+ * 58x24 lozenge, so a grey one reads as "a time goes here" without inventing
113
+ * a time. Row footprints mirror `EventCompactRowSkeleton`.
99
114
  */
100
- export function HourBandSkeleton({ cardCount = 2, className }: HourBandSkeletonProps) {
101
- const cards = [
102
- { poster: 220, title: "80%", meta: "50%" },
103
- { poster: 170, title: "70%", meta: null },
104
- ];
105
-
115
+ export function HourBandSkeleton({
116
+ cardCount = 2,
117
+ className,
118
+ }: HourBandSkeletonProps) {
106
119
  return (
107
- <div className={cn("flex gap-6 pt-[18px]", className)} aria-hidden="true">
108
- <div className="relative flex w-[52px] shrink-0 justify-center">
109
- <Separator orientation="vertical" className="h-full" />
120
+ <div className={cn("flex gap-3 pt-2.5", className)} aria-hidden="true">
121
+ <div className={cn("relative shrink-0", TIMELINE_MOBILE_GUTTER_CLASS)}>
122
+ <Skeleton className="h-6 w-[58px] rounded-[7px]" />
123
+ <Separator
124
+ orientation="vertical"
125
+ className="absolute bottom-[-10px] left-1/2 top-[30px] h-auto -translate-x-1/2"
126
+ />
110
127
  </div>
111
128
 
112
- <div className="flex min-w-0 flex-1 flex-col gap-5 pb-2 pt-1">
113
- {Array.from({ length: cardCount }).map((_, index) => {
114
- const card = cards[index % cards.length];
115
- return (
116
- <div key={index} className="flex flex-col gap-2.5">
117
- <Skeleton className="w-full" style={{ height: card.poster }} />
118
- <Skeleton className="h-4" style={{ width: card.title }} />
119
- {card.meta && <Skeleton className="h-3" style={{ width: card.meta }} />}
129
+ <div className="flex min-w-0 flex-1 flex-col">
130
+ {Array.from({ length: cardCount }).map((_, index) => (
131
+ <div key={index}>
132
+ {index > 0 && <Separator />}
133
+ <div className="flex gap-3 py-[11px]">
134
+ <Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[10px]" />
135
+ <div className="min-w-0 flex-1">
136
+ <Skeleton className="h-[14px] w-3/4" />
137
+ <Skeleton className="mt-1.5 h-[11.5px] w-1/2" />
138
+ <Skeleton className="mt-2 h-[22px] w-16 rounded-full" />
139
+ </div>
120
140
  </div>
121
- );
122
- })}
141
+ </div>
142
+ ))}
123
143
  </div>
124
144
  </div>
125
145
  );
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { HubSurface } from "./hub-surface";
3
+ import { SectionHeader } from "./section-header";
4
+
5
+ const meta: Meta<typeof HubSurface> = {
6
+ title: "Menu/HubSurface",
7
+ component: HubSurface,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[390px] bg-[var(--surface-2,oklch(0.97_0.004_60))] p-3">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The surface a hub section sits on below `lg`. On a phone a hub is an app screen, not a page: each section is a white card on the tinted ground, 18px radius, one shadow, no border. From `lg` up the card dissolves entirely and sections sit flat the way the desktop design draws them — one element, two looks, identical markup inside. Hairlines live inside a card, never across the page.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof HubSurface>;
28
+
29
+ export const Default: Story = {
30
+ render: () => (
31
+ <HubSurface>
32
+ <SectionHeader title="Open right now" />
33
+ <p className="text-sm text-muted-foreground">
34
+ Section content, inset by the surface.
35
+ </p>
36
+ </HubSurface>
37
+ ),
38
+ };
39
+
40
+ /** `flush` drops the inset so a rail or a photograph can bleed to the edge. */
41
+ export const Flush: Story = {
42
+ render: () => (
43
+ <HubSurface flush>
44
+ <div className="px-3.5 pt-4">
45
+ <SectionHeader title="Browse by cuisine" />
46
+ </div>
47
+ <div className="flex gap-3 overflow-x-auto px-3.5 pb-4">
48
+ {[0, 1, 2].map((card) => (
49
+ <div key={card} className="h-24 w-40 shrink-0 rounded-xl bg-muted" />
50
+ ))}
51
+ </div>
52
+ </HubSurface>
53
+ ),
54
+ };
55
+
56
+ /** Stacked, at the 12px rhythm the hub uses between sections. */
57
+ export const Stacked: Story = {
58
+ render: () => (
59
+ <div className="flex flex-col gap-3">
60
+ {["Open right now", "One kitchen worth knowing", "Most named"].map((title) => (
61
+ <HubSurface key={title}>
62
+ <SectionHeader title={title} />
63
+ <div className="h-12 rounded-lg bg-muted" />
64
+ </HubSurface>
65
+ ))}
66
+ </div>
67
+ ),
68
+ };
@@ -0,0 +1,40 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * HubSurface — the surface a hub section sits on BELOW `lg`.
5
+ *
6
+ * On a phone a hub is an app screen, not a page: each section is a white card
7
+ * on the tinted ground, 18px radius, one card shadow and no border, with 14px
8
+ * of inset. Hairlines live inside a card, never across the page. From `lg` up
9
+ * the card dissolves — no fill, no radius, no inset — and the sections sit flat
10
+ * the way the desktop design draws them. One element, two looks; the section
11
+ * markup inside is identical at every width.
12
+ *
13
+ * `flush` drops the inset so a section whose content bleeds to the card edge —
14
+ * a snap rail, a full-width photograph — can pad its own header and let the
15
+ * rest run.
16
+ *
17
+ * Generalised out of the events hub's `EventsHubCard`, which had the same job
18
+ * and lived in the app. Nothing here is menu- or event-shaped.
19
+ */
20
+
21
+ export type HubSurfaceProps = {
22
+ flush?: boolean;
23
+ className?: string;
24
+ children: React.ReactNode;
25
+ };
26
+
27
+ export function HubSurface({ flush, className, children }: HubSurfaceProps) {
28
+ return (
29
+ <section
30
+ className={cn(
31
+ "rounded-[18px] border border-border bg-card shadow-none",
32
+ flush ? "overflow-hidden" : "px-3.5 py-4",
33
+ "lg:overflow-visible lg:rounded-none lg:bg-transparent lg:p-0 lg:shadow-none",
34
+ className
35
+ )}
36
+ >
37
+ {children}
38
+ </section>
39
+ );
40
+ }
@@ -0,0 +1,112 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuFamilyCard } from "./menu-family-card";
3
+ import { mockDrinkFamily, mockFamily } from "./menu-mocks";
4
+
5
+ const itemLabels = {
6
+ currency: "lei",
7
+ mentions: (count: number) => `${count} mentions`,
8
+ signature: "SIGNATURE",
9
+ mentioned: "MENTIONED",
10
+ };
11
+ const formatPrice = (amount: number) => String(amount);
12
+
13
+ const labels = {
14
+ venueUnit: "KITCHENS",
15
+ mostNamed: "Most named",
16
+ priceBand: "kitchen price band",
17
+ };
18
+
19
+ const meta: Meta<typeof MenuFamilyCard> = {
20
+ title: "Menu/MenuFamilyCard",
21
+ component: MenuFamilyCard,
22
+ tags: ["autodocs"],
23
+ decorators: [
24
+ (Story) => (
25
+ <div className="w-[300px] bg-background p-4">
26
+ <Story />
27
+ </div>
28
+ ),
29
+ ],
30
+ parameters: {
31
+ docs: {
32
+ description: {
33
+ component:
34
+ "A browsable grouping of venues — a cuisine on the dishes tab, a drink family on the drinks tab. Two halves with two jobs: the dark panel is for scanning a row of four (one number, one name, the groups the family consists of, over a mosaic of its venues), and the white half underneath answers what you would actually order, as the production `MenuItem`. Both halves count VENUES: cuisine exists only on places, so a card counting dishes cannot be built.",
35
+ },
36
+ },
37
+ },
38
+ };
39
+
40
+ export default meta;
41
+ type Story = StoryObj<typeof MenuFamilyCard>;
42
+
43
+ export const Default: Story = {
44
+ args: {
45
+ family: mockFamily,
46
+ labels,
47
+ itemLabels,
48
+ formatPrice,
49
+ href: "/top/italian",
50
+ hue: "oklch(0.55 0.15 40)",
51
+ priceBandLabel: "32–84 lei",
52
+ },
53
+ };
54
+
55
+ /** The drinks tab. Same card, different unit and vocabulary. */
56
+ export const DrinkFamily: Story = {
57
+ args: {
58
+ family: mockDrinkFamily,
59
+ labels: { venueUnit: "BARS", mostNamed: "Most named", priceBand: "bar price band" },
60
+ itemLabels,
61
+ formatPrice,
62
+ href: "/top/baruri",
63
+ hue: "oklch(0.5 0.13 240)",
64
+ priceBandLabel: "18–54 lei",
65
+ },
66
+ };
67
+
68
+ /** A one-venue cuisine: the count is honest, and the mosaic repeats one photo. */
69
+ export const Sparse: Story = {
70
+ args: {
71
+ family: {
72
+ slug: "portuguese",
73
+ name: "Portuguese",
74
+ venue_count: 1,
75
+ chips: [{ slug: "seafood-dish", label: "seafood" }],
76
+ signature: mockFamily.signature,
77
+ },
78
+ labels,
79
+ itemLabels,
80
+ formatPrice,
81
+ href: "/top/restaurante",
82
+ hue: "oklch(0.52 0.13 340)",
83
+ priceBandLabel: "42–88 lei",
84
+ },
85
+ };
86
+
87
+ /** The row of four the card is designed to be scanned in. */
88
+ export const Row: Story = {
89
+ render: () => (
90
+ <div className="grid grid-cols-4 gap-5">
91
+ {[mockFamily, mockDrinkFamily, mockFamily, mockDrinkFamily].map((family, index) => (
92
+ <MenuFamilyCard
93
+ key={index}
94
+ family={family}
95
+ labels={labels}
96
+ itemLabels={itemLabels}
97
+ formatPrice={formatPrice}
98
+ href={`/top/${family.slug}`}
99
+ hue="oklch(0.55 0.15 40)"
100
+ priceBandLabel="32–84 lei"
101
+ />
102
+ ))}
103
+ </div>
104
+ ),
105
+ decorators: [
106
+ (Story) => (
107
+ <div className="w-[1200px] bg-background p-4">
108
+ <Story />
109
+ </div>
110
+ ),
111
+ ],
112
+ };