@bearmenu/ui 0.8.16 → 0.8.17

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 (87) hide show
  1. package/dist/{chunk-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -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-QY66ME5B.js +207 -0
  7. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  8. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  9. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  10. package/dist/components/alert-dialog.js +1 -1
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/badge.js +1 -1
  13. package/dist/components/button.d.ts +1 -1
  14. package/dist/components/button.js +1 -1
  15. package/dist/components/calendar.js +1 -1
  16. package/dist/components/carousel.js +1 -1
  17. package/dist/components/coach-marks.d.ts +63 -0
  18. package/dist/components/coach-marks.js +162 -0
  19. package/dist/components/combobox.js +1 -1
  20. package/dist/components/concept-compare-rail.js +2 -2
  21. package/dist/components/concept-compare.js +2 -2
  22. package/dist/components/concept-convergence.js +2 -2
  23. package/dist/components/doorway-tiles.d.ts +11 -0
  24. package/dist/components/doorway-tiles.js +5 -3
  25. package/dist/components/empty-state.js +1 -1
  26. package/dist/components/event-detail-panel.js +2 -2
  27. package/dist/components/event-index-row.js +1 -1
  28. package/dist/components/event-list-row-wide.js +2 -2
  29. package/dist/components/event-list-row.js +1 -1
  30. package/dist/components/facet-group.d.ts +8 -1
  31. package/dist/components/facet-group.js +3 -0
  32. package/dist/components/filter-category-row.js +1 -1
  33. package/dist/components/hue-chip.js +1 -1
  34. package/dist/components/item.js +4 -1
  35. package/dist/components/kitchen-dossier.js +1 -1
  36. package/dist/components/link-button.js +1 -1
  37. package/dist/components/menu-family-block.d.ts +8 -1
  38. package/dist/components/menu-family-block.js +101 -68
  39. package/dist/components/menu-family-card.js +2 -2
  40. package/dist/components/menu-filter-chips.d.ts +12 -3
  41. package/dist/components/menu-filter-chips.js +22 -4
  42. package/dist/components/menu-index-list.js +1 -1
  43. package/dist/components/menu-item.js +3 -3
  44. package/dist/components/menu-kitchen-list.d.ts +14 -2
  45. package/dist/components/menu-kitchen-list.js +1 -1
  46. package/dist/components/multi-select-combobox.js +2 -2
  47. package/dist/components/pagination.js +1 -1
  48. package/dist/components/photo-ground.d.ts +7 -1
  49. package/dist/components/photo-ground.js +1 -1
  50. package/dist/components/place-card.js +1 -1
  51. package/dist/components/place-details-mobile.js +1 -1
  52. package/dist/components/searchable-select.js +1 -1
  53. package/dist/components/section-header.d.ts +7 -1
  54. package/dist/components/section-header.js +11 -7
  55. package/dist/components/signature-chip.js +2 -2
  56. package/dist/components/sliding-panels.js +1 -1
  57. package/dist/components/toggle-group.js +9 -4
  58. package/dist/components/toggle.d.ts +1 -1
  59. package/dist/components/toggle.js +1 -1
  60. package/dist/components/top-ranking-entry.js +3 -3
  61. package/package.json +1 -1
  62. package/src/components/badge.stories.tsx +31 -1
  63. package/src/components/badge.tsx +2 -0
  64. package/src/components/button.stories.tsx +5 -4
  65. package/src/components/button.test.tsx +11 -1
  66. package/src/components/button.tsx +16 -5
  67. package/src/components/coach-marks.stories.tsx +79 -0
  68. package/src/components/coach-marks.test.tsx +140 -0
  69. package/src/components/coach-marks.tsx +252 -0
  70. package/src/components/doorway-tiles.stories.tsx +34 -0
  71. package/src/components/doorway-tiles.test.tsx +27 -0
  72. package/src/components/doorway-tiles.tsx +30 -2
  73. package/src/components/facet-group.tsx +9 -0
  74. package/src/components/hue-chip.test.tsx +6 -3
  75. package/src/components/hue-chip.tsx +5 -2
  76. package/src/components/item.tsx +4 -1
  77. package/src/components/menu-family-block.stories.tsx +12 -0
  78. package/src/components/menu-family-block.tsx +53 -20
  79. package/src/components/menu-filter-chips.tsx +32 -4
  80. package/src/components/menu-kitchen-list.tsx +45 -6
  81. package/src/components/photo-ground.tsx +7 -0
  82. package/src/components/section-header.test.tsx +15 -0
  83. package/src/components/section-header.tsx +26 -12
  84. package/src/components/toggle-group.stories.tsx +37 -1
  85. package/src/components/toggle-group.tsx +11 -6
  86. package/src/components/toggle.tsx +8 -0
  87. package/dist/chunk-SXQZ7DLH.js +0 -167
@@ -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 */}
@@ -118,6 +118,13 @@ export type FacetGroupProps = Omit<
118
118
  /** e.g. "Show all". Omit to hide the trailing action. */
119
119
  moreLabel?: string;
120
120
  onMore?: () => void;
121
+ /**
122
+ * Whether the trailing action has already expanded the options it controls
123
+ * — set it when `onMore` reveals more rows in place (and `moreLabel` reads
124
+ * "Show fewer"), so the button carries `aria-expanded`. Leave it unset when
125
+ * the action opens another surface.
126
+ */
127
+ moreExpanded?: boolean;
121
128
  className?: string;
122
129
  };
123
130
 
@@ -133,6 +140,7 @@ export function FacetGroup({
133
140
  onToggle,
134
141
  moreLabel,
135
142
  onMore,
143
+ moreExpanded,
136
144
  className,
137
145
  ...rest
138
146
  }: FacetGroupProps) {
@@ -304,6 +312,7 @@ export function FacetGroup({
304
312
  <button
305
313
  type="button"
306
314
  onClick={onMore}
315
+ aria-expanded={moreExpanded}
307
316
  className="cursor-pointer pt-[11px] text-[12.5px] font-bold text-primary transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-sm"
308
317
  >
309
318
  {moreLabel}
@@ -31,9 +31,12 @@ describe("hueTint", () => {
31
31
  });
32
32
 
33
33
  describe("HueChip", () => {
34
+ // The label sits in an inner span that ellipsises; the chip is its parent.
35
+ const chipOf = (text: string) => screen.getByText(text).parentElement!;
36
+
34
37
  it("wears the tint when it has a hue", () => {
35
38
  render(<HueChip hue="oklch(0.62 0.14 82)">Sweet Pastry</HueChip>);
36
- const chip = screen.getByText("Sweet Pastry");
39
+ const chip = chipOf("Sweet Pastry");
37
40
  expect(chip.getAttribute("style")).toContain("oklab");
38
41
  expect(chip.className).not.toContain("bg-muted");
39
42
  });
@@ -41,7 +44,7 @@ describe("HueChip", () => {
41
44
  it("falls back to the neutral rather than to a colour", () => {
42
45
  // A facet with no family behind it must not borrow another family's colour.
43
46
  render(<HueChip>Fast Food</HueChip>);
44
- const chip = screen.getByText("Fast Food");
47
+ const chip = chipOf("Fast Food");
45
48
  expect(chip.className).toContain("bg-muted");
46
49
  expect(chip.getAttribute("style")).toBeNull();
47
50
  });
@@ -52,7 +55,7 @@ describe("HueChip", () => {
52
55
  <HueChip as="li">Salad</HueChip>
53
56
  </ul>
54
57
  );
55
- expect(screen.getByText("Salad").tagName).toBe("LI");
58
+ expect(chipOf("Salad").tagName).toBe("LI");
56
59
  });
57
60
 
58
61
  it("takes an accessible name for a chip whose text is a bare figure", () => {
@@ -86,14 +86,17 @@ export function HueChip({
86
86
  <Tag
87
87
  aria-label={ariaLabel}
88
88
  className={cn(
89
- "inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-foreground",
89
+ // One line, and never wider than its row: a chip that wraps grows past
90
+ // its 28px and paints over its neighbours; a chip that overflows pushes
91
+ // the row. The label ellipsises instead.
92
+ "inline-flex h-7 min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-[11.5px] text-foreground",
90
93
  !hue && "bg-muted",
91
94
  className
92
95
  )}
93
96
  style={hue ? { backgroundColor: hueTint(hue, tone, base) } : undefined}
94
97
  >
95
98
  {icon}
96
- {children}
99
+ <span className="min-w-0 truncate">{children}</span>
97
100
  </Tag>
98
101
  );
99
102
  }
@@ -20,7 +20,10 @@ const itemVariants = cva(
20
20
  lg: "px-5 py-4",
21
21
  },
22
22
  interactive: {
23
- true: "cursor-pointer hover:bg-accent hover:text-accent-foreground active:scale-[0.99]",
23
+ // The surface tint a hover is, not the brand fill: `bg-accent` here is
24
+ // the brown, and a row that inverts under the cursor loses its muted
25
+ // description. Same step the buttons take (`--accent-subtle`).
26
+ true: "cursor-pointer hover:bg-accent-subtle hover:border-accent/40 active:bg-muted active:scale-[0.99]",
24
27
  false: "",
25
28
  },
26
29
  },
@@ -61,6 +61,18 @@ export const Default: Story = {
61
61
  args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
62
62
  };
63
63
 
64
+ /** The phone card at every width — for a desktop rail where the two-column card is too wide to scan. */
65
+ export const Compact: Story = {
66
+ args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS }, variant: "compact" },
67
+ decorators: [
68
+ (Story) => (
69
+ <div className="w-[360px] bg-background p-4">
70
+ <Story />
71
+ </div>
72
+ ),
73
+ ],
74
+ };
75
+
64
76
  /**
65
77
  * No photographs: the mosaic ground with the family's initial, never a grey box.
66
78
  * A missing photograph is not a broken image, and a placeholder makes a real
@@ -99,6 +99,13 @@ export type MenuFamilyBlockProps = {
99
99
  hue?: string;
100
100
  /** Finished CSS gradient — the family's colour over the mosaic. */
101
101
  gradient?: string;
102
+ /**
103
+ * `wide` (default): the phone card below `lg`, the two-column card — photo
104
+ * third, rows beside it — from `lg`. `compact`: the phone card at every
105
+ * width, for a rail on a desktop page where the two-column card is too much
106
+ * to scan across (the browse page's concept rail).
107
+ */
108
+ variant?: "wide" | "compact";
102
109
  linkComponent?: MenuLinkComponent;
103
110
  className?: string;
104
111
  };
@@ -108,17 +115,20 @@ const GROUND = PHOTO_GROUND;
108
115
 
109
116
  /**
110
117
  * The ink. Nearly absent at the top, heavier at the foot on the phone — the
111
- * top 40% is the photograph, the rows sit on 0.92+; a to-right ramp from
112
- * `lg`, where the row column brings its own opaque paper anyway. It is the
113
- * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
114
- * lives in the app's stylesheet, not this package's, so a DS component reading
115
- * it renders nothing in Storybook and the other three consumers.
118
+ * top 40% is the photograph, the rows sit on 0.92+. It is the layer the
119
+ * contrast rests on. Literal rather than `var(--scrim)`: that token lives in
120
+ * the app's stylesheet, not this package's, so a DS component reading it
121
+ * renders nothing in Storybook and the other three consumers.
116
122
  */
117
- const INK =
118
- "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
119
-
120
- /** The row column's paper from `lg`: the card's dark, opaque. */
121
- const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
123
+ const INK_PHONE =
124
+ "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]";
125
+ /**
126
+ * From `lg` the ramp runs to the right: clear over the photo third, dark
127
+ * under the rows — the rows sit on the ink itself, no opaque paper, so the
128
+ * photograph still reads through the ramp's shoulder.
129
+ */
130
+ const INK_WIDE =
131
+ "lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.32)_0%,oklch(0.16_0.012_55_/_0.62)_34%,oklch(0.16_0.012_55_/_0.9)_52%,oklch(0.16_0.012_55_/_0.96)_100%)]";
122
132
 
123
133
  /**
124
134
  * A second, LOCAL scrim under the name, count and action — the photo column's
@@ -144,9 +154,11 @@ export function MenuFamilyBlock({
144
154
  formatCount = String,
145
155
  hue,
146
156
  gradient,
157
+ variant = "wide",
147
158
  linkComponent: LinkComponent = DefaultLink,
148
159
  className,
149
160
  }: MenuFamilyBlockProps) {
161
+ const wide = variant === "wide";
150
162
  const [photo] = family.photoUrls ?? [];
151
163
  // "+N more" counts past the rows shown, which are the venues the reader can
152
164
  // see — the photograph is one of them, not a venue on its own.
@@ -161,7 +173,9 @@ export function MenuFamilyBlock({
161
173
  className={cn(
162
174
  /* `isolate` confines the blend and the backdrop-filter to this card.
163
175
  `dark` scopes every token inside to its dark-theme value. */
164
- "dark relative isolate overflow-hidden rounded-[14px] text-foreground",
176
+ /* A flex column, so a `h-full` card in a rail fills its row: siblings
177
+ with two rows and with three end on the same baseline. */
178
+ "dark relative isolate flex flex-col overflow-hidden rounded-[14px] text-foreground",
165
179
  GROUND,
166
180
  className
167
181
  )}
@@ -171,21 +185,32 @@ export function MenuFamilyBlock({
171
185
  <PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
172
186
 
173
187
  {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
174
- <span aria-hidden className={cn("absolute inset-0", INK)} />
188
+ <span aria-hidden className={cn("absolute inset-0", INK_PHONE, wide && INK_WIDE)} />
175
189
 
176
190
  {/* ── The content, floating ───────────────────────────────────────── */}
177
- <div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
191
+ <div
192
+ className={cn(
193
+ "relative grid flex-1 grid-cols-1 grid-rows-[auto_1fr]",
194
+ wide && "lg:grid-cols-[34%_1fr] lg:grid-rows-1"
195
+ )}
196
+ >
178
197
  {/* The photo column. On the phone it is the card's head, with the
179
198
  overflow pill pinned to the corner and the photograph clear above
180
199
  the name; on desktop the left third, its stack anchored to the foot. */}
181
200
  <div
182
201
  className={cn(
183
- "relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
202
+ "relative flex min-h-[220px] flex-col justify-end p-3.5",
203
+ wide && "lg:min-h-[320px] lg:p-5",
184
204
  TEXT_SCRIM
185
205
  )}
186
206
  >
187
207
  {pill && (
188
- <span className="absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:static lg:mb-3 lg:self-start">
208
+ <span
209
+ className={cn(
210
+ "absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white",
211
+ wide && "lg:static lg:mb-3 lg:self-start"
212
+ )}
213
+ >
189
214
  {pill}
190
215
  </span>
191
216
  )}
@@ -195,7 +220,12 @@ export function MenuFamilyBlock({
195
220
  {family.kicker}
196
221
  </span>
197
222
  )}
198
- <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
223
+ <h3
224
+ className={cn(
225
+ "text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white",
226
+ wide && "lg:text-[30px]"
227
+ )}
228
+ >
199
229
  {family.name}
200
230
  </h3>
201
231
  {/* The count, the facts and the band on one line: a few facts about
@@ -230,7 +260,7 @@ export function MenuFamilyBlock({
230
260
  aria-label={labels.browse}
231
261
  className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
232
262
  >
233
- {labels.browseShort ? (
263
+ {labels.browseShort && wide ? (
234
264
  <>
235
265
  <span className="lg:hidden">{labels.browse}</span>
236
266
  <span className="hidden lg:inline">{labels.browseShort}</span>
@@ -241,10 +271,10 @@ export function MenuFamilyBlock({
241
271
  </LinkComponent>
242
272
  </div>
243
273
 
244
- {/* The row column: chips, then the rows, on paper from `lg`. */}
245
- <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
274
+ {/* The row column: chips, then the rows, on the ink's dark shoulder. */}
275
+ <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5", wide && "lg:p-5")}>
246
276
  {family.chips && family.chips.length > 0 && (
247
- <ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
277
+ <ul className={cn("flex flex-wrap items-center gap-1.5", wide && "lg:justify-end")}>
248
278
  {family.chips.map((chip) => (
249
279
  /* Label only. The counts are VENUES beside a dish group's name,
250
280
  which every reader takes for dishes. */
@@ -261,6 +291,9 @@ export function MenuFamilyBlock({
261
291
  <MenuKitchenList
262
292
  kitchens={venues}
263
293
  labels={labels.kitchens}
294
+ /* A half-width column: the dish goes under the venue, not
295
+ beside it. The compact card is the phone row throughout. */
296
+ layout={wide ? "stacked" : "phone"}
264
297
  linkComponent={LinkComponent}
265
298
  />
266
299
  </>