@bearmenu/ui 0.8.11 → 0.8.13

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 (91) hide show
  1. package/dist/{chunk-24RFI4RQ.js → chunk-3NMG3KNA.js} +1 -1
  2. package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
  3. package/dist/{chunk-DTXNDLSE.js → chunk-FKNBZF5T.js} +15 -0
  4. package/dist/{chunk-H3ZZDX5L.js → chunk-P7XTW2CG.js} +2 -2
  5. package/dist/{chunk-JS3EINZD.js → chunk-VAXOQS7W.js} +12 -0
  6. package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
  7. package/dist/components/alert-dialog.js +1 -1
  8. package/dist/components/alert.d.ts +2 -2
  9. package/dist/components/avatar.d.ts +3 -3
  10. package/dist/components/badge.d.ts +1 -1
  11. package/dist/components/button.d.ts +2 -2
  12. package/dist/components/button.js +1 -1
  13. package/dist/components/calendar.js +1 -1
  14. package/dist/components/card.d.ts +2 -2
  15. package/dist/components/carousel.js +1 -1
  16. package/dist/components/combobox.js +1 -1
  17. package/dist/components/command.d.ts +15 -15
  18. package/dist/components/concept-compare-rail.js +3 -3
  19. package/dist/components/concept-compare.d.ts +1 -1
  20. package/dist/components/concept-compare.js +29 -8
  21. package/dist/components/concept-convergence.js +1 -1
  22. package/dist/components/dialog.d.ts +2 -2
  23. package/dist/components/empty-state.js +1 -1
  24. package/dist/components/event-detail-panel.js +1 -1
  25. package/dist/components/event-list-row-wide.js +1 -1
  26. package/dist/components/haptic-button.js +1 -1
  27. package/dist/components/input-group.d.ts +4 -4
  28. package/dist/components/input.d.ts +2 -2
  29. package/dist/components/item.d.ts +17 -7
  30. package/dist/components/item.js +15 -10
  31. package/dist/components/kbd.d.ts +2 -2
  32. package/dist/components/kitchen-dossier.d.ts +2 -0
  33. package/dist/components/kitchen-dossier.js +25 -18
  34. package/dist/components/link-button.js +1 -1
  35. package/dist/components/menu-family-block.d.ts +15 -9
  36. package/dist/components/menu-family-block.js +63 -60
  37. package/dist/components/menu-family-card.js +1 -1
  38. package/dist/components/menu-family-tiles.d.ts +19 -0
  39. package/dist/components/menu-family-tiles.js +57 -0
  40. package/dist/components/menu-hub-hero.js +8 -6
  41. package/dist/components/menu-index-list.d.ts +31 -12
  42. package/dist/components/menu-index-list.js +68 -31
  43. package/dist/components/menu-intent-hero.d.ts +1 -1
  44. package/dist/components/menu-kitchen-list.js +1 -1
  45. package/dist/components/menu-mocks.d.ts +10 -3
  46. package/dist/components/menu-mocks.js +21 -9
  47. package/dist/components/menu-types.d.ts +33 -10
  48. package/dist/components/multi-select-combobox.js +1 -1
  49. package/dist/components/native-select.d.ts +2 -2
  50. package/dist/components/pagination.js +1 -1
  51. package/dist/components/photo-ground.d.ts +7 -1
  52. package/dist/components/photo-ground.js +1 -1
  53. package/dist/components/searchable-select.js +1 -1
  54. package/dist/components/select.d.ts +1 -1
  55. package/dist/components/sliding-panels.js +1 -1
  56. package/dist/components/snap-rail.d.ts +2 -7
  57. package/dist/components/snap-rail.js +1 -1
  58. package/dist/components/spinner.d.ts +4 -4
  59. package/dist/components/tag.d.ts +4 -4
  60. package/dist/components/text.d.ts +5 -5
  61. package/dist/components/toggle.d.ts +1 -1
  62. package/dist/components/venue-grade-dimensions.js +1 -1
  63. package/dist/components/weekday-theme-banner.d.ts +24 -3
  64. package/dist/components/weekday-theme-banner.js +62 -14
  65. package/package.json +1 -1
  66. package/src/components/button.stories.tsx +25 -2
  67. package/src/components/button.tsx +12 -0
  68. package/src/components/colour-dictionary.stories.tsx +1 -1
  69. package/src/components/concept-compare.tsx +40 -9
  70. package/src/components/item.tsx +69 -45
  71. package/src/components/kitchen-dossier.tsx +45 -24
  72. package/src/components/menu-family-block.tsx +86 -79
  73. package/src/components/menu-family-tiles.stories.tsx +54 -0
  74. package/src/components/menu-family-tiles.test.tsx +30 -0
  75. package/src/components/menu-family-tiles.tsx +132 -0
  76. package/src/components/menu-hub-hero.tsx +13 -7
  77. package/src/components/menu-index-list.stories.tsx +6 -1
  78. package/src/components/menu-index-list.tsx +101 -54
  79. package/src/components/menu-intent-hero.tsx +1 -1
  80. package/src/components/menu-kitchen-list.tsx +4 -1
  81. package/src/components/menu-mocks.ts +32 -9
  82. package/src/components/menu-types.ts +32 -9
  83. package/src/components/photo-ground.tsx +13 -1
  84. package/src/components/snap-rail.tsx +19 -0
  85. package/src/components/venue-grade-dimensions.tsx +9 -2
  86. package/src/components/weekday-theme-banner.stories.tsx +43 -4
  87. package/src/components/weekday-theme-banner.tsx +94 -17
  88. package/dist/components/menu-intent-tiles.d.ts +0 -31
  89. package/dist/components/menu-intent-tiles.js +0 -50
  90. package/src/components/menu-intent-tiles.stories.tsx +0 -54
  91. package/src/components/menu-intent-tiles.tsx +0 -76
@@ -1,5 +1,6 @@
1
1
  import { cn } from '../chunk-FEK5XGZW.js';
2
2
  import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
3
+ import { ChevronRight } from 'lucide-react';
3
4
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
4
5
 
5
6
  function DefaultLink(_a) {
@@ -15,6 +16,7 @@ function WeekdayThemeBanner({
15
16
  weekLabel,
16
17
  action,
17
18
  actionNote,
19
+ compactAction,
18
20
  dayHref,
19
21
  onDayChange,
20
22
  imageUrl,
@@ -27,7 +29,9 @@ function WeekdayThemeBanner({
27
29
  "div",
28
30
  {
29
31
  className: cn(
30
- "relative grid overflow-hidden rounded-[18px] bg-[linear-gradient(120deg,oklch(0.32_0.06_45),oklch(0.24_0.04_40))] text-white lg:grid-cols-[1fr_auto]",
32
+ // A plate needs a ~2.8:1 window to read on a phone; the text alone
33
+ // would give it ~85px.
34
+ "group relative grid overflow-hidden rounded-[18px] bg-[linear-gradient(120deg,oklch(0.32_0.06_45),oklch(0.24_0.04_40))] text-white max-lg:min-h-[128px] lg:grid-cols-[1fr_auto]",
31
35
  className
32
36
  ),
33
37
  children: [
@@ -45,7 +49,7 @@ function WeekdayThemeBanner({
45
49
  "div",
46
50
  {
47
51
  "aria-hidden": true,
48
- className: "absolute inset-0 bg-[linear-gradient(100deg,oklch(0.18_0.03_45_/_0.94)_0%,oklch(0.18_0.03_45_/_0.82)_45%,oklch(0.18_0.03_45_/_0.66)_100%)]"
52
+ className: "absolute inset-0 bg-[linear-gradient(100deg,oklch(0.18_0.03_45_/_0.85)_0%,oklch(0.18_0.03_45_/_0.45)_48%,oklch(0.18_0.03_45_/_0.2)_100%)] lg:bg-[linear-gradient(100deg,oklch(0.18_0.03_45_/_0.94)_0%,oklch(0.18_0.03_45_/_0.82)_45%,oklch(0.18_0.03_45_/_0.66)_100%)]"
49
53
  }
50
54
  )
51
55
  ] }),
@@ -57,18 +61,62 @@ function WeekdayThemeBanner({
57
61
  children: active.code
58
62
  }
59
63
  ),
60
- /* @__PURE__ */ jsxs("div", { className: "relative z-[1] flex flex-col gap-2.5 p-4 lg:gap-3 lg:p-6", children: [
61
- kicker && /* @__PURE__ */ jsxs("span", { className: "inline-flex w-max items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-[10.5px] font-bold tracking-[0.09em]", children: [
62
- kickerIcon,
63
- kicker
64
- ] }),
65
- /* @__PURE__ */ jsx("h3", { className: "font-serif text-[24px] font-semibold tracking-[-0.02em] lg:text-[36px]", children: active.name }),
66
- body && /* @__PURE__ */ jsx("p", { className: "max-w-[520px] text-[13.5px] leading-[1.5] opacity-90", children: body }),
67
- (action || actionNote) && /* @__PURE__ */ jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-3", children: [
68
- action,
69
- actionNote && /* @__PURE__ */ jsx("span", { className: "text-xs opacity-75", children: actionNote })
70
- ] })
71
- ] }),
64
+ compactAction && /* @__PURE__ */ jsx(
65
+ "button",
66
+ {
67
+ type: "button",
68
+ "aria-label": compactAction.label,
69
+ onClick: compactAction.onClick,
70
+ className: "absolute inset-0 z-[2] rounded-[18px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70 lg:hidden",
71
+ children: /* @__PURE__ */ jsx(
72
+ "span",
73
+ {
74
+ "aria-hidden": true,
75
+ className: "absolute right-4 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border border-white/20 bg-[oklch(0.18_0.03_45_/_0.55)] text-white transition-colors [@media(pointer:coarse)]:group-active:bg-[oklch(0.18_0.03_45_/_0.75)]",
76
+ children: /* @__PURE__ */ jsx(ChevronRight, { className: "h-5 w-5" })
77
+ }
78
+ )
79
+ }
80
+ ),
81
+ /* @__PURE__ */ jsxs(
82
+ "div",
83
+ {
84
+ className: cn(
85
+ "relative z-[1] flex flex-col gap-2.5 p-4 lg:gap-3 lg:p-6",
86
+ // On a phone the text keeps to the left 64% — the photograph gets
87
+ // the rest, and the chevron sits in it — and centres in the row.
88
+ imageUrl && "max-lg:max-w-[64%] max-lg:justify-center max-lg:gap-1.5",
89
+ // Without a photograph the text may run wide; keep it off the
90
+ // chevron. With one, the 36% beside the column already holds it.
91
+ compactAction && !imageUrl && "max-lg:pr-16"
92
+ ),
93
+ children: [
94
+ kicker && /* @__PURE__ */ jsxs("span", { className: "inline-flex w-max items-center gap-1.5 text-[10.5px] font-bold tracking-[0.09em] text-white/75 lg:rounded-full lg:bg-white/15 lg:px-2.5 lg:py-1 lg:text-white", children: [
95
+ kickerIcon,
96
+ kicker
97
+ ] }),
98
+ /* @__PURE__ */ jsx("h3", { className: "font-serif text-[22px] font-semibold leading-[1.15] tracking-[-0.02em] lg:text-[36px] lg:leading-tight", children: active.shortHeadline ? /* @__PURE__ */ jsxs(Fragment, { children: [
99
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 lg:hidden", children: active.shortHeadline }),
100
+ /* @__PURE__ */ jsx("span", { className: "hidden lg:block", children: active.name })
101
+ ] }) : active.name }),
102
+ body && /* @__PURE__ */ jsx("p", { className: "hidden max-w-[520px] text-[13.5px] leading-[1.5] opacity-90 lg:block", children: body }),
103
+ (action || actionNote) && /* @__PURE__ */ jsxs(
104
+ "div",
105
+ {
106
+ className: cn(
107
+ "mt-1 flex flex-wrap items-center gap-3",
108
+ // With a compact action the phone has its chevron instead.
109
+ compactAction && "hidden lg:flex"
110
+ ),
111
+ children: [
112
+ action,
113
+ actionNote && /* @__PURE__ */ jsx("span", { className: "text-xs opacity-75", children: actionNote })
114
+ ]
115
+ }
116
+ )
117
+ ]
118
+ }
119
+ ),
72
120
  /* @__PURE__ */ jsxs(
73
121
  "div",
74
122
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Button } from "./button";
3
- import { Heart, Mail, Plus } from "lucide-react";
3
+ import { ArrowRight, Heart, Mail, Plus } from "lucide-react";
4
4
 
5
5
  const meta = {
6
6
  title: "Components/Button",
@@ -19,11 +19,13 @@ const meta = {
19
19
  "glass",
20
20
  "brown",
21
21
  "antagonist",
22
+ "overlay",
23
+ "ink",
22
24
  ],
23
25
  },
24
26
  size: {
25
27
  control: "select",
26
- options: ["default", "sm", "lg", "icon"],
28
+ options: ["default", "sm", "lg", "pill", "icon"],
27
29
  },
28
30
  },
29
31
  } satisfies Meta<typeof Button>;
@@ -59,6 +61,27 @@ export const Glass: Story = {
59
61
  args: { children: "Glass", variant: "glass" },
60
62
  };
61
63
 
64
+ /** The labelled action on a photo surface: glass on the ink, icon trailing. */
65
+ export const Ink: Story = {
66
+ args: {
67
+ children: (
68
+ <>
69
+ Open the menu
70
+ <ArrowRight aria-hidden />
71
+ </>
72
+ ),
73
+ variant: "ink",
74
+ size: "pill",
75
+ },
76
+ decorators: [
77
+ (Story) => (
78
+ <div className="rounded-2xl bg-[oklch(0.21_0.012_55)] p-6">
79
+ <Story />
80
+ </div>
81
+ ),
82
+ ],
83
+ };
84
+
62
85
  export const Brown: Story = {
63
86
  args: { children: "Brown", variant: "brown" },
64
87
  };
@@ -36,6 +36,16 @@ const buttonVariants = cva(
36
36
  */
37
37
  overlay:
38
38
  "bg-black/50 text-white hover:bg-black/65 focus-visible:ring-white/70",
39
+ /**
40
+ * The LABELLED action on a photo surface — "Open the menu", "See all
41
+ * 46 bars", "How we read menus" — where `overlay` is the unlabelled
42
+ * chrome. Glass on the ink: translucent white with a hairline, white
43
+ * type. Same reasoning as `overlay`: not theme-aware, because it
44
+ * contrasts with the photograph, not the canvas. Pair with `size="pill"`.
45
+ * Icons trail, never lead: `ArrowRight` when it navigates to a page,
46
+ * `ChevronRight` when it opens a sheet or drawer.
47
+ */
48
+ ink: "border border-white/20 bg-white/[0.16] text-white hover:bg-white/25 focus-visible:ring-white/70 focus-visible:ring-offset-0",
39
49
  brown:
40
50
  "bg-brown text-brown-foreground shadow-sm hover:bg-brown/85 hover:shadow",
41
51
  antagonist:
@@ -46,6 +56,8 @@ const buttonVariants = cva(
46
56
  sm: "h-9 rounded-lg px-4",
47
57
  default: "h-11 px-6 py-2",
48
58
  lg: "h-12 rounded-2xl px-8",
59
+ /** The `ink` action's shape. Owns the radius so no variant fights it. */
60
+ pill: "h-11 rounded-full px-5 text-[13.5px] font-bold",
49
61
  icon: "h-11 w-11 rounded-full",
50
62
  "icon-xs": "h-8 w-8 rounded-full [&_svg]:size-3.5",
51
63
  "icon-sm": "h-9 w-9 rounded-full",
@@ -209,7 +209,7 @@ function ColourDictionaryPage() {
209
209
  <section>
210
210
  <h2 className="text-[15px] font-extrabold">Where the values live</h2>
211
211
  <p className="mt-2 text-[13px] leading-[1.55] text-foreground/85">
212
- The design system owns the typography, the falloff and the layering (<code>MenuField</code>, <code>MenuFamilyCard</code>, <code>MenuIntentTiles</code>) and takes a finished colour as a prop. The palette lives in the fe app: the eighteen <code>--menu-field-*</code> tokens in <code>app/globals.css</code>, <code>FAMILY_HUES</code> and <code>INTENT_GRADIENTS</code> in <code>hub-gradients.ts</code>, category → family in <code>category-colors.ts</code>. The Storybook mocks carry the same values so post-generator films what the app paints.
212
+ The design system owns the typography, the falloff and the layering (<code>MenuField</code>, <code>MenuFamilyCard</code>, <code>MenuFamilyTiles</code>) and takes a finished colour as a prop. The palette lives in the fe app: the eighteen <code>--menu-field-*</code> tokens in <code>app/globals.css</code>, <code>FAMILY_HUES</code> and <code>INTENT_GRADIENTS</code> in <code>hub-gradients.ts</code>, category → family in <code>category-colors.ts</code>. The Storybook mocks carry the same values so post-generator films what the app paints.
213
213
  </p>
214
214
  </section>
215
215
  </div>
@@ -79,7 +79,7 @@ export type ConceptCompareFigure = {
79
79
  export type ConceptCompareLabels = {
80
80
  /** Small caps kicker, e.g. "DISH CONCEPT". */
81
81
  kind: string;
82
- /** The badge on the lowest-priced room, e.g. "CHEAPEST". */
82
+ /** The badge on the lowest-priced room, e.g. "CHEAPEST" — the fallback when a row carries no `badge`. */
83
83
  cheapest: string;
84
84
  /** The badge on every other room, e.g. "#2". Defaults to `#${rank}`. */
85
85
  rank?: (rank: number) => string;
@@ -277,7 +277,15 @@ export function ConceptCompare({
277
277
  >
278
278
  {rooms.map((room, index) => {
279
279
  const href = hrefs?.[room.place_slug];
280
- const isCheapest = room.price === cheapest;
280
+ // A winner room says what it won; a ranked room says its rank.
281
+ // The CHEAPEST badge — and only it — is green with the arrow: it
282
+ // is the census's signature claim, the one badge a diner scans
283
+ // for. Gated on the badge, not the price: drink concepts cluster
284
+ // on price, and a "most named" room at the minimum must not wear
285
+ // the cheapest's glyph.
286
+ const badge = room.badge ?? (room.price === cheapest ? labels.cheapest : rankOf(index + 1));
287
+ const isCheapest = badge === labels.cheapest;
288
+ const lead = room.lead ?? (isCheapest ? "price" : undefined);
281
289
  const roomClass = cn(
282
290
  /* Transparent on the phone — the card behind it is the room's
283
291
  photograph — with only its scrim and its line. A photograph of
@@ -312,7 +320,7 @@ export function ConceptCompare({
312
320
  )}
313
321
  >
314
322
  {isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
315
- {isCheapest ? labels.cheapest : rankOf(index + 1)}
323
+ {badge}
316
324
  </span>
317
325
 
318
326
  {/* Name and rating on one line: the pin leads, the name takes
@@ -322,11 +330,25 @@ export function ConceptCompare({
322
330
  <b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
323
331
  {room.place_name}
324
332
  </b>
325
- {room.rating !== undefined && (
326
- <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
327
- <Star className="h-3 w-3 shrink-0" aria-hidden />
328
- <span className="tabular-nums">{room.rating}</span>
333
+ {/* The BearMenu grade, in the grade's amber — the number
334
+ the census computed, beside the kitchen it computed it
335
+ for. Google's star only for a kitchen with no grade. */}
336
+ {room.grade ? (
337
+ <span
338
+ className={cn(
339
+ "shrink-0 text-[14px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)]",
340
+ lead === "grade" && "text-[16px]"
341
+ )}
342
+ >
343
+ {room.grade}
329
344
  </span>
345
+ ) : (
346
+ room.rating !== undefined && (
347
+ <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
348
+ <Star className="h-3 w-3 shrink-0" aria-hidden />
349
+ <span className="tabular-nums">{room.rating}</span>
350
+ </span>
351
+ )
330
352
  )}
331
353
  </span>
332
354
  {room.place_area && (
@@ -343,7 +365,11 @@ export function ConceptCompare({
343
365
  is a 0.32-lightness grey that vanishes on inked photography. */}
344
366
  <Separator className="my-2.5 via-white/20" />
345
367
 
346
- <span className="flex items-baseline gap-2">
368
+ {/* The figures: the one the badge is about leads — the badge
369
+ says what the room won, the number is the evidence. Green
370
+ is the cheapest's alone (a dearest room's price in green
371
+ would read as cheap); the other winners lead in full white. */}
372
+ <span className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
347
373
  <b
348
374
  className={cn(
349
375
  "text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
@@ -353,7 +379,12 @@ export function ConceptCompare({
353
379
  {formatPrice(room.price)}
354
380
  </b>
355
381
  {room.mentions !== undefined && room.mentions > 0 && (
356
- <span className="text-[11.5px] text-white/65">
382
+ <span
383
+ className={cn(
384
+ "text-[11.5px]",
385
+ lead === "mentions" ? "font-bold text-white" : "text-white/65"
386
+ )}
387
+ >
357
388
  {labels.mentions(room.mentions)}
358
389
  </span>
359
390
  )}
@@ -52,63 +52,87 @@ const Item = React.forwardRef<HTMLDivElement, ItemProps>(
52
52
  );
53
53
  Item.displayName = "Item";
54
54
 
55
- const ItemMedia = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
56
- ({ className, ...props }, ref) => (
57
- <div
58
- ref={ref}
59
- data-slot="item-media"
60
- className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
61
- {...props}
62
- />
63
- )
55
+ /**
56
+ * The parts take `asChild` too. An `Item asChild` around a `<button>` makes
57
+ * the parts the button's children, and a button may hold only phrasing
58
+ * content — so the parts have to be able to render as `<span>`s there.
59
+ */
60
+ type ItemPartProps = React.HTMLAttributes<HTMLDivElement> & {
61
+ asChild?: boolean;
62
+ };
63
+
64
+ const ItemMedia = React.forwardRef<HTMLDivElement, ItemPartProps>(
65
+ ({ className, asChild, ...props }, ref) => {
66
+ const Comp = asChild ? Slot : "div";
67
+ return (
68
+ <Comp
69
+ ref={ref}
70
+ data-slot="item-media"
71
+ className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
72
+ {...props}
73
+ />
74
+ );
75
+ }
64
76
  );
65
77
  ItemMedia.displayName = "ItemMedia";
66
78
 
67
- const ItemContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
68
- ({ className, ...props }, ref) => (
69
- <div
70
- ref={ref}
71
- data-slot="item-content"
72
- className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
73
- {...props}
74
- />
75
- )
79
+ const ItemContent = React.forwardRef<HTMLDivElement, ItemPartProps>(
80
+ ({ className, asChild, ...props }, ref) => {
81
+ const Comp = asChild ? Slot : "div";
82
+ return (
83
+ <Comp
84
+ ref={ref}
85
+ data-slot="item-content"
86
+ className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
87
+ {...props}
88
+ />
89
+ );
90
+ }
76
91
  );
77
92
  ItemContent.displayName = "ItemContent";
78
93
 
79
- const ItemTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
80
- ({ className, ...props }, ref) => (
81
- <div
82
- ref={ref}
83
- data-slot="item-title"
84
- className={cn("truncate text-sm font-semibold text-foreground", className)}
85
- {...props}
86
- />
87
- )
94
+ const ItemTitle = React.forwardRef<HTMLDivElement, ItemPartProps>(
95
+ ({ className, asChild, ...props }, ref) => {
96
+ const Comp = asChild ? Slot : "div";
97
+ return (
98
+ <Comp
99
+ ref={ref}
100
+ data-slot="item-title"
101
+ className={cn("truncate text-sm font-semibold text-foreground", className)}
102
+ {...props}
103
+ />
104
+ );
105
+ }
88
106
  );
89
107
  ItemTitle.displayName = "ItemTitle";
90
108
 
91
- const ItemDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
92
- ({ className, ...props }, ref) => (
93
- <div
94
- ref={ref}
95
- data-slot="item-description"
96
- className={cn("truncate text-xs text-muted-foreground", className)}
97
- {...props}
98
- />
99
- )
109
+ const ItemDescription = React.forwardRef<HTMLDivElement, ItemPartProps>(
110
+ ({ className, asChild, ...props }, ref) => {
111
+ const Comp = asChild ? Slot : "div";
112
+ return (
113
+ <Comp
114
+ ref={ref}
115
+ data-slot="item-description"
116
+ className={cn("truncate text-xs text-muted-foreground", className)}
117
+ {...props}
118
+ />
119
+ );
120
+ }
100
121
  );
101
122
  ItemDescription.displayName = "ItemDescription";
102
123
 
103
- const ItemActions = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
104
- ({ className, ...props }, ref) => (
105
- <div
106
- ref={ref}
107
- data-slot="item-actions"
108
- className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
109
- {...props}
110
- />
111
- )
124
+ const ItemActions = React.forwardRef<HTMLDivElement, ItemPartProps>(
125
+ ({ className, asChild, ...props }, ref) => {
126
+ const Comp = asChild ? Slot : "div";
127
+ return (
128
+ <Comp
129
+ ref={ref}
130
+ data-slot="item-actions"
131
+ className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
132
+ {...props}
133
+ />
134
+ );
135
+ }
112
136
  );
113
137
  ItemActions.displayName = "ItemActions";
114
138
 
@@ -72,6 +72,8 @@ export type KitchenDossierLabels = {
72
72
  next: string;
73
73
  /** Dimension key → translated label. */
74
74
  dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
75
+ /** Beside the grade in the title row, e.g. "/10". Omit for the bare numeral. */
76
+ gradeUnit?: string;
75
77
  };
76
78
 
77
79
  export type KitchenDossierProps = {
@@ -202,25 +204,18 @@ export function KitchenDossier({
202
204
  .sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
203
205
  .slice(0, DIMENSION_LIMIT)
204
206
  : [];
205
- if (grade && (grade.overall || strongest.length > 0)) {
207
+ // The overall grade leads the title row now, so the part shows what the
208
+ // title cannot: the three strongest dimensions. No numeral repeated.
209
+ if (dimensions && strongest.length > 0) {
206
210
  parts.push(
207
211
  <Part key="grade" kicker={labels.theGrade}>
208
- <div className="flex items-end gap-4">
209
- {grade.overall && (
210
- <span className="text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums text-[oklch(0.82_0.15_85)]">
211
- {grade.overall}
212
- </span>
213
- )}
214
- {dimensions && strongest.length > 0 && (
215
- <VenueGradeDimensions
216
- className="min-w-0 flex-1"
217
- dimensions={dimensions}
218
- labels={labels.dimensions}
219
- order={strongest}
220
- formatScore={formatScore}
221
- />
222
- )}
223
- </div>
212
+ <VenueGradeDimensions
213
+ className="min-w-0"
214
+ dimensions={dimensions}
215
+ labels={labels.dimensions}
216
+ order={strongest}
217
+ formatScore={formatScore}
218
+ />
224
219
  </Part>
225
220
  );
226
221
  }
@@ -281,16 +276,42 @@ export function KitchenDossier({
281
276
  {place.name}
282
277
  </LinkComponent>
283
278
  </h3>
284
- {rating !== undefined && (
285
- <span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
286
- {rating.toFixed(1)}
287
- <Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
279
+ {/* The GRADE leads — the number the rail is named for, in the
280
+ grade's amber. Google's star used to sit here while the
281
+ grade waited in story part three; a card rated by BearMenu
282
+ cannot lead with a number BearMenu did not compute. The
283
+ star drops to the meta line, and the title row only falls
284
+ back to it for a kitchen with no grade. */}
285
+ {grade?.overall ? (
286
+ <span className="inline-flex shrink-0 items-baseline gap-1 text-[22px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)] lg:text-[26px]">
287
+ {grade.overall}
288
+ {labels.gradeUnit && (
289
+ <span className="text-[11px] font-bold text-white/70">{labels.gradeUnit}</span>
290
+ )}
288
291
  </span>
292
+ ) : (
293
+ rating !== undefined && (
294
+ <span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
295
+ {rating.toFixed(1)}
296
+ <Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
297
+ </span>
298
+ )
289
299
  )}
290
300
  </div>
291
- {(meta || labels.reviewCount) && (
292
- <p className="mt-1 truncate text-[12.5px] text-white/70">
293
- {[meta, labels.reviewCount].filter(Boolean).join(" · ")}
301
+ {(meta || labels.reviewCount || (grade?.overall && rating !== undefined)) && (
302
+ <p className="mt-1 flex min-w-0 items-center gap-1 text-[12.5px] text-white/70">
303
+ {(meta || labels.reviewCount) && (
304
+ <span className="truncate">
305
+ {[meta, labels.reviewCount].filter(Boolean).join(" · ")}
306
+ </span>
307
+ )}
308
+ {grade?.overall && rating !== undefined && (
309
+ <span className="inline-flex shrink-0 items-center gap-1 tabular-nums">
310
+ {(meta || labels.reviewCount) && <span aria-hidden>·</span>}
311
+ {rating.toFixed(1)}
312
+ <Star className="h-3 w-3" aria-hidden />
313
+ </span>
314
+ )}
294
315
  </p>
295
316
  )}
296
317
  </div>