@bearmenu/ui 0.8.11 → 0.8.12

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 (86) hide show
  1. package/dist/{chunk-H3ZZDX5L.js → chunk-7A42J73Q.js} +1 -1
  2. package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
  3. package/dist/{chunk-JS3EINZD.js → chunk-VAXOQS7W.js} +12 -0
  4. package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
  5. package/dist/components/alert-dialog.js +1 -1
  6. package/dist/components/alert.d.ts +2 -2
  7. package/dist/components/avatar.d.ts +3 -3
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/button.d.ts +2 -2
  10. package/dist/components/button.js +1 -1
  11. package/dist/components/calendar.js +1 -1
  12. package/dist/components/card.d.ts +4 -4
  13. package/dist/components/carousel.js +1 -1
  14. package/dist/components/combobox.js +1 -1
  15. package/dist/components/command.d.ts +8 -8
  16. package/dist/components/concept-compare-rail.js +2 -2
  17. package/dist/components/concept-compare.d.ts +1 -1
  18. package/dist/components/concept-compare.js +28 -7
  19. package/dist/components/concept-convergence.js +1 -1
  20. package/dist/components/date-input.d.ts +1 -1
  21. package/dist/components/dialog.d.ts +2 -2
  22. package/dist/components/empty-state.js +1 -1
  23. package/dist/components/event-detail-panel.js +1 -1
  24. package/dist/components/event-list-row-wide.js +1 -1
  25. package/dist/components/haptic-button.js +1 -1
  26. package/dist/components/input-group.d.ts +4 -4
  27. package/dist/components/input.d.ts +2 -2
  28. package/dist/components/item.d.ts +19 -9
  29. package/dist/components/item.js +15 -10
  30. package/dist/components/kbd.d.ts +2 -2
  31. package/dist/components/kitchen-dossier.d.ts +2 -0
  32. package/dist/components/kitchen-dossier.js +25 -18
  33. package/dist/components/link-button.js +1 -1
  34. package/dist/components/menu-family-block.d.ts +15 -9
  35. package/dist/components/menu-family-block.js +62 -59
  36. package/dist/components/menu-family-card.js +1 -1
  37. package/dist/components/menu-family-tiles.d.ts +19 -0
  38. package/dist/components/menu-family-tiles.js +57 -0
  39. package/dist/components/menu-hub-hero.js +8 -6
  40. package/dist/components/menu-index-list.d.ts +31 -12
  41. package/dist/components/menu-index-list.js +68 -31
  42. package/dist/components/menu-intent-hero.d.ts +1 -1
  43. package/dist/components/menu-mocks.d.ts +10 -3
  44. package/dist/components/menu-mocks.js +21 -9
  45. package/dist/components/menu-types.d.ts +33 -10
  46. package/dist/components/multi-select-combobox.js +1 -1
  47. package/dist/components/native-select.d.ts +2 -2
  48. package/dist/components/pagination.js +1 -1
  49. package/dist/components/photo-ground.d.ts +7 -1
  50. package/dist/components/photo-ground.js +1 -1
  51. package/dist/components/searchable-select.js +1 -1
  52. package/dist/components/select.d.ts +1 -1
  53. package/dist/components/sliding-panels.js +1 -1
  54. package/dist/components/snap-rail.d.ts +2 -2
  55. package/dist/components/spinner.d.ts +4 -4
  56. package/dist/components/tag.d.ts +4 -4
  57. package/dist/components/text.d.ts +3 -3
  58. package/dist/components/toggle.d.ts +1 -1
  59. package/dist/components/venue-grade-dimensions.js +1 -1
  60. package/dist/components/weekday-theme-banner.d.ts +24 -3
  61. package/dist/components/weekday-theme-banner.js +62 -14
  62. package/package.json +1 -1
  63. package/src/components/button.stories.tsx +25 -2
  64. package/src/components/button.tsx +12 -0
  65. package/src/components/colour-dictionary.stories.tsx +1 -1
  66. package/src/components/concept-compare.tsx +40 -9
  67. package/src/components/item.tsx +69 -45
  68. package/src/components/kitchen-dossier.tsx +45 -24
  69. package/src/components/menu-family-block.tsx +86 -79
  70. package/src/components/menu-family-tiles.stories.tsx +54 -0
  71. package/src/components/menu-family-tiles.test.tsx +30 -0
  72. package/src/components/menu-family-tiles.tsx +132 -0
  73. package/src/components/menu-hub-hero.tsx +13 -7
  74. package/src/components/menu-index-list.stories.tsx +6 -1
  75. package/src/components/menu-index-list.tsx +101 -54
  76. package/src/components/menu-intent-hero.tsx +1 -1
  77. package/src/components/menu-mocks.ts +32 -9
  78. package/src/components/menu-types.ts +32 -9
  79. package/src/components/photo-ground.tsx +13 -1
  80. package/src/components/venue-grade-dimensions.tsx +9 -2
  81. package/src/components/weekday-theme-banner.stories.tsx +43 -4
  82. package/src/components/weekday-theme-banner.tsx +94 -17
  83. package/dist/components/menu-intent-tiles.d.ts +0 -31
  84. package/dist/components/menu-intent-tiles.js +0 -50
  85. package/src/components/menu-intent-tiles.stories.tsx +0 -54
  86. package/src/components/menu-intent-tiles.tsx +0 -76
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { MenuFamilyTiles } from "./menu-family-tiles";
4
+ import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
5
+
6
+ describe("MenuFamilyTiles", () => {
7
+ it("renders one link per family plus the index tile, in order", () => {
8
+ render(<MenuFamilyTiles tiles={mockFamilyTiles} all={mockFamilyTilesAll} />);
9
+ const links = screen.getAllByRole("link");
10
+ expect(links).toHaveLength(mockFamilyTiles.length + 1);
11
+ expect(links[0]).toHaveAttribute("href", mockFamilyTiles[0].href);
12
+ expect(links.at(-1)).toHaveAttribute("href", mockFamilyTilesAll.href);
13
+ expect(links.at(-1)).toHaveTextContent(mockFamilyTilesAll.label);
14
+ });
15
+
16
+ // A family without a photograph keeps the polaroid — the initial fills it —
17
+ // so a row of seven never has one tile with a different silhouette.
18
+ it("draws the initial where a family has no photograph", () => {
19
+ const [withPhoto, withoutPhoto] = [mockFamilyTiles[0], mockFamilyTiles.at(-1)!];
20
+ expect(withoutPhoto.photoUrl).toBeUndefined();
21
+ render(<MenuFamilyTiles tiles={[withPhoto, withoutPhoto]} />);
22
+ expect(document.querySelectorAll("img")).toHaveLength(1);
23
+ expect(screen.getByText(withoutPhoto.label.charAt(0))).toBeInTheDocument();
24
+ });
25
+
26
+ it("omits the index tile when no index is given", () => {
27
+ render(<MenuFamilyTiles tiles={mockFamilyTiles.slice(0, 3)} />);
28
+ expect(screen.getAllByRole("link")).toHaveLength(3);
29
+ });
30
+ });
@@ -0,0 +1,132 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { MenuFamilyTile, MenuLinkComponent, MenuLinkProps } from "./menu-types";
3
+ import { PhotoGround } from "./photo-ground";
4
+
5
+ /**
6
+ * MenuFamilyTiles — the hub's doorways: every family the city browses by, as
7
+ * a grid of coloured tiles, closed by one tile that opens the whole index.
8
+ *
9
+ * ## A tile, not a card
10
+ *
11
+ * The family card this replaces was a full-width mosaic with chips, a price
12
+ * band and a browse pill — a summary of the family, in a rail the reader had
13
+ * to swipe through one family at a time. The tile is the family's NAME, its
14
+ * venue count and one photograph on the family's hue, two to a row, so seven
15
+ * families and the index fit on one phone screen. The reader is choosing a
16
+ * family here, not learning about one; the summary lives on the page the tile
17
+ * opens.
18
+ *
19
+ * ## The photograph is a polaroid, not a ground
20
+ *
21
+ * One square photograph, tilted, running off the tile's top and right edges
22
+ * — the tile's `overflow-hidden` crops it, so it reads as a print dropped on
23
+ * the tile rather than a thumbnail placed in it — with the name and the count
24
+ * in the bottom-left, under the print's lower edge. Not a ground: white type
25
+ * on a photograph needs a scrim heavy enough to hide the photograph, and the
26
+ * hue is what tells seven tiles apart at a glance. The photograph is the
27
+ * FAMILY's — a plate, a room, a glass — served from the consumer's own origin,
28
+ * never a venue's (a venue on a tile about every venue reads as an
29
+ * endorsement). A family with no photograph gets the same tilted square
30
+ * holding `PhotoGround`'s serif initial, so the silhouette never changes.
31
+ *
32
+ * Every measure on the polaroid is a percentage of the tile, so the tile and
33
+ * the design stay in proportion from a 170px phone cell to a 260px desktop
34
+ * one: 82% of the height, 8% above the top, 4% past the right, 8° over —
35
+ * measured off the design frame.
36
+ *
37
+ * The bottom scrim is mandatory. The honey and terrace hues sit around
38
+ * `oklch(0.6…)`, where white at 14.5px is about 3:1 — under the 4.5:1 the
39
+ * label needs. The scrim takes the label's corner down to where it passes on
40
+ * every hue in the dictionary.
41
+ *
42
+ * Server-safe.
43
+ */
44
+
45
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
46
+ return <a href={href} {...props} />;
47
+ }
48
+
49
+ export type MenuFamilyTilesProps = {
50
+ tiles: MenuFamilyTile[];
51
+ /**
52
+ * The closing tile: the index behind the seven, drawn exactly like them —
53
+ * its own hue and photograph, `countLabel` as the caption. Only its place in
54
+ * the grid says it is the index. Omit when there is none.
55
+ */
56
+ all?: MenuFamilyTile;
57
+ linkComponent?: MenuLinkComponent;
58
+ className?: string;
59
+ };
60
+
61
+ /**
62
+ * The tile: the design's 16:10, never shorter than two lines of name plus the
63
+ * count need.
64
+ */
65
+ const TILE =
66
+ "group relative isolate flex aspect-[16/10] min-h-[108px] flex-col justify-end overflow-hidden rounded-2xl bg-muted p-3.5 text-white lg:p-4 " +
67
+ "transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-card-hover)] active:translate-y-0 active:scale-[0.99] motion-reduce:transition-none " +
68
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
69
+
70
+ /**
71
+ * The polaroid. Same literal family as `PHOTO_GROUND` for the shadow, never a
72
+ * black `rgba` — the shadow is the ground the card family already stands on.
73
+ */
74
+ const POLAROID =
75
+ "absolute -right-[4%] -top-[8%] z-0 block aspect-square h-[82%] overflow-hidden rounded-[10%] rotate-[8deg] " +
76
+ "shadow-[0_10px_22px_-6px_oklch(0.16_0.012_55_/_0.65)] transition-transform duration-300 group-hover:rotate-[5deg] motion-reduce:transition-none";
77
+
78
+ const SCRIM =
79
+ "after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-3/5 after:bg-gradient-to-t after:from-[oklch(0.16_0.012_55_/_0.55)] after:to-transparent after:content-['']";
80
+
81
+ export function MenuFamilyTiles({
82
+ tiles,
83
+ all,
84
+ linkComponent: LinkComponent = DefaultLink,
85
+ className,
86
+ }: MenuFamilyTilesProps) {
87
+ const cells = all ? [...tiles, all] : tiles;
88
+
89
+ return (
90
+ <div className={cn("grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5", className)}>
91
+ {cells.map((tile) => (
92
+ <LinkComponent
93
+ key={tile.slug}
94
+ href={tile.href}
95
+ className={cn(TILE, SCRIM)}
96
+ style={tile.gradient ? { backgroundImage: tile.gradient } : undefined}
97
+ >
98
+ <span aria-hidden className={POLAROID}>
99
+ {tile.photoUrl ? (
100
+ /* Not lazy: the grid sits directly under the hero, and these
101
+ photographs are the first thing the reader looks at. */
102
+ // eslint-disable-next-line @next/next/no-img-element
103
+ <img
104
+ src={tile.photoUrl}
105
+ alt=""
106
+ decoding="async"
107
+ className="absolute inset-0 h-full w-full object-cover"
108
+ />
109
+ ) : (
110
+ <PhotoGround
111
+ photos={[undefined]}
112
+ initials={tile.label.charAt(0)}
113
+ mode="stack"
114
+ glyphClassName="text-[44px]"
115
+ />
116
+ )}
117
+ </span>
118
+
119
+ {/* Both lines keep to the left 55%: the polaroid owns the right. */}
120
+ <span className="relative z-[1] line-clamp-2 max-w-[55%] text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em] lg:text-[16px]">
121
+ {tile.label}
122
+ </span>
123
+ {tile.countLabel && (
124
+ <span className="relative z-[1] mt-0.5 max-w-[55%] truncate text-[12px] font-medium leading-[1.35] tabular-nums text-white/80 lg:text-[13px]">
125
+ {tile.countLabel}
126
+ </span>
127
+ )}
128
+ </LinkComponent>
129
+ ))}
130
+ </div>
131
+ );
132
+ }
@@ -158,7 +158,9 @@ export function MenuHubHero({
158
158
  </span>
159
159
  )}
160
160
 
161
- <h1 className="max-w-[640px] font-serif text-[32px] font-semibold leading-[1.04] tracking-[-0.025em] lg:text-[50px]">
161
+ {/* 28px on a phone: 32px on a 358px column is five or six words a
162
+ line, which left "walk in blind." wrapping alone. */}
163
+ <h1 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
162
164
  {headline}
163
165
  </h1>
164
166
 
@@ -169,21 +171,25 @@ export function MenuHubHero({
169
171
  )}
170
172
 
171
173
  {figures && figures.length > 0 && (
172
- // Two by two on the phone: four figures in one row wrapped to three and an orphan.
173
- <div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-3 lg:flex lg:flex-wrap lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
174
+ // One row of four on the phone. It was two by two, which was the
175
+ // taller half of a 500px hero; at 20px the widest figure ("19,090")
176
+ // is ~68px and four of them share a 358px column with room.
177
+ <div className="flex justify-between gap-x-3 pt-2 lg:flex-wrap lg:justify-start lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
174
178
  {figures.map((figure) => (
175
- <div key={figure.label}>
176
- <div className="text-2xl font-black leading-none tracking-[-0.03em] tabular-nums lg:text-[32px]">
179
+ <div key={figure.label} className="min-w-0">
180
+ <div className="text-xl font-black leading-none tracking-[-0.02em] tabular-nums lg:text-[32px] lg:tracking-[-0.03em]">
177
181
  {figure.value}
178
182
  </div>
179
- <div className="mt-[5px] text-xs opacity-[0.72]">{figure.label}</div>
183
+ <div className="mt-1 text-[11px] leading-tight opacity-[0.72] lg:mt-[5px] lg:text-xs">
184
+ {figure.label}
185
+ </div>
180
186
  </div>
181
187
  ))}
182
188
  </div>
183
189
  )}
184
190
 
185
191
  {(proof || action) && (
186
- <div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
192
+ <div className="mt-auto flex flex-wrap items-center gap-3 pt-4 lg:pt-5">
187
193
  {proof && (
188
194
  <span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/[0.14] px-4 py-2 text-[13px]">
189
195
  {proofIcon}
@@ -38,8 +38,13 @@ export const Tail: Story = {
38
38
  args: { entries: mockIndexEntries.slice(-8), title: "The tail" },
39
39
  };
40
40
 
41
- /** Two columns, not one: thirty-eight rows in one column is four screens. */
41
+ /** One column with the expander: twelve rows, then "Show all 38 cuisines". */
42
42
  export const Mobile: Story = {
43
+ args: {
44
+ collapsedCount: 12,
45
+ expandLabels: { showAll: "Show all 38 cuisines", showFewer: "Show fewer" },
46
+ },
47
+ parameters: { viewport: { defaultViewport: "mobile1" } },
43
48
  decorators: [
44
49
  (Story) => (
45
50
  <div className="w-[390px] bg-background p-3">
@@ -1,23 +1,32 @@
1
- import { ChevronRight } from "lucide-react";
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronRight } from "lucide-react";
2
5
  import { cn } from "../lib/utils";
6
+ import { Button } from "./button";
3
7
  import { Separator } from "./separator";
4
8
  import { Skeleton } from "./skeleton";
5
9
  import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-types";
6
10
 
7
11
  /**
8
- * MenuIndexList — the complete set, four across.
12
+ * MenuIndexList — the complete set: one column on a phone, three from `lg`.
9
13
  *
10
14
  * ## This block is why the page can afford to be progressive
11
15
  *
12
16
  * It carries a crawlable link to EVERY destination the city has — all
13
- * thirty-eight cuisines, not the ten the page draws in full below. That makes
14
- * it the page's entire link-equity job, which in turn means the rich blocks
15
- * underneath are free to load ten at a time behind a "show more": nothing a
16
- * crawler needs is hidden behind that control, because it is all up here.
17
+ * thirty-eight cuisines, not the eight the page draws in full above. That
18
+ * makes it the page's entire link-equity job, which in turn means the rich
19
+ * blocks are free to load eight at a time behind a "show more": nothing a
20
+ * crawler needs is hidden behind that control, because it is all here.
21
+ *
22
+ * The phone's own "show all" is CSS, not markup: every row is in the DOM and
23
+ * the ones past `collapsedCount` are hidden below `lg` until the button is
24
+ * pressed. A list that mounted its tail on tap would have taken twenty-six
25
+ * links out of the page for the crawler that never taps.
17
26
  *
18
- * Get this backwards — put the whole set behind the disclosure and only ten
19
- * links in the markup — and the page silently stops linking to twenty-eight
20
- * pages it is supposed to be the index for.
27
+ * Get this backwards — put the whole set behind the disclosure and only eight
28
+ * links in the markup — and the page silently stops linking to thirty pages
29
+ * it is supposed to be the index for.
21
30
  *
22
31
  * ## Ordered by count, and the counts overlap
23
32
  *
@@ -25,6 +34,10 @@ import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-ty
25
34
  * places, because a trattoria is Italian AND pizza AND international. So this
26
35
  * is a ranked list of ways in, never a partition, and no copy near it may total
27
36
  * the numbers or call them a breakdown.
37
+ *
38
+ * A client component for the one piece of state the expander needs; the
39
+ * `linkComponent` a server caller hands in must therefore be a client
40
+ * reference (`ClientLink`), not `next/link` itself.
28
41
  */
29
42
 
30
43
  /** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
@@ -40,8 +53,15 @@ export type MenuIndexListProps = {
40
53
  title: string;
41
54
  /** One line under it, e.g. "Ordered by kitchens — every one has its own page". */
42
55
  subtitle?: string;
43
- /** Locale-aware integer formatter. Defaults to `String`. */
44
- formatCount?: (value: number) => string;
56
+ /**
57
+ * Rows shown on a phone before the expander; the rest are hidden below `lg`
58
+ * until it is pressed. Omit to show every row at every width.
59
+ */
60
+ collapsedCount?: number;
61
+ /** The expander's labels, e.g. "Show all 38 cuisines" / "Show fewer". */
62
+ expandLabels?: { showAll: string; showFewer: string };
63
+ /** The colour mark beside each name, keyed by slug. Falls back to the border colour. */
64
+ hues?: Record<string, string>;
45
65
  linkComponent?: MenuLinkComponent;
46
66
  className?: string;
47
67
  };
@@ -50,10 +70,16 @@ export function MenuIndexList({
50
70
  entries,
51
71
  title,
52
72
  subtitle,
53
- formatCount = String,
73
+ collapsedCount,
74
+ expandLabels,
75
+ hues,
54
76
  linkComponent: LinkComponent = DefaultLink,
55
77
  className,
56
78
  }: MenuIndexListProps) {
79
+ const [expanded, setExpanded] = useState(false);
80
+ const collapsible =
81
+ collapsedCount !== undefined && expandLabels !== undefined && entries.length > collapsedCount;
82
+
57
83
  return (
58
84
  <section
59
85
  className={cn(
@@ -68,55 +94,76 @@ export function MenuIndexList({
68
94
  )}
69
95
  </div>
70
96
 
71
- {/*
72
- Two columns on a phone, four from `lg`. One column at 390px turns
73
- thirty-eight rows into four screens of scrolling for a list whose whole
74
- job is to be scanned; two halves that, and the row still holds a name of
75
- realistic length beside its count.
76
- */}
77
- <ul className="grid grid-cols-2 gap-x-5 lg:grid-cols-4 lg:gap-x-8">
78
- {entries.map((entry) => (
79
- <li key={entry.slug}>
80
- {/* The DS `Separator`, above every row including the first.
81
- It was `border-t first:border-t-0 lg:first:border-t`, which
82
- only exempted the very FIRST entry — so in the two-column phone
83
- layout the top-left row had no rule and the top-right one, its
84
- own neighbour, did. Ruling every row is both uniform and simpler
85
- than restoring the exception per breakpoint. */}
86
- <Separator />
87
- <LinkComponent
88
- href={entry.href}
89
- className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
97
+ {/* One column on a phone — a row holds a name of realistic length, its
98
+ count and the chevron with room to spare, and the expander keeps the
99
+ list to twelve rows until asked. Three from `lg`. */}
100
+ <ul className="grid grid-cols-1 lg:grid-cols-3 lg:gap-x-8">
101
+ {entries.map((entry, index) => {
102
+ const hue = hues?.[entry.slug];
103
+ return (
104
+ <li
105
+ key={entry.slug}
106
+ className={cn(
107
+ collapsible && !expanded && index >= collapsedCount && "max-lg:hidden"
108
+ )}
90
109
  >
91
- <span
92
- aria-hidden
93
- className={cn(
94
- "h-2.5 w-2.5 shrink-0 rounded-[3px]",
95
- !entry.gradient && "bg-border"
96
- )}
97
- style={entry.gradient ? { backgroundImage: entry.gradient } : undefined}
98
- />
99
- <span className="min-w-0 flex-1 truncate text-[13.5px]">{entry.name}</span>
100
- <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">
101
- {formatCount(entry.count)}
102
- </span>
103
- <ChevronRight
104
- className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
105
- aria-hidden
106
- />
107
- </LinkComponent>
108
- </li>
109
- ))}
110
+ {/* The DS `Separator`, above every row including the first, so
111
+ the rule is uniform across columns. */}
112
+ <Separator />
113
+ <LinkComponent
114
+ href={entry.href}
115
+ className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
116
+ >
117
+ {/* The family's mark: a dot in its hue. */}
118
+ <span
119
+ aria-hidden
120
+ className={cn("h-2 w-2 shrink-0 rounded-full", !hue && !entry.gradient && "bg-border")}
121
+ style={
122
+ hue
123
+ ? { backgroundColor: hue }
124
+ : entry.gradient
125
+ ? { backgroundImage: entry.gradient }
126
+ : undefined
127
+ }
128
+ />
129
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">{entry.name}</span>
130
+ <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">
131
+ {entry.countLabel ?? String(entry.count)}
132
+ </span>
133
+ <ChevronRight
134
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
135
+ aria-hidden
136
+ />
137
+ </LinkComponent>
138
+ </li>
139
+ );
140
+ })}
110
141
  </ul>
142
+
143
+ {collapsible && (
144
+ <Button
145
+ variant="outline"
146
+ fullWidth
147
+ className="mt-2 lg:hidden"
148
+ aria-expanded={expanded}
149
+ onClick={() => setExpanded((open) => !open)}
150
+ >
151
+ {expanded ? expandLabels.showFewer : expandLabels.showAll}
152
+ <ChevronDown
153
+ className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-180")}
154
+ aria-hidden
155
+ />
156
+ </Button>
157
+ )}
111
158
  </section>
112
159
  );
113
160
  }
114
161
 
115
162
  // ─── Skeleton ───────────────────────────────────────────────────────────────
116
163
 
117
- /** Two columns on a phone, four from `lg` — the grid the list itself renders. */
164
+ /** One column on a phone, three from `lg` — the grid the list itself renders. */
118
165
  export function MenuIndexListSkeleton({
119
- rows = 24,
166
+ rows = 12,
120
167
  className,
121
168
  }: {
122
169
  rows?: number;
@@ -132,12 +179,12 @@ export function MenuIndexListSkeleton({
132
179
  >
133
180
  <Skeleton className="h-[15px] w-[140px]" />
134
181
  <Skeleton className="mt-1.5 h-3 w-[240px]" />
135
- <ul className="mt-2 grid grid-cols-2 gap-x-5 lg:grid-cols-4 lg:gap-x-8">
182
+ <ul className="mt-2 grid grid-cols-1 lg:grid-cols-3 lg:gap-x-8">
136
183
  {Array.from({ length: rows }, (_, index) => (
137
184
  <li key={index}>
138
185
  <Separator />
139
186
  <div className="flex items-center gap-2.5 py-2.5">
140
- <Skeleton className="h-2.5 w-2.5 rounded-[3px]" />
187
+ <Skeleton className="h-2 w-2 rounded-full" />
141
188
  <Skeleton className="h-3.5 flex-1" />
142
189
  <Skeleton className="h-3 w-6" />
143
190
  </div>
@@ -55,7 +55,7 @@ export type MenuIntentHeroProps = {
55
55
  * Finished CSS gradient for the ground, e.g. `linear-gradient(140deg, …)`.
56
56
  *
57
57
  * A gradient rather than a token name, for the same reason `<MenuField>` and
58
- * `<MenuIntentTiles>` take one: the hue tables are app-owned, and a component
58
+ * `<MenuFamilyTiles>` take one: the hue tables are app-owned, and a component
59
59
  * referencing `var(--h-pizza)` renders an unstyled panel in any consumer that
60
60
  * does not define it. Missing, the panel falls back to the foreground colour
61
61
  * — visibly unstyled rather than white text on white.
@@ -25,7 +25,7 @@ import type {
25
25
  MenuFilterChip,
26
26
  MenuIndexEntry,
27
27
  MenuIntentFigure,
28
- MenuIntentTile,
28
+ MenuFamilyTile,
29
29
  MenuItemPreview,
30
30
  MenuKitchenPreview,
31
31
  MenuLoosenOption,
@@ -372,18 +372,41 @@ export const mockDrinkFamily: MenuFamilyPreview = {
372
372
  venue_photo_urls: [],
373
373
  };
374
374
 
375
- const intentGradient = (hue: number) =>
375
+ const familyGradient = (hue: number) =>
376
376
  `linear-gradient(150deg, oklch(0.52 0.14 ${hue}), oklch(0.43 0.13 ${hue - 4}))`;
377
377
 
378
- export const mockIntentTiles: MenuIntentTile[] = [
379
- { slug: "italian", label: "Italian tonight", caption: "247 kitchens", href: "/top/italian?open=now", gradient: intentGradient(40) },
380
- { slug: "burgeri", label: "Burgers", caption: "229 kitchens", href: "/top/burgeri", gradient: intentGradient(25) },
381
- { slug: "restaurante", label: "Open right now", caption: "by the hour", href: "/top/restaurante?open=now", gradient: intentGradient(60) },
382
- { slug: "brunch", label: "Brunch", caption: "314 kitchens", href: "/top/brunch", gradient: intentGradient(75) },
383
- { slug: "vegan", label: "Vegan", caption: "108 kitchens", href: "/top/vegan", gradient: intentGradient(148) },
384
- { slug: "centru", label: "In Centru", caption: "by neighbourhood", href: "/top/centru", gradient: intentGradient(300) },
378
+ /** A venue photograph stand-in: a tinted room, not a plate. */
379
+ const familyPhoto = (hue: number) =>
380
+ "data:image/svg+xml;utf8," +
381
+ encodeURIComponent(
382
+ `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 28% 60%)"/><stop offset="1" stop-color="hsl(${hue} 22% 26%)"/></linearGradient></defs><rect width="320" height="320" fill="url(#g)"/><circle cx="100" cy="110" r="56" fill="hsl(${hue} 40% 82%)" opacity=".55"/><rect x="170" y="190" width="120" height="90" fill="hsl(${hue} 15% 18%)" opacity=".7"/></svg>`
383
+ );
384
+
385
+ /**
386
+ * The hub's seven cuisines, in the registry's order. The photograph is a
387
+ * venue's, never a plate's; the last one ships without, so the story shows
388
+ * the initial fallback beside six real tiles.
389
+ */
390
+ export const mockFamilyTiles: MenuFamilyTile[] = [
391
+ { slug: "italian", label: "Italian", countLabel: "66", href: "/menu/cuisines/italian", gradient: familyGradient(38), photoUrl: familyPhoto(30) },
392
+ { slug: "romanian", label: "Romanian", countLabel: "64", href: "/menu/cuisines/romanian", gradient: familyGradient(25), photoUrl: familyPhoto(10) },
393
+ { slug: "breakfast-brunch", label: "Breakfast & brunch", countLabel: "64", href: "/menu/cuisines/breakfast-brunch", gradient: familyGradient(82), photoUrl: familyPhoto(45) },
394
+ { slug: "vegetarian", label: "Vegetarian", countLabel: "64", href: "/menu/cuisines/vegetarian", gradient: familyGradient(148), photoUrl: familyPhoto(120) },
395
+ { slug: "pizza", label: "Pizza", countLabel: "63", href: "/menu/cuisines/pizza", gradient: familyGradient(58), photoUrl: familyPhoto(20) },
396
+ { slug: "vegan", label: "Vegan", countLabel: "38", href: "/menu/cuisines/vegan", gradient: familyGradient(135), photoUrl: familyPhoto(100) },
397
+ { slug: "bakery", label: "Bakeries & patisseries", countLabel: "65", href: "/menu/cuisines/bakery", gradient: familyGradient(72) },
385
398
  ];
386
399
 
400
+ /** The closing tile: stone, so it reads as the index and not as an eighth cuisine. */
401
+ export const mockFamilyTilesAll: MenuFamilyTile = {
402
+ slug: "all",
403
+ label: "All 38 cuisines",
404
+ countLabel: "The full index",
405
+ href: "/menu/cuisines",
406
+ gradient: "linear-gradient(150deg, oklch(0.50 0.03 70), oklch(0.40 0.028 64))",
407
+ photoUrl: familyPhoto(60),
408
+ };
409
+
387
410
  /** Formatters the stories pass. Real callers inject locale-aware ones. */
388
411
  export const mockFormatters = {
389
412
  price: (value: number) => `${value} lei`,
@@ -134,6 +134,20 @@ export type MenuConceptRow = {
134
134
  featured?: boolean
135
135
  /** Portion, already formatted with its unit, e.g. "260g". */
136
136
  portion_label?: string
137
+ /**
138
+ * What this room WON — "CHEAPEST", "MOST NAMED", "HIGHEST GRADE",
139
+ * "DEAREST" — already localised. A card whose rooms are four winners rather
140
+ * than four ranks sets one per room; without it the card falls back to
141
+ * "cheapest, then #2, #3, #4".
142
+ */
143
+ badge?: string
144
+ /** Which figure the badge is about; that figure is emphasised. */
145
+ lead?: 'price' | 'mentions' | 'grade'
146
+ /**
147
+ * The venue's BearMenu grade, pre-formatted ("9.2"). Takes the star's
148
+ * place beside the kitchen's name; `rating` (Google's) shows only without it.
149
+ */
150
+ grade?: string
137
151
  }
138
152
 
139
153
  /**
@@ -203,17 +217,21 @@ export type MenuVenueSignature = {
203
217
  }
204
218
 
205
219
  /**
206
- * An intent tile — a family plus a constraint, resolving to a category page.
220
+ * A family tile — a cuisine on the dishes tab, a going-out family on the
221
+ * drinks tab — as the hub's doorway grid draws it: a hue, a name, a venue
222
+ * count and one venue's photograph.
207
223
  *
208
- * Only about thirty-two of the thirty-eight cuisines have a page, so `href` is
209
- * required: a tile that cannot resolve to a real destination is not rendered
210
- * rather than linking nowhere.
224
+ * `href` is required: a tile that cannot resolve to a real destination is not
225
+ * rendered rather than linking nowhere.
211
226
  */
212
- export type MenuIntentTile = {
227
+ export type MenuFamilyTile = {
213
228
  slug: string
214
229
  label: string
215
- /** Supporting line, e.g. "247 kitchens". Already formatted by the caller. */
216
- caption?: string
230
+ /**
231
+ * The venue count, already formatted by the caller — "64", "1,204" — because
232
+ * thousands separators are locale-dependent and the DS owns no locale.
233
+ */
234
+ countLabel?: string
217
235
  href: string
218
236
  /**
219
237
  * Finished CSS gradient for the tile's ground, e.g.
@@ -226,8 +244,8 @@ export type MenuIntentTile = {
226
244
  * — visible and obviously unstyled, rather than white text on white.
227
245
  */
228
246
  gradient?: string
229
- /** Icon node, resolved by the caller the DS does not pick icons for it. */
230
- icon?: ReactNode
247
+ /** Pre-resolved VENUE photograph. Venue photography is real; item photography is not. */
248
+ photoUrl?: string
231
249
  }
232
250
 
233
251
  /**
@@ -351,6 +369,11 @@ export type MenuIndexEntry = {
351
369
  slug: string
352
370
  name: string
353
371
  count: number
372
+ /**
373
+ * `count`, already formatted ("1,204"). The list is a client component and a
374
+ * formatter cannot cross into it from a server caller; a string can.
375
+ */
376
+ countLabel?: string
354
377
  href: string
355
378
  /** Finished CSS gradient for the mark. Falls back to the border colour. */
356
379
  gradient?: string
@@ -62,6 +62,12 @@ export type PhotoGroundProps = {
62
62
  overlayClassName?: string;
63
63
  /** How eagerly the first photograph loads. `stack` grounds are above the fold. */
64
64
  loading?: "lazy" | "eager";
65
+ /**
66
+ * Classes on the fallback initial — its size, when the ground is smaller
67
+ * than a card. The 64px default fills a card cell; a tile's 88px polaroid
68
+ * passes `text-[44px]`.
69
+ */
70
+ glyphClassName?: string;
65
71
  className?: string;
66
72
  };
67
73
 
@@ -76,6 +82,7 @@ export function PhotoGround({
76
82
  overlay,
77
83
  overlayClassName,
78
84
  loading = "lazy",
85
+ glyphClassName,
79
86
  className,
80
87
  }: PhotoGroundProps) {
81
88
  const initialOf = (index: number) =>
@@ -100,7 +107,12 @@ export function PhotoGround({
100
107
  photograph is not a broken image, and drawing one as a placeholder
101
108
  makes a real city look unfinished. */
102
109
  <span className="absolute inset-0 flex items-center justify-center">
103
- <span className="font-serif text-[64px] font-semibold leading-none text-white/15">
110
+ <span
111
+ className={cn(
112
+ "font-serif text-[64px] font-semibold leading-none text-white/15",
113
+ glyphClassName
114
+ )}
115
+ >
104
116
  {initialOf(index)}
105
117
  </span>
106
118
  </span>
@@ -74,12 +74,19 @@ export function VenueGradeDimensions({
74
74
  <dd className="text-[15px] font-extrabold tracking-[-0.02em] tabular-nums">
75
75
  {formatScore(value)}
76
76
  </dd>
77
+ {/* The track is `currentColor` at low alpha rather than `bg-muted`:
78
+ on the dossier's ink `--muted` is within a hair of the ground
79
+ and the empty bar vanished. */}
77
80
  <div
78
81
  aria-hidden
79
- className="relative block h-[3px] overflow-hidden rounded-full bg-muted"
82
+ className="relative block h-[3px] overflow-hidden rounded-full bg-current/15"
80
83
  >
84
+ {/* `bg-[image:…]`, not `bg-[…]`: `--gradient-primary` is a
85
+ linear-gradient, and as a background-COLOR it is an invalid
86
+ declaration the browser drops — the fill rendered at the right
87
+ width with no paint at all. */}
81
88
  <span
82
- className="absolute inset-y-0 left-0 rounded-full bg-[var(--gradient-primary,currentColor)]"
89
+ className="absolute inset-y-0 left-0 rounded-full bg-current bg-[image:var(--gradient-primary)]"
83
90
  style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
84
91
  />
85
92
  </div>