@bearmenu/ui 0.8.16 → 0.8.18

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 (151) hide show
  1. package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
  2. package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
  3. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  4. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  5. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  6. package/dist/{chunk-ZLPWEHZO.js → chunk-EQYB4Q77.js} +2 -2
  7. package/dist/chunk-LYREUZH6.js +156 -0
  8. package/dist/chunk-PW247TG2.js +81 -0
  9. package/dist/chunk-QY66ME5B.js +207 -0
  10. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  11. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  12. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  13. package/dist/components/alert-dialog.js +1 -1
  14. package/dist/components/alert.d.ts +2 -2
  15. package/dist/components/badge.d.ts +1 -1
  16. package/dist/components/badge.js +1 -1
  17. package/dist/components/button.d.ts +1 -1
  18. package/dist/components/button.js +1 -1
  19. package/dist/components/calendar.js +1 -1
  20. package/dist/components/card.d.ts +2 -2
  21. package/dist/components/carousel.js +1 -1
  22. package/dist/components/coach-marks.d.ts +63 -0
  23. package/dist/components/coach-marks.js +162 -0
  24. package/dist/components/combobox.js +1 -1
  25. package/dist/components/command.d.ts +15 -15
  26. package/dist/components/concept-compare-rail.js +3 -3
  27. package/dist/components/concept-compare.js +3 -3
  28. package/dist/components/concept-convergence.js +2 -2
  29. package/dist/components/doorway-tiles.d.ts +11 -0
  30. package/dist/components/doorway-tiles.js +5 -3
  31. package/dist/components/empty-state.js +1 -1
  32. package/dist/components/event-card.d.ts +22 -4
  33. package/dist/components/event-card.js +95 -50
  34. package/dist/components/event-day-header.d.ts +5 -3
  35. package/dist/components/event-day-header.js +12 -4
  36. package/dist/components/event-detail-panel.js +2 -2
  37. package/dist/components/event-door.d.ts +41 -0
  38. package/dist/components/event-door.js +65 -0
  39. package/dist/components/event-hub-hero.d.ts +10 -2
  40. package/dist/components/event-hub-hero.js +11 -5
  41. package/dist/components/event-index-row.js +1 -1
  42. package/dist/components/event-list-row-wide.js +2 -2
  43. package/dist/components/event-list-row.js +1 -1
  44. package/dist/components/event-tonight-strip.d.ts +50 -0
  45. package/dist/components/event-tonight-strip.js +67 -0
  46. package/dist/components/facet-group.d.ts +8 -1
  47. package/dist/components/facet-group.js +3 -0
  48. package/dist/components/filter-category-row.js +1 -1
  49. package/dist/components/hue-chip.js +1 -1
  50. package/dist/components/item.d.ts +2 -2
  51. package/dist/components/item.js +4 -1
  52. package/dist/components/kitchen-dossier.js +1 -1
  53. package/dist/components/link-button.js +1 -1
  54. package/dist/components/menu-family-block.d.ts +8 -1
  55. package/dist/components/menu-family-block.js +101 -68
  56. package/dist/components/menu-family-card.js +2 -2
  57. package/dist/components/menu-filter-chips.d.ts +12 -3
  58. package/dist/components/menu-filter-chips.js +22 -4
  59. package/dist/components/menu-hub-hero.d.ts +7 -1
  60. package/dist/components/menu-hub-hero.js +2 -1
  61. package/dist/components/menu-index-list.js +1 -1
  62. package/dist/components/menu-item.js +3 -3
  63. package/dist/components/menu-kitchen-list.d.ts +14 -2
  64. package/dist/components/menu-kitchen-list.js +1 -1
  65. package/dist/components/multi-select-combobox.js +2 -2
  66. package/dist/components/pagination.js +1 -1
  67. package/dist/components/photo-ground.d.ts +7 -1
  68. package/dist/components/photo-ground.js +1 -1
  69. package/dist/components/place-card.js +2 -2
  70. package/dist/components/place-details-mobile.js +1 -1
  71. package/dist/components/poster-stack.d.ts +36 -3
  72. package/dist/components/poster-stack.js +49 -18
  73. package/dist/components/searchable-select.js +1 -1
  74. package/dist/components/section-header.d.ts +7 -1
  75. package/dist/components/section-header.js +11 -7
  76. package/dist/components/signature-chip.js +2 -2
  77. package/dist/components/sliding-panels.js +1 -1
  78. package/dist/components/snap-rail.d.ts +2 -2
  79. package/dist/components/snap-rail.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag.d.ts +2 -2
  82. package/dist/components/text.d.ts +3 -3
  83. package/dist/components/timeline-day.d.ts +54 -0
  84. package/dist/components/timeline-day.js +65 -0
  85. package/dist/components/timeline-hour-puck.d.ts +47 -0
  86. package/dist/components/timeline-hour-puck.js +3 -0
  87. package/dist/components/timeline-hour.d.ts +96 -0
  88. package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
  89. package/dist/components/timeline-playhead.js +1 -3
  90. package/dist/components/toggle-group.js +9 -4
  91. package/dist/components/toggle.d.ts +1 -1
  92. package/dist/components/toggle.js +1 -1
  93. package/dist/components/top-ranking-entry.js +3 -3
  94. package/package.json +1 -1
  95. package/src/components/badge.stories.tsx +31 -1
  96. package/src/components/badge.tsx +2 -0
  97. package/src/components/button.stories.tsx +5 -4
  98. package/src/components/button.test.tsx +11 -1
  99. package/src/components/button.tsx +16 -5
  100. package/src/components/coach-marks.stories.tsx +79 -0
  101. package/src/components/coach-marks.test.tsx +140 -0
  102. package/src/components/coach-marks.tsx +252 -0
  103. package/src/components/doorway-tiles.stories.tsx +34 -0
  104. package/src/components/doorway-tiles.test.tsx +27 -0
  105. package/src/components/doorway-tiles.tsx +30 -2
  106. package/src/components/event-card.tsx +70 -13
  107. package/src/components/event-day-header.tsx +19 -7
  108. package/src/components/event-door.stories.tsx +88 -0
  109. package/src/components/event-door.tsx +97 -0
  110. package/src/components/event-hub-hero.tsx +21 -3
  111. package/src/components/event-tonight-strip.stories.tsx +78 -0
  112. package/src/components/event-tonight-strip.test.tsx +59 -0
  113. package/src/components/event-tonight-strip.tsx +134 -0
  114. package/src/components/facet-group.tsx +9 -0
  115. package/src/components/hue-chip.test.tsx +6 -3
  116. package/src/components/hue-chip.tsx +5 -2
  117. package/src/components/item.tsx +4 -1
  118. package/src/components/menu-family-block.stories.tsx +12 -0
  119. package/src/components/menu-family-block.tsx +53 -20
  120. package/src/components/menu-filter-chips.tsx +32 -4
  121. package/src/components/menu-hub-hero.test.tsx +13 -0
  122. package/src/components/menu-hub-hero.tsx +9 -2
  123. package/src/components/menu-kitchen-list.tsx +45 -6
  124. package/src/components/photo-ground.tsx +7 -0
  125. package/src/components/poster-stack.stories.tsx +43 -1
  126. package/src/components/poster-stack.tsx +98 -19
  127. package/src/components/section-header.test.tsx +15 -0
  128. package/src/components/section-header.tsx +26 -12
  129. package/src/components/snap-rail.tsx +6 -1
  130. package/src/components/timeline-day.stories.tsx +201 -0
  131. package/src/components/timeline-day.test.tsx +78 -0
  132. package/src/components/timeline-day.tsx +114 -0
  133. package/src/components/timeline-hour-puck.tsx +139 -0
  134. package/src/components/timeline-hour.tsx +266 -0
  135. package/src/components/timeline-playhead.tsx +1 -1
  136. package/src/components/toggle-group.stories.tsx +37 -1
  137. package/src/components/toggle-group.tsx +11 -6
  138. package/src/components/toggle.tsx +8 -0
  139. package/dist/chunk-NSNEANOQ.js +0 -104
  140. package/dist/chunk-SXQZ7DLH.js +0 -167
  141. package/dist/components/hour-band.d.ts +0 -66
  142. package/dist/components/hour-band.js +0 -81
  143. package/dist/components/hour-group.d.ts +0 -67
  144. package/dist/components/hour-group.js +0 -59
  145. package/dist/components/timeline-band.d.ts +0 -64
  146. package/src/components/hour-band.stories.tsx +0 -182
  147. package/src/components/hour-band.tsx +0 -146
  148. package/src/components/hour-group.stories.tsx +0 -100
  149. package/src/components/hour-group.tsx +0 -124
  150. package/src/components/timeline-band.stories.tsx +0 -206
  151. package/src/components/timeline-band.tsx +0 -247
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { ArrowRight, Lightbulb } from "lucide-react";
6
+ import { cn } from "../lib/utils";
7
+ import { Button } from "./button";
8
+
9
+ /**
10
+ * CoachMarks — a guided tour drawn OVER the real page.
11
+ *
12
+ * Four things, one at a time: the page dims, the current stop's target is cut
13
+ * out of the dimming in a spotlight, its step number sits on the spotlight's
14
+ * corner, and one callout beside the spotlight says what the target is. Only
15
+ * the current stop is marked — a map of every stop's number over the page
16
+ * competes with the page's own numerals and with the callout's "Step N of
17
+ * M"; the way through is Next, Back and the arrow keys. The reader learns
18
+ * the surface by looking at the surface, not at a picture of it; nothing on
19
+ * the page moves or re-renders to be taught.
20
+ *
21
+ * Targets are found by `data-coach-mark="<stop id>"` on the page's own
22
+ * elements, so the page keeps its markup and the tour attaches from outside.
23
+ * The tour is built from the stops whose target is ON THE PAGE when it opens
24
+ * (or when the itinerary changes): a stop with nothing to point at is not
25
+ * counted, so "Step 3 of 5" is always a step the reader will reach. Where
26
+ * the same id is on two elements — a desktop and a phone rendering of one
27
+ * control — the visible one is taken.
28
+ *
29
+ * The consumer owns the words (`labels`, `stops`) and the open state; the
30
+ * tour owns its position, the geometry (8px breathing room, the callout's
31
+ * side, re-measure on scroll and resize) and the keys (Escape closes, the
32
+ * arrows step). `onProgress` reports the current stop for a toolbar label.
33
+ *
34
+ * Rendered into `document.body`: a page wrapper with an opacity or transform
35
+ * transition is a stacking context, and the tour must sit above everything
36
+ * the page and its chrome draw, whatever wraps the consumer.
37
+ */
38
+
39
+ export type CoachMarkStop = {
40
+ /** Matches the target's `data-coach-mark` attribute. */
41
+ id: string;
42
+ title: string;
43
+ body: string;
44
+ };
45
+
46
+ export type CoachMarksLabels = {
47
+ /** "Step 2 of 4". */
48
+ progress: (step: number, total: number) => string;
49
+ back: string;
50
+ next: string;
51
+ /** The last stop's forward action, e.g. "Done". */
52
+ done: string;
53
+ skip: string;
54
+ };
55
+
56
+ export type CoachMarksProps = {
57
+ open: boolean;
58
+ stops: CoachMarkStop[];
59
+ /** Skip, Done, Escape and a tap on the dimmed page all end here. */
60
+ onClose: () => void;
61
+ /**
62
+ * 1-based position, the count of stops the page actually has, and the
63
+ * stop being shown — its id is what a consumer remembers, since the
64
+ * position shifts when a stop's target is missing.
65
+ */
66
+ onProgress?: (step: number, total: number, stop: CoachMarkStop) => void;
67
+ labels: CoachMarksLabels;
68
+ className?: string;
69
+ };
70
+
71
+ type Rect = { top: number; left: number; width: number; height: number };
72
+
73
+ const PAD = 8;
74
+ const CALLOUT_WIDTH = 320;
75
+ const CALLOUT_GAP = 14;
76
+ /** The room a side needs before the callout goes there. */
77
+ const CALLOUT_ROOM = 280;
78
+ const EDGE = 12;
79
+
80
+ /** The step number, an inverted disc on the spotlight's corner: dark on the gold ring. */
81
+ const PIN =
82
+ "pointer-events-none absolute z-[1] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[oklch(0.26_0.03_60)] text-[13px] font-extrabold text-[oklch(0.82_0.15_85)] ring-2 ring-[oklch(0.82_0.15_85)] shadow-[0_2px_8px_oklch(0.16_0.012_55_/_0.45)] transition-[top,left] duration-300 motion-reduce:transition-none";
83
+
84
+ /** The visible element carrying the id — a `display: none` twin has no boxes. */
85
+ function findTarget(id: string): HTMLElement | null {
86
+ if (typeof document === "undefined") return null;
87
+ const candidates = document.querySelectorAll<HTMLElement>(`[data-coach-mark="${id}"]`);
88
+ for (const candidate of candidates) {
89
+ if (candidate.getClientRects().length > 0) return candidate;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function measure(element: HTMLElement): Rect {
95
+ const box = element.getBoundingClientRect();
96
+ return { top: box.top, left: box.left, width: box.width, height: box.height };
97
+ }
98
+
99
+ export function CoachMarks({ open, stops, onClose, onProgress, labels, className }: CoachMarksProps) {
100
+ // The stops the page can show, fixed when the tour opens or the itinerary
101
+ // changes — by its ids, so a consumer rebuilding the array each render does
102
+ // not send the tour back to the first stop.
103
+ const [available, setAvailable] = useState<CoachMarkStop[]>([]);
104
+ const [index, setIndex] = useState(0);
105
+ const [rect, setRect] = useState<Rect | null>(null);
106
+ const [viewport, setViewport] = useState({ width: 0, height: 0 });
107
+ const itinerary = stops.map((stop) => stop.id).join("|");
108
+
109
+ useLayoutEffect(() => {
110
+ if (!open) return;
111
+ const present = stops.filter((stop) => findTarget(stop.id));
112
+ setAvailable(present);
113
+ setIndex(0);
114
+ if (present.length === 0) onClose();
115
+ // `stops` is read whenever its ids change; the words alone do not restart the tour.
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ }, [open, itinerary]);
118
+
119
+ const current = available[index];
120
+ const currentId = current?.id;
121
+
122
+ const remeasure = useCallback(() => {
123
+ const element = currentId ? findTarget(currentId) : null;
124
+ setRect(element ? measure(element) : null);
125
+ setViewport({ width: window.innerWidth, height: window.innerHeight });
126
+ }, [currentId]);
127
+
128
+ // Bring the current target into view, then measure it — again once the
129
+ // smooth scroll has settled.
130
+ useLayoutEffect(() => {
131
+ if (!open || !current) return;
132
+ const element = findTarget(current.id);
133
+ if (!element) {
134
+ // The page changed under the tour: move on, or end when nothing is left.
135
+ const forward = available.findIndex((stop, at) => at > index && findTarget(stop.id));
136
+ if (forward !== -1) setIndex(forward);
137
+ else onClose();
138
+ return;
139
+ }
140
+ element.scrollIntoView({ block: "center", behavior: "smooth", inline: "nearest" });
141
+ remeasure();
142
+ const settle = setTimeout(remeasure, 400);
143
+ return () => clearTimeout(settle);
144
+ }, [open, current, index, available, remeasure, onClose]);
145
+
146
+ useEffect(() => {
147
+ if (!open || !current) return;
148
+ onProgress?.(index + 1, available.length, current);
149
+ }, [open, current, index, available.length, onProgress]);
150
+
151
+ useEffect(() => {
152
+ if (!open) return;
153
+ window.addEventListener("resize", remeasure);
154
+ window.addEventListener("scroll", remeasure, true);
155
+ const onKey = (event: KeyboardEvent) => {
156
+ if (event.key === "Escape") onClose();
157
+ if (event.key === "ArrowRight") setIndex((at) => Math.min(at + 1, available.length - 1));
158
+ if (event.key === "ArrowLeft") setIndex((at) => Math.max(at - 1, 0));
159
+ };
160
+ window.addEventListener("keydown", onKey);
161
+ return () => {
162
+ window.removeEventListener("resize", remeasure);
163
+ window.removeEventListener("scroll", remeasure, true);
164
+ window.removeEventListener("keydown", onKey);
165
+ };
166
+ }, [open, remeasure, onClose, available.length]);
167
+
168
+ if (!open || !current || !rect) return null;
169
+
170
+ const last = index === available.length - 1;
171
+ const spot = {
172
+ top: rect.top - PAD,
173
+ left: rect.left - PAD,
174
+ width: rect.width + PAD * 2,
175
+ height: rect.height + PAD * 2,
176
+ };
177
+ // Below the target when there is room for the callout, else above it; a
178
+ // target taller than the viewport leaves room on neither side, and the
179
+ // callout then sits at the bottom edge, over the target's lower part.
180
+ const roomBelow = viewport.height - (spot.top + spot.height);
181
+ const calloutLeft = Math.min(
182
+ Math.max(spot.left, EDGE),
183
+ Math.max(EDGE, viewport.width - CALLOUT_WIDTH - EDGE)
184
+ );
185
+ const calloutPlace =
186
+ roomBelow > CALLOUT_ROOM
187
+ ? { top: spot.top + spot.height + CALLOUT_GAP, left: calloutLeft }
188
+ : spot.top > CALLOUT_ROOM
189
+ ? { bottom: viewport.height - spot.top + CALLOUT_GAP, left: calloutLeft }
190
+ : { bottom: EDGE, left: calloutLeft };
191
+
192
+ return createPortal(
193
+ <div
194
+ className={cn("fixed inset-0 z-[80]", className)}
195
+ role="dialog"
196
+ aria-modal
197
+ aria-label={current.title}
198
+ >
199
+ {/* The dimming is the spotlight's shadow: one element, cut out where
200
+ the target is, so the target stays fully visible and the rest of the
201
+ page falls back. A tap on the dim closes the tour. */}
202
+ <button
203
+ type="button"
204
+ aria-label={labels.skip}
205
+ onClick={onClose}
206
+ className="absolute inset-0 cursor-default"
207
+ />
208
+ <div
209
+ aria-hidden
210
+ className="pointer-events-none absolute rounded-[18px] shadow-[0_0_0_9999px_oklch(0.16_0.012_55_/_0.55)] ring-2 ring-[oklch(0.82_0.15_85)] transition-[top,left,width,height] duration-300 motion-reduce:transition-none"
211
+ style={spot}
212
+ />
213
+
214
+ {/* The step number on the spotlight's corner; the callout says the rest. */}
215
+ <span aria-hidden className={PIN} style={{ top: rect.top - 2, left: rect.left - 2 }}>
216
+ {index + 1}
217
+ </span>
218
+
219
+ {/* The callout. */}
220
+ <div
221
+ className="absolute z-[2] w-[min(320px,calc(100vw-24px))] rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-xl"
222
+ style={calloutPlace}
223
+ >
224
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-[oklch(0.82_0.15_85)] px-2.5 py-1 text-[10.5px] font-extrabold uppercase tracking-[0.08em] text-[oklch(0.26_0.03_60)]">
225
+ <Lightbulb className="h-3 w-3" aria-hidden />
226
+ {labels.progress(index + 1, available.length)}
227
+ </span>
228
+ <p className="mt-3 text-[15px] font-bold leading-snug">{current.title}</p>
229
+ <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{current.body}</p>
230
+ <div className="mt-4 flex items-center gap-2">
231
+ {index > 0 && (
232
+ <Button type="button" variant="ghost" size="sm" onClick={() => setIndex(index - 1)}>
233
+ {labels.back}
234
+ </Button>
235
+ )}
236
+ <Button
237
+ type="button"
238
+ size="sm"
239
+ onClick={() => (last ? onClose() : setIndex(index + 1))}
240
+ >
241
+ {last ? labels.done : labels.next}
242
+ {!last && <ArrowRight aria-hidden />}
243
+ </Button>
244
+ <Button type="button" variant="link" size="sm" className="ml-auto" onClick={onClose}>
245
+ {labels.skip}
246
+ </Button>
247
+ </div>
248
+ </div>
249
+ </div>,
250
+ document.body
251
+ );
252
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TextSearch } from "lucide-react";
2
3
  import { DoorwayTiles } from "./doorway-tiles";
3
4
  import {
4
5
  mockFamilyTiles,
@@ -58,6 +59,25 @@ export const PolaroidPartial: Story = {
58
59
  args: { treatment: "polaroid", tiles: mockFamilyTiles.slice(0, 5) },
59
60
  };
60
61
 
62
+ /**
63
+ * The closing tile as a door to an ACTION — the menu's search page. No
64
+ * photograph, no initial: the print holds a glyph on the photo ground, and
65
+ * the tile keeps its siblings' silhouette.
66
+ */
67
+ export const PolaroidWithSearchDoor: Story = {
68
+ args: {
69
+ treatment: "polaroid",
70
+ tiles: mockFamilyTiles,
71
+ all: {
72
+ ...mockFamilyTilesAll,
73
+ label: "Search the menu",
74
+ countLabel: "535 dishes · 210 drinks",
75
+ photoUrl: undefined,
76
+ icon: <TextSearch strokeWidth={1.75} aria-hidden />,
77
+ },
78
+ },
79
+ };
80
+
61
81
  /** The occasions: the mood's hue multiplied over a room. */
62
82
  export const Ground: Story = {
63
83
  args: { treatment: "ground", tiles: mockOccasionTiles, all: mockOccasionTilesAll },
@@ -81,3 +101,17 @@ export const GroundWithoutPhotos: Story = {
81
101
  tiles: mockOccasionTiles.map(({ photoUrl: _photoUrl, ...tile }) => tile),
82
102
  },
83
103
  };
104
+
105
+ /** The closing tile as a door to an action on the ground treatment: the glyph where the initial was. */
106
+ export const GroundWithActionDoor: Story = {
107
+ args: {
108
+ treatment: "ground",
109
+ tiles: mockOccasionTiles,
110
+ all: {
111
+ ...mockOccasionTilesAll,
112
+ label: "Search the menu",
113
+ photoUrl: undefined,
114
+ icon: <TextSearch strokeWidth={1.75} aria-hidden />,
115
+ },
116
+ },
117
+ };
@@ -30,6 +30,33 @@ describe("DoorwayTiles", () => {
30
30
  expect(screen.getByText(withoutPhoto.label.charAt(0))).toBeInTheDocument();
31
31
  });
32
32
 
33
+ // A door about an action carries a glyph in the print: no photograph, and
34
+ // not the initial either — that would read as a family starting with "S".
35
+ it("draws the glyph, not the initial or the photo, where a tile has an icon", () => {
36
+ const search = {
37
+ ...mockFamilyTilesAll,
38
+ label: "Search the menu",
39
+ icon: <svg data-testid="glyph" />,
40
+ };
41
+ render(<DoorwayTiles treatment="polaroid" tiles={mockFamilyTiles.slice(0, 1)} all={search} />);
42
+ expect(screen.getByTestId("glyph")).toBeInTheDocument();
43
+ expect(screen.queryByText("S")).not.toBeInTheDocument();
44
+ expect(document.querySelectorAll("img")).toHaveLength(1);
45
+ });
46
+
47
+ it("ground: the glyph takes the initial's place and no photograph is drawn for it", () => {
48
+ const door = {
49
+ ...mockOccasionTilesAll,
50
+ label: "Search the menu",
51
+ icon: <svg data-testid="glyph" />,
52
+ };
53
+ render(<DoorwayTiles treatment="ground" tiles={mockOccasionTiles.slice(0, 1)} all={door} />);
54
+ expect(screen.getByTestId("glyph")).toBeInTheDocument();
55
+ expect(screen.queryByText("S")).not.toBeInTheDocument();
56
+ const withPhoto = mockOccasionTiles.slice(0, 1).filter((t) => t.photoUrl).length;
57
+ expect(document.querySelectorAll("img")).toHaveLength(withPhoto);
58
+ });
59
+
33
60
  it("omits the index tile when no index is given", () => {
34
61
  render(<DoorwayTiles treatment="polaroid" tiles={mockFamilyTiles.slice(0, 3)} />);
35
62
  expect(screen.getAllByRole("link")).toHaveLength(3);
@@ -1,6 +1,6 @@
1
1
  import { cn } from "../lib/utils";
2
2
  import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
3
- import { PhotoGround } from "./photo-ground";
3
+ import { PHOTO_GLYPH_SHADOW, PHOTO_GROUND, PhotoGround } from "./photo-ground";
4
4
 
5
5
  /**
6
6
  * DoorwayTiles — a grid of doors: every class the reader browses by, as
@@ -89,6 +89,17 @@ export type DoorwayTile = {
89
89
  gradient?: string;
90
90
  /** Pre-resolved photograph of the CLASS, from the consumer's own origin. */
91
91
  photoUrl?: string;
92
+ /**
93
+ * A glyph in the photograph's place — for a tile about an ACTION, not a
94
+ * thing. A search door has no class to photograph, and any subject put on
95
+ * it (a table, a lens, a book) reads as one more family; the initial the
96
+ * fallback draws is a family's initial too. So the print holds the glyph
97
+ * instead, on the photo ground with the photo fallback's drop shadow, and
98
+ * the tile keeps its siblings' silhouette. Wins over `photoUrl`.
99
+ *
100
+ * A node rather than a name: the app owns its iconography.
101
+ */
102
+ icon?: React.ReactNode;
92
103
  };
93
104
 
94
105
  export type DoorwayTilesTreatment = "polaroid" | "ground";
@@ -144,6 +155,17 @@ const GROUND_LIFT = "absolute inset-0 z-0 opacity-[0.22]";
144
155
  const GROUND_GLYPH =
145
156
  "absolute inset-0 z-0 flex items-center justify-center font-serif text-[64px] font-semibold leading-none text-white/15";
146
157
 
158
+ /**
159
+ * The action glyph. In the polaroid it fills the print at 44% on the photo
160
+ * ground, white with the fallback family's drop shadow; on the ground
161
+ * treatment it takes the initial's place at the initial's weight.
162
+ */
163
+ const PRINT_ICON =
164
+ `absolute inset-0 flex items-center justify-center ${PHOTO_GROUND} text-white/90 ` +
165
+ `[&_svg]:h-[44%] [&_svg]:w-[44%] [&_svg]:${PHOTO_GLYPH_SHADOW}`;
166
+ const GROUND_ICON =
167
+ "absolute inset-0 z-0 flex items-center justify-center text-white/15 [&_svg]:h-[40%] [&_svg]:w-[40%]";
168
+
147
169
  export function DoorwayTiles({
148
170
  tiles,
149
171
  all,
@@ -167,7 +189,9 @@ export function DoorwayTiles({
167
189
  >
168
190
  {isPolaroid ? (
169
191
  <span aria-hidden className={POLAROID}>
170
- {tile.photoUrl ? (
192
+ {tile.icon ? (
193
+ <span className={PRINT_ICON}>{tile.icon}</span>
194
+ ) : tile.photoUrl ? (
171
195
  /* Not lazy: the grid sits directly under the hero, and these
172
196
  photographs are the first thing the reader looks at. */
173
197
  // eslint-disable-next-line @next/next/no-img-element
@@ -186,6 +210,10 @@ export function DoorwayTiles({
186
210
  />
187
211
  )}
188
212
  </span>
213
+ ) : tile.icon ? (
214
+ <span aria-hidden className={GROUND_ICON}>
215
+ {tile.icon}
216
+ </span>
189
217
  ) : tile.photoUrl ? (
190
218
  <>
191
219
  {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -55,8 +55,22 @@ export type EventCardProps = {
55
55
  * `Coverflow`) — it crops less than `3/4` and is not the same as it.
56
56
  */
57
57
  posterAspect?: "4/3" | "3/4" | "4/5";
58
- /** Larger title — the featured/highlight slot. Never a clamp: the title wraps in full either way. */
59
- emphasis?: boolean;
58
+ /**
59
+ * Larger title — the featured/highlight slot. Never a clamp: the title
60
+ * wraps in full either way. `"lg"` applies it from the `lg` breakpoint
61
+ * only: the timeline's day hero is an ordinary card in the phone column and
62
+ * the lane's feature poster on desktop, one node.
63
+ */
64
+ emphasis?: boolean | "lg";
65
+ /**
66
+ * `card` (default) at every width. `row-lg`: the card below `lg`, and from
67
+ * `lg` the compact row `EventCompactRow` draws — a 64px portrait poster
68
+ * beside the text, overlay pills off, the fee as a chip on the tags row.
69
+ * The timeline demotes an hour's later events to rows on desktop only, and
70
+ * a server render cannot swap components per viewport; this is the one
71
+ * node that paints both.
72
+ */
73
+ density?: "card" | "row-lg";
60
74
  /** Top-left overlay pill (the design's "Busy" hype marker). Include its icon. */
61
75
  badge?: React.ReactNode;
62
76
  /** Bottom-left overlay pill naming the category. Include its icon. */
@@ -77,8 +91,12 @@ export type EventCardProps = {
77
91
  * locale, and "is this live" depends on the app's clock, not the DS's.
78
92
  */
79
93
  liveStatus?: string;
80
- /** Hide the day/time line — the timeline supplies its own hour heading. */
81
- hideWhen?: boolean;
94
+ /**
95
+ * Hide the day/time line — the timeline supplies its own hour heading.
96
+ * `"below-lg"` hides it on the phone only: the desktop lane's hero sits
97
+ * beside whatever hour is passing and has to carry its own start time.
98
+ */
99
+ hideWhen?: boolean | "below-lg";
82
100
  /**
83
101
  * Already over — dims the poster, mutes the title and shows the `endedLabel`
84
102
  * chip. Never row-level opacity, which composites the text below AA. Same
@@ -111,6 +129,7 @@ export const EventCard = memo(function EventCard({
111
129
  formatters,
112
130
  posterAspect = "4/3",
113
131
  emphasis,
132
+ density = "card",
114
133
  badge,
115
134
  tagLabel,
116
135
  tags,
@@ -129,7 +148,10 @@ export const EventCard = memo(function EventCard({
129
148
  }: EventCardProps) {
130
149
  const [imgError, setImgError] = useState(false);
131
150
  const showEnded = isEnded && !!endedLabel;
132
- const time = hideWhen ? "" : formatters.time(event.starts_at);
151
+ // `"below-lg"` still renders the line (hidden by CSS on the phone), so the
152
+ // time is formatted whenever the line can show anywhere.
153
+ const time = hideWhen === true ? "" : formatters.time(event.starts_at);
154
+ const isRowLg = density === "row-lg";
133
155
 
134
156
  const fee = resolveEventEntryFee(event);
135
157
  const showPoster = !!event.poster_url && !imgError;
@@ -146,6 +168,8 @@ export const EventCard = memo(function EventCard({
146
168
  : posterAspect === "4/5"
147
169
  ? "aspect-[4/5]"
148
170
  : "aspect-[4/3]",
171
+ // The compact row's poster from `lg`: 64px, portrait, tighter radius.
172
+ isRowLg && "lg:aspect-[4/5] lg:w-16 lg:shrink-0 lg:rounded-[10px]",
149
173
  )}
150
174
  >
151
175
  {showPoster ? (
@@ -164,8 +188,14 @@ export const EventCard = memo(function EventCard({
164
188
  )}
165
189
 
166
190
  {/* Top overlay row — hype badge left, entry fee right. The empty span
167
- keeps the fee pill right-aligned when there is no badge. */}
168
- <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
191
+ keeps the fee pill right-aligned when there is no badge. Off on a
192
+ 64px row poster, where the fee moves to the tags row. */}
193
+ <div
194
+ className={cn(
195
+ "absolute inset-x-2 top-2 flex items-start justify-between gap-2",
196
+ isRowLg && "lg:hidden",
197
+ )}
198
+ >
169
199
  {badge ? (
170
200
  <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
171
201
  {badge}
@@ -187,7 +217,12 @@ export const EventCard = memo(function EventCard({
187
217
  </div>
188
218
 
189
219
  {tagLabel && (
190
- <div className="absolute inset-x-2 bottom-2 flex gap-1">
220
+ <div
221
+ className={cn(
222
+ "absolute inset-x-2 bottom-2 flex gap-1",
223
+ isRowLg && "lg:hidden",
224
+ )}
225
+ >
191
226
  <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
192
227
  {tagLabel}
193
228
  </span>
@@ -195,6 +230,8 @@ export const EventCard = memo(function EventCard({
195
230
  )}
196
231
  </div>
197
232
 
233
+ {/* The text column — a wrapper so `row-lg` can lay it beside the poster. */}
234
+ <div className={cn("flex min-w-0 flex-col", isRowLg && "lg:flex-1")}>
198
235
  {/* NEVER CLAMPED, in any variant. The title is the one thing a card
199
236
  exists to say, and "Movie Night: Futni m…" says none of it — the
200
237
  truncated form was the reported defect. It wraps to as many lines as
@@ -205,9 +242,11 @@ export const EventCard = memo(function EventCard({
205
242
  <h3
206
243
  className={cn(
207
244
  "mt-2.5 leading-[1.35]",
208
- emphasis
245
+ emphasis === true
209
246
  ? "text-lg font-bold tracking-[-0.02em]"
210
247
  : "text-sm font-semibold",
248
+ emphasis === "lg" && "lg:text-lg lg:font-bold lg:tracking-[-0.02em]",
249
+ isRowLg && "lg:mt-0 lg:text-[13.5px]",
211
250
  isEnded && "text-muted-foreground",
212
251
  )}
213
252
  >
@@ -219,8 +258,13 @@ export const EventCard = memo(function EventCard({
219
258
  <span className="truncate">{venue}</span>
220
259
  </div>
221
260
 
222
- {!hideWhen && (
223
- <div className="mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground">
261
+ {hideWhen !== true && (
262
+ <div
263
+ className={cn(
264
+ "mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground",
265
+ hideWhen === "below-lg" && "hidden lg:flex",
266
+ )}
267
+ >
224
268
  <span>{dayLabel ?? formatters.day(event.starts_at)}</span>
225
269
  {/* An empty time is "hour unknown" (see `EventFormatters.time`): the
226
270
  day stands alone, no dangling separator. Consumers used to have
@@ -244,13 +288,21 @@ export const EventCard = memo(function EventCard({
244
288
  </div>
245
289
  )}
246
290
 
247
- {tags && tags.length > 0 && (
291
+ {/* The row look carries the fee as a chip here, where the compact row
292
+ puts it — the poster is too small for an overlay pill. */}
293
+ {((tags && tags.length > 0) || (isRowLg && fee.kind === "free")) && (
248
294
  <TagGroup className="mt-1.5">
249
- {tags.map((tag) => (
295
+ {tags?.map((tag) => (
250
296
  <Tag key={tag} size="sm">
251
297
  {tag}
252
298
  </Tag>
253
299
  ))}
300
+ {isRowLg && fee.kind === "free" && (
301
+ <Tag size="sm" variant="success" className="hidden font-bold lg:inline-flex">
302
+ <Gift className="h-3 w-3" aria-hidden />
303
+ {labels.free}
304
+ </Tag>
305
+ )}
254
306
  </TagGroup>
255
307
  )}
256
308
 
@@ -268,13 +320,18 @@ export const EventCard = memo(function EventCard({
268
320
  </Tag>
269
321
  </div>
270
322
  )}
323
+ </div>
271
324
  </>
272
325
  );
273
326
 
274
327
  const rootClasses = cn(
275
328
  "flex min-w-0 flex-col text-left",
329
+ // The compact row from `lg`: poster beside text, the row's own padding
330
+ // and negative gutter so the hover wash bleeds like `EventCompactRow`'s.
331
+ isRowLg && "lg:-mx-2.5 lg:flex-row lg:items-start lg:gap-[11px] lg:rounded-lg lg:px-2.5 lg:py-2",
276
332
  (href || onClick) &&
277
333
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
334
+ (href || onClick) && isRowLg && "lg:hover:bg-foreground/5",
278
335
  className,
279
336
  );
280
337
 
@@ -47,10 +47,12 @@ export type EventDayHeaderProps = {
47
47
  */
48
48
  action?: React.ReactNode;
49
49
  /**
50
- * Larger desktop scale: 58px day number, 18px weekday, 12.5px meta,
51
- * against the mobile 40/15/11.5.
50
+ * `sm`: the phone scale (30px day number, 15px weekday, 11.5px meta).
51
+ * `default`: the desktop scale (58 / 18 / 12.5). `responsive`: `sm` below
52
+ * `lg` and `default` from it, in one node — for a server render that
53
+ * cannot know the viewport.
52
54
  */
53
- size?: "sm" | "default";
55
+ size?: "sm" | "default" | "responsive";
54
56
  href?: string;
55
57
  linkComponent?: React.ComponentType<EventDayHeaderLinkProps>;
56
58
  onClick?: () => void;
@@ -70,6 +72,9 @@ export function EventDayHeader({
70
72
  }: EventDayHeaderProps) {
71
73
  const isInteractive = !!href || !!onClick;
72
74
  const large = size === "default";
75
+ // `responsive` is the phone scale with every `default` class as an `lg:`
76
+ // twin — one node for a server-rendered timeline.
77
+ const responsive = size === "responsive";
73
78
 
74
79
  const body = (
75
80
  <>
@@ -78,21 +83,27 @@ export function EventDayHeader({
78
83
  "font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
79
84
  // `sm` is the phone scale: 30px, the design's app day head. 40px
80
85
  // was a page-scale number that took a third of a 390px row.
81
- large ? "text-[58px]" : "text-[30px] leading-[0.86]"
86
+ large ? "text-[58px]" : "text-[30px] leading-[0.86]",
87
+ responsive && "lg:text-[58px] lg:leading-[0.82]"
82
88
  )}
83
89
  >
84
90
  {dayNumber}
85
91
  </div>
86
- <div className={large ? "pb-1" : "pb-0.5"}>
92
+ <div className={cn(large ? "pb-1" : "pb-0.5", responsive && "lg:pb-1")}>
87
93
  <div
88
- className={cn("font-extrabold tracking-[-0.015em]", large ? "text-lg" : "text-[15px]")}
94
+ className={cn(
95
+ "font-extrabold tracking-[-0.015em]",
96
+ large ? "text-lg" : "text-[15px]",
97
+ responsive && "lg:text-lg"
98
+ )}
89
99
  >
90
100
  {weekday}
91
101
  </div>
92
102
  <div
93
103
  className={cn(
94
104
  "mt-0.5 text-muted-foreground",
95
- large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]"
105
+ large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]",
106
+ responsive && "lg:whitespace-nowrap lg:text-[12.5px]"
96
107
  )}
97
108
  >
98
109
  {meta}
@@ -132,6 +143,7 @@ export function EventDayHeader({
132
143
  className={cn(
133
144
  "relative flex w-full items-end gap-3 pb-3 pt-[18px]",
134
145
  large && "gap-[18px] pb-4",
146
+ responsive && "lg:gap-[18px] lg:pb-4",
135
147
  className
136
148
  )}
137
149
  >