@bearmenu/ui 0.8.45 → 0.8.47

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.
@@ -2,7 +2,7 @@ import { SignatureChip } from './chunk-6PQINSAA.js';
2
2
  import { cn } from './chunk-FEK5XGZW.js';
3
3
  import { __objRest, __spreadValues } from './chunk-MMUBH76A.js';
4
4
  import { memo } from 'react';
5
- import { MessageSquareQuote } from 'lucide-react';
5
+ import { Star, MessageSquareQuote } from 'lucide-react';
6
6
  import { jsxs, jsx } from 'react/jsx-runtime';
7
7
 
8
8
  function DefaultLink(_a) {
@@ -51,6 +51,20 @@ function MentionSignal({
51
51
  }
52
52
  );
53
53
  }
54
+ function ExternalRatingSignal({ label, value }) {
55
+ return /* @__PURE__ */ jsxs(
56
+ "span",
57
+ {
58
+ role: "img",
59
+ "aria-label": label,
60
+ className: "inline-flex shrink-0 items-center gap-0.5 text-[13px] font-semibold text-muted-foreground",
61
+ children: [
62
+ /* @__PURE__ */ jsx(Star, { className: "h-3 w-3 fill-current opacity-70", "aria-hidden": true }),
63
+ /* @__PURE__ */ jsx("span", { className: "tabular-nums", children: value })
64
+ ]
65
+ }
66
+ );
67
+ }
54
68
  var MenuItem = memo(function MenuItem2({
55
69
  item,
56
70
  labels,
@@ -61,6 +75,8 @@ var MenuItem = memo(function MenuItem2({
61
75
  signatureTier = "strong",
62
76
  onSignatureEvidence,
63
77
  onMentions,
78
+ externalRating,
79
+ formatRating = (score) => score.toFixed(1),
64
80
  markGradient,
65
81
  venueName,
66
82
  subtitle,
@@ -70,7 +86,7 @@ var MenuItem = memo(function MenuItem2({
70
86
  disabled = false,
71
87
  className
72
88
  }) {
73
- var _a, _b, _c, _d, _e;
89
+ var _a, _b, _c, _d, _e, _f, _g;
74
90
  const firstEntry = (_a = item.sizes_and_prices) == null ? void 0 : _a[0];
75
91
  const quantity = firstEntry == null ? void 0 : firstEntry.size;
76
92
  const hasQuantity = quantity != null && quantity > 0;
@@ -79,6 +95,7 @@ var MenuItem = memo(function MenuItem2({
79
95
  const maxPrice = allPrices.length > 0 ? Math.max(...allPrices) : null;
80
96
  const isPriceRange = minPrice != null && maxPrice != null && minPrice !== maxPrice;
81
97
  const mentionCount = (_e = (_d = item.mentions) != null ? _d : item.review_count) != null ? _e : 0;
98
+ const ratingText = externalRating != null ? formatRating(externalRating) : null;
82
99
  const isInert = !href && !onOpen;
83
100
  const isActionable = !disabled && !isInert;
84
101
  const handleRowClick = () => {
@@ -183,7 +200,7 @@ var MenuItem = memo(function MenuItem2({
183
200
  children: item.description
184
201
  }
185
202
  ),
186
- (minPrice != null || hasQuantity || mentionCount > 0) && /* @__PURE__ */ jsxs("div", { className: "mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1", children: [
203
+ (minPrice != null || hasQuantity || mentionCount > 0 || ratingText != null) && /* @__PURE__ */ jsxs("div", { className: "mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1", children: [
187
204
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-baseline gap-x-2", children: [
188
205
  minPrice != null && /* @__PURE__ */ jsxs("span", { className: "flex items-baseline gap-1", children: [
189
206
  /* @__PURE__ */ jsx("span", { className: "text-[15px] font-bold tabular-nums text-foreground", children: isPriceRange ? `${formatPrice(minPrice)}\u2013${formatPrice(maxPrice)}` : formatPrice(minPrice) }),
@@ -194,15 +211,24 @@ var MenuItem = memo(function MenuItem2({
194
211
  item.kind === "dish" ? "g" : "ml"
195
212
  ] })
196
213
  ] }),
197
- mentionCount > 0 && /* @__PURE__ */ jsx(
198
- MentionSignal,
199
- {
200
- label: labels.mentions(mentionCount),
201
- count: mentionCount,
202
- onMentions,
203
- formatCount
204
- }
205
- )
214
+ (ratingText != null || mentionCount > 0) && /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-x-3", children: [
215
+ ratingText != null && /* @__PURE__ */ jsx(
216
+ ExternalRatingSignal,
217
+ {
218
+ label: (_g = (_f = labels.externalRating) == null ? void 0 : _f.call(labels, ratingText)) != null ? _g : ratingText,
219
+ value: ratingText
220
+ }
221
+ ),
222
+ mentionCount > 0 && /* @__PURE__ */ jsx(
223
+ MentionSignal,
224
+ {
225
+ label: labels.mentions(mentionCount),
226
+ count: mentionCount,
227
+ onMentions,
228
+ formatCount
229
+ }
230
+ )
231
+ ] })
206
232
  ] })
207
233
  ] })
208
234
  ]
@@ -210,4 +236,4 @@ var MenuItem = memo(function MenuItem2({
210
236
  );
211
237
  });
212
238
 
213
- export { MentionSignal, MenuItem };
239
+ export { ExternalRatingSignal, MentionSignal, MenuItem };
@@ -6,6 +6,13 @@ import * as React from 'react';
6
6
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
7
7
 
8
8
  var HEADING_CENTRE_LG = "lg:top-[30px]";
9
+ var RULED_STACK = "gap-4 pb-2 lg:gap-0 lg:pb-0";
10
+ function ruleFromLg(items) {
11
+ return items.map((child, index) => /* @__PURE__ */ jsxs("div", { children: [
12
+ index > 0 && /* @__PURE__ */ jsx(Separator, { className: "hidden lg:block" }),
13
+ child
14
+ ] }, index));
15
+ }
9
16
  function TimelineHour({
10
17
  time,
11
18
  role = "hour",
@@ -95,20 +102,14 @@ function TimelineHour({
95
102
  {
96
103
  className: cn(
97
104
  "flex min-w-0 flex-col",
98
- isCards && "gap-4 pb-2 lg:grid lg:grid-cols-2 lg:gap-5 lg:pb-0 lg:pt-3.5 xl:grid-cols-3",
105
+ isCards ? "gap-4 pb-2 lg:grid lg:grid-cols-2 lg:gap-5 lg:pb-0 lg:pt-3.5 xl:grid-cols-3" : RULED_STACK,
99
106
  isCards && isHero && "lg:flex lg:pt-0",
100
107
  isUnscheduled && isCards && "pt-3.5"
101
108
  ),
102
- children: isCards ? items : items.map((child, index) => /* @__PURE__ */ jsxs("div", { children: [
103
- index > 0 && /* @__PURE__ */ jsx(Separator, {}),
104
- child
105
- ] }, index))
109
+ children: isCards ? items : ruleFromLg(items)
106
110
  }
107
111
  ),
108
- overflowItems.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-4 pb-2 lg:gap-0 lg:pb-0 lg:pt-2", children: overflowItems.map((child, index) => /* @__PURE__ */ jsxs("div", { children: [
109
- index > 0 && /* @__PURE__ */ jsx(Separator, { className: "hidden lg:block" }),
110
- child
111
- ] }, index)) }),
112
+ overflowItems.length > 0 && /* @__PURE__ */ jsx("div", { className: cn("flex flex-col lg:pt-2", RULED_STACK), children: ruleFromLg(overflowItems) }),
112
113
  note && /* @__PURE__ */ jsx("p", { className: "pt-[9px] text-[11.5px] text-muted-foreground", children: note })
113
114
  ]
114
115
  }
@@ -4,7 +4,7 @@ import * as React from 'react';
4
4
  import { VariantProps } from 'class-variance-authority';
5
5
 
6
6
  declare const badgeVariants: (props?: ({
7
- variant?: "default" | "secondary" | "success" | "warning" | "destructive" | "outline" | "glass" | "overlay" | "brown" | "code" | "onPrimary" | "confirmed" | "pending" | "completed" | "assignee" | "softSuccess" | "softWarning" | "softDestructive" | "softNeutral" | null | undefined;
7
+ variant?: "secondary" | "default" | "success" | "warning" | "destructive" | "outline" | "glass" | "overlay" | "brown" | "code" | "onPrimary" | "confirmed" | "pending" | "completed" | "assignee" | "softSuccess" | "softWarning" | "softDestructive" | "softNeutral" | null | undefined;
8
8
  size?: "status" | "chip" | null | undefined;
9
9
  live?: boolean | null | undefined;
10
10
  } & class_variance_authority_types.ClassProp) | undefined) => string;
@@ -3,8 +3,8 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const buttonVariants: (props?: ({
6
- variant?: "default" | "link" | "secondary" | "warning" | "destructive" | "outline" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "cta" | null | undefined;
7
- size?: "icon" | "default" | "cta" | "xs" | "sm" | "lg" | "pill" | "cta-sm" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
6
+ variant?: "secondary" | "default" | "link" | "warning" | "destructive" | "outline" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "cta" | null | undefined;
7
+ size?: "default" | "icon" | "cta" | "xs" | "sm" | "lg" | "pill" | "cta-sm" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
8
8
  fullWidth?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
@@ -28,7 +28,7 @@ declare const CommandInput: React.ForwardRefExoticComponent<Omit<Omit<Pick<Pick<
28
28
  ref?: React.Ref<HTMLInputElement>;
29
29
  } & {
30
30
  asChild?: boolean;
31
- }, "asChild" | "key" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "value" | "onChange" | "type"> & {
31
+ }, "asChild" | "key" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "onChange" | "value" | "type"> & {
32
32
  value?: string;
33
33
  onValueChange?: (search: string) => void;
34
34
  } & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
@@ -64,7 +64,7 @@ declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
64
64
  ref?: React.Ref<HTMLDivElement>;
65
65
  } & {
66
66
  asChild?: boolean;
67
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "value" | "heading"> & {
67
+ }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "heading" | "value"> & {
68
68
  heading?: React.ReactNode;
69
69
  value?: string;
70
70
  forceMount?: boolean;
@@ -82,7 +82,7 @@ declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
82
82
  ref?: React.Ref<HTMLDivElement>;
83
83
  } & {
84
84
  asChild?: boolean;
85
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "value" | "onSelect" | "disabled"> & {
85
+ }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "onSelect" | "value" | "disabled"> & {
86
86
  disabled?: boolean;
87
87
  onSelect?: (value: string) => void;
88
88
  value?: string;
@@ -57,6 +57,12 @@ type MenuItemLabels = {
57
57
  signature: string;
58
58
  /** Chip text for a `weak` (merely mentioned) item. */
59
59
  mentioned: string;
60
+ /**
61
+ * Fully-worded external rating, e.g. "Rated 4.1 on Vivino" — the accessible
62
+ * name for the star-and-score pair. Receives the FORMATTED score. Falls back
63
+ * to the bare score when omitted.
64
+ */
65
+ externalRating?: (score: string) => string;
60
66
  };
61
67
  type MenuItemProps = {
62
68
  item: MenuItemPreview;
@@ -79,6 +85,17 @@ type MenuItemProps = {
79
85
  onSignatureEvidence?: () => void;
80
86
  /** Opens the reviews behind the mention count. */
81
87
  onMentions?: () => void;
88
+ /**
89
+ * An outside aggregate score for the item (a wine's Vivino rating, a beer's
90
+ * Untappd one), shown as a star and the score beside the mention count.
91
+ *
92
+ * The caller GATES it: those scales run low and a thin vote count is noise,
93
+ * so the app decides whether a score is fit to show and passes nothing when
94
+ * it is not. The DS renders what it is given and applies no floor of its own.
95
+ */
96
+ externalRating?: number;
97
+ /** Formats `externalRating`. Defaults to one decimal place. */
98
+ formatRating?: (score: number) => string;
82
99
  /** The mark's colour, as a CSS `background-image` value. */
83
100
  markGradient?: string;
84
101
  /**
@@ -123,6 +140,16 @@ declare function MentionSignal({ label, count, onMentions, formatCount, }: {
123
140
  onMentions?: () => void;
124
141
  formatCount?: (value: number) => string;
125
142
  }): react_jsx_runtime.JSX.Element;
143
+ /**
144
+ * An external aggregate score: a star and the numeral, in the neutral muted
145
+ * tone. Deliberately quieter than the mention count beside it (lighter weight,
146
+ * a step smaller, no accent) — the census count is this app's own evidence;
147
+ * the star is someone else's, and it must not outrank or be mistaken for it.
148
+ */
149
+ declare function ExternalRatingSignal({ label, value }: {
150
+ label: string;
151
+ value: string;
152
+ }): react_jsx_runtime.JSX.Element;
126
153
  declare const MenuItem: React.NamedExoticComponent<MenuItemProps>;
127
154
 
128
- export { MentionSignal, MenuItem, type MenuItemLabels, type MenuItemLinkProps, type MenuItemProps };
155
+ export { ExternalRatingSignal, MentionSignal, MenuItem, type MenuItemLabels, type MenuItemLinkProps, type MenuItemProps };
@@ -1,4 +1,4 @@
1
- export { MentionSignal, MenuItem } from '../chunk-VXGIDKXV.js';
1
+ export { ExternalRatingSignal, MentionSignal, MenuItem } from '../chunk-4WW3HPIW.js';
2
2
  import '../chunk-6PQINSAA.js';
3
3
  import '../chunk-RYPPN2GZ.js';
4
4
  import '../chunk-FEK5XGZW.js';
@@ -4,11 +4,11 @@ import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const spinnerVariants: (props?: ({
6
6
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
7
- tone?: "default" | "inherit" | "primary" | "destructive" | null | undefined;
7
+ tone?: "primary" | "default" | "inherit" | "destructive" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Spinner: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
10
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
11
- tone?: "default" | "inherit" | "primary" | "destructive" | null | undefined;
11
+ tone?: "primary" | "default" | "inherit" | "destructive" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  /** Visually-hidden label for screen readers. Defaults to "Loading". */
14
14
  label?: string;
@@ -3,11 +3,11 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const tagVariants: (props?: ({
6
- variant?: "default" | "primary" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
6
+ variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Tag: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
- variant?: "default" | "primary" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
10
+ variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
11
11
  size?: "default" | "sm" | "lg" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  onRemove?: () => void;
@@ -22,8 +22,8 @@ import { VariantProps } from 'class-variance-authority';
22
22
  * Weights are 400 / 600 / 700. 500 is not in the system.
23
23
  */
24
24
  declare const textVariants: (props?: ({
25
- variant?: "body" | "small" | "display" | "h2" | "h3" | "label" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
- color?: "default" | "inherit" | "primary" | "destructive" | "muted" | "primary-foreground" | null | undefined;
25
+ variant?: "small" | "body" | "display" | "h2" | "h3" | "label" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
+ color?: "primary" | "default" | "inherit" | "destructive" | "muted" | "primary-foreground" | null | undefined;
27
27
  weight?: "bold" | "medium" | "normal" | "semibold" | null | undefined;
28
28
  leading?: "none" | "normal" | "tight" | "snug" | "relaxed" | "loose" | null | undefined;
29
29
  tracking?: "normal" | "tight" | "wide" | "wider" | null | undefined;
@@ -1,4 +1,4 @@
1
- import { TimelineHourSkeleton } from '../chunk-QRSIJC4X.js';
1
+ import { TimelineHourSkeleton } from '../chunk-S5GDARLW.js';
2
2
  import '../chunk-BRXI6EDO.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { Skeleton } from '../chunk-XBBEX4SF.js';
@@ -48,7 +48,8 @@ import * as React from 'react';
48
48
  *
49
49
  * `layout`: `cards` stacks posters with a gap (a card is its own frame, a
50
50
  * hairline between two posters reads as a third object) and from `lg` lays
51
- * them on a two/three-up grid; `rows` rules between children on both widths.
51
+ * them on a two/three-up grid; `rows` is the same gapped stack on the phone
52
+ * (rows are full cards there) and rules between children from `lg`.
52
53
  * Children are read with `Children.toArray`, which drops `null`/`false`, so a
53
54
  * conditional first row never inherits a rule meant for a second; pass rows
54
55
  * as siblings, not inside a fragment.
@@ -1,4 +1,4 @@
1
- export { TimelineHour, TimelineHourSkeleton } from '../chunk-QRSIJC4X.js';
1
+ export { TimelineHour, TimelineHourSkeleton } from '../chunk-S5GDARLW.js';
2
2
  import '../chunk-BRXI6EDO.js';
3
3
  import '../chunk-NWZGSLPU.js';
4
4
  import '../chunk-XBBEX4SF.js';
@@ -1,4 +1,4 @@
1
- import { MenuItem } from '../chunk-VXGIDKXV.js';
1
+ import { MenuItem } from '../chunk-4WW3HPIW.js';
2
2
  import '../chunk-6PQINSAA.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { Skeleton } from '../chunk-XBBEX4SF.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.45",
3
+ "version": "0.8.47",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { MenuItem, type MenuItemLabels } from "./menu-item";
4
+ import type { MenuItemPreview } from "./menu-types";
5
+
6
+ const wine: MenuItemPreview = {
7
+ id: "w1",
8
+ slug: "feteasca-neagra",
9
+ name: "Fetească Neagră",
10
+ kind: "drink",
11
+ sizes_and_prices: [{ size: 750, price: 169 }],
12
+ mentions: 4,
13
+ };
14
+
15
+ const labels: MenuItemLabels = {
16
+ currency: "RON",
17
+ mentions: (count) => `Mentioned in ${count} reviews`,
18
+ signature: "Signature",
19
+ mentioned: "Mentioned",
20
+ externalRating: (score) => `Rated ${score} on Vivino`,
21
+ };
22
+
23
+ describe("MenuItem external rating", () => {
24
+ it("shows the score beside the mention count, named for assistive tech", () => {
25
+ render(
26
+ <MenuItem item={wine} labels={labels} formatPrice={String} externalRating={4.14} onOpen={() => {}} />
27
+ );
28
+ const rating = screen.getByRole("img", { name: "Rated 4.1 on Vivino" });
29
+ expect(rating.textContent).toBe("4.1");
30
+ expect(screen.getByLabelText("Mentioned in 4 reviews")).toBeTruthy();
31
+ });
32
+
33
+ it("uses the injected formatter and falls back to the bare score as its name", () => {
34
+ const { mentions, currency, signature, mentioned } = labels;
35
+ render(
36
+ <MenuItem
37
+ item={{ ...wine, mentions: 0 }}
38
+ labels={{ mentions, currency, signature, mentioned }}
39
+ formatPrice={String}
40
+ externalRating={4.14}
41
+ formatRating={(score) => score.toFixed(2).replace(".", ",")}
42
+ />
43
+ );
44
+ expect(screen.getByRole("img", { name: "4,14" }).textContent).toBe("4,14");
45
+ });
46
+
47
+ it("renders no rating when the caller passes none", () => {
48
+ render(<MenuItem item={wine} labels={labels} formatPrice={String} />);
49
+ expect(screen.queryByRole("img", { name: /Rated/ })).toBeNull();
50
+ });
51
+
52
+ it("keeps the meta row for a rating alone, with no price, size or mentions", () => {
53
+ const bare: MenuItemPreview = { id: "w2", slug: "x", name: "X", kind: "drink" };
54
+ render(<MenuItem item={bare} labels={labels} formatPrice={String} externalRating={3.9} />);
55
+ expect(screen.getByRole("img", { name: "Rated 3.9 on Vivino" })).toBeTruthy();
56
+ });
57
+ });
@@ -1,5 +1,5 @@
1
1
  import { memo } from "react";
2
- import { MessageSquareQuote } from "lucide-react";
2
+ import { MessageSquareQuote, Star } from "lucide-react";
3
3
  import { cn } from "../lib/utils";
4
4
  import { SignatureChip } from "./signature-chip";
5
5
  import type { MenuItemPreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
@@ -63,6 +63,12 @@ export type MenuItemLabels = {
63
63
  signature: string;
64
64
  /** Chip text for a `weak` (merely mentioned) item. */
65
65
  mentioned: string;
66
+ /**
67
+ * Fully-worded external rating, e.g. "Rated 4.1 on Vivino" — the accessible
68
+ * name for the star-and-score pair. Receives the FORMATTED score. Falls back
69
+ * to the bare score when omitted.
70
+ */
71
+ externalRating?: (score: string) => string;
66
72
  };
67
73
 
68
74
  export type MenuItemProps = {
@@ -86,6 +92,17 @@ export type MenuItemProps = {
86
92
  onSignatureEvidence?: () => void;
87
93
  /** Opens the reviews behind the mention count. */
88
94
  onMentions?: () => void;
95
+ /**
96
+ * An outside aggregate score for the item (a wine's Vivino rating, a beer's
97
+ * Untappd one), shown as a star and the score beside the mention count.
98
+ *
99
+ * The caller GATES it: those scales run low and a thin vote count is noise,
100
+ * so the app decides whether a score is fit to show and passes nothing when
101
+ * it is not. The DS renders what it is given and applies no floor of its own.
102
+ */
103
+ externalRating?: number;
104
+ /** Formats `externalRating`. Defaults to one decimal place. */
105
+ formatRating?: (score: number) => string;
89
106
  /** The mark's colour, as a CSS `background-image` value. */
90
107
  markGradient?: string;
91
108
  /**
@@ -187,6 +204,25 @@ export function MentionSignal({
187
204
  );
188
205
  }
189
206
 
207
+ /**
208
+ * An external aggregate score: a star and the numeral, in the neutral muted
209
+ * tone. Deliberately quieter than the mention count beside it (lighter weight,
210
+ * a step smaller, no accent) — the census count is this app's own evidence;
211
+ * the star is someone else's, and it must not outrank or be mistaken for it.
212
+ */
213
+ export function ExternalRatingSignal({ label, value }: { label: string; value: string }) {
214
+ return (
215
+ <span
216
+ role="img"
217
+ aria-label={label}
218
+ className="inline-flex shrink-0 items-center gap-0.5 text-[13px] font-semibold text-muted-foreground"
219
+ >
220
+ <Star className="h-3 w-3 fill-current opacity-70" aria-hidden />
221
+ <span className="tabular-nums">{value}</span>
222
+ </span>
223
+ );
224
+ }
225
+
190
226
  export const MenuItem = memo(function MenuItem({
191
227
  item,
192
228
  labels,
@@ -197,6 +233,8 @@ export const MenuItem = memo(function MenuItem({
197
233
  signatureTier = "strong",
198
234
  onSignatureEvidence,
199
235
  onMentions,
236
+ externalRating,
237
+ formatRating = (score) => score.toFixed(1),
200
238
  markGradient,
201
239
  venueName,
202
240
  subtitle,
@@ -222,6 +260,7 @@ export const MenuItem = memo(function MenuItem({
222
260
  // `review_count` counts full review objects and is sparse. Prefer the former,
223
261
  // fall back rather than render nothing.
224
262
  const mentionCount = item.mentions ?? item.review_count ?? 0;
263
+ const ratingText = externalRating != null ? formatRating(externalRating) : null;
225
264
 
226
265
  // A row with neither destination is content, not a control.
227
266
  const isInert = !href && !onOpen;
@@ -375,10 +414,10 @@ export const MenuItem = memo(function MenuItem({
375
414
  to the far edge severs it from the name it belongs to. Do not
376
415
  re-litigate without looking at it.
377
416
 
378
- Suppressed entirely when the row has none of the three: a mention
417
+ Suppressed entirely when the row has none of these: a mention
379
418
  with no menu row behind it has no price and no size, and an empty
380
419
  meta line still spent its top margin. */}
381
- {(minPrice != null || hasQuantity || mentionCount > 0) && (
420
+ {(minPrice != null || hasQuantity || mentionCount > 0 || ratingText != null) && (
382
421
  <div className="mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1">
383
422
  <div className="flex min-w-0 items-baseline gap-x-2">
384
423
  {minPrice != null && (
@@ -400,13 +439,25 @@ export const MenuItem = memo(function MenuItem({
400
439
  )}
401
440
  </div>
402
441
 
403
- {mentionCount > 0 && (
404
- <MentionSignal
405
- label={labels.mentions(mentionCount)}
406
- count={mentionCount}
407
- onMentions={onMentions}
408
- formatCount={formatCount}
409
- />
442
+ {/* The external score sits LEFT of the mention count, so the count
443
+ keeps the right edge and still forms a column down the list. */}
444
+ {(ratingText != null || mentionCount > 0) && (
445
+ <div className="flex shrink-0 items-center gap-x-3">
446
+ {ratingText != null && (
447
+ <ExternalRatingSignal
448
+ label={labels.externalRating?.(ratingText) ?? ratingText}
449
+ value={ratingText}
450
+ />
451
+ )}
452
+ {mentionCount > 0 && (
453
+ <MentionSignal
454
+ label={labels.mentions(mentionCount)}
455
+ count={mentionCount}
456
+ onMentions={onMentions}
457
+ formatCount={formatCount}
458
+ />
459
+ )}
460
+ </div>
410
461
  )}
411
462
  </div>
412
463
  )}
@@ -75,4 +75,18 @@ describe("TimelineDay", () => {
75
75
  expect(rows.querySelectorAll('[data-orientation="horizontal"]').length).toBe(2);
76
76
  expect(cards.querySelectorAll('[data-orientation="horizontal"]').length).toBe(1);
77
77
  });
78
+
79
+ it("stacks rows like cards on the phone and rules them only from lg", () => {
80
+ render(
81
+ <TimelineHour time="21:00" layout="rows">
82
+ <p>a</p>
83
+ <p>b</p>
84
+ </TimelineHour>
85
+ );
86
+ const stack = screen.getByText("a").parentElement?.parentElement;
87
+ expect(stack).toHaveClass("gap-4", "lg:gap-0");
88
+ const rule = screen.getByText("b").previousElementSibling;
89
+ expect(rule).toHaveAttribute("data-orientation", "horizontal");
90
+ expect(rule).toHaveClass("hidden", "lg:block");
91
+ });
78
92
  });
@@ -53,7 +53,8 @@ import { TIMELINE_MOBILE_GUTTER_CLASS, TimelineHourPuck } from "./timeline-hour-
53
53
  *
54
54
  * `layout`: `cards` stacks posters with a gap (a card is its own frame, a
55
55
  * hairline between two posters reads as a third object) and from `lg` lays
56
- * them on a two/three-up grid; `rows` rules between children on both widths.
56
+ * them on a two/three-up grid; `rows` is the same gapped stack on the phone
57
+ * (rows are full cards there) and rules between children from `lg`.
57
58
  * Children are read with `Children.toArray`, which drops `null`/`false`, so a
58
59
  * conditional first row never inherits a rule meant for a second; pass rows
59
60
  * as siblings, not inside a fragment.
@@ -92,6 +93,19 @@ export type TimelineHourProps = {
92
93
  * half the fixed 16px heading row. */
93
94
  const HEADING_CENTRE_LG = "lg:top-[30px]";
94
95
 
96
+ /** A stack that is cards on the phone (gapped, unruled) and rows from `lg`
97
+ * (flush, ruled). Shared by `layout="rows"` and `overflow`. */
98
+ const RULED_STACK = "gap-4 pb-2 lg:gap-0 lg:pb-0";
99
+
100
+ function ruleFromLg(items: React.ReactNode[]) {
101
+ return items.map((child, index) => (
102
+ <div key={index}>
103
+ {index > 0 && <Separator className="hidden lg:block" />}
104
+ {child}
105
+ </div>
106
+ ));
107
+ }
108
+
95
109
  export function TimelineHour({
96
110
  time,
97
111
  role = "hour",
@@ -188,30 +202,18 @@ export function TimelineHour({
188
202
  <div
189
203
  className={cn(
190
204
  "flex min-w-0 flex-col",
191
- isCards && "gap-4 pb-2 lg:grid lg:grid-cols-2 lg:gap-5 lg:pb-0 lg:pt-3.5 xl:grid-cols-3",
205
+ isCards
206
+ ? "gap-4 pb-2 lg:grid lg:grid-cols-2 lg:gap-5 lg:pb-0 lg:pt-3.5 xl:grid-cols-3"
207
+ : RULED_STACK,
192
208
  isCards && isHero && "lg:flex lg:pt-0",
193
209
  isUnscheduled && isCards && "pt-3.5"
194
210
  )}
195
211
  >
196
- {isCards
197
- ? items
198
- : items.map((child, index) => (
199
- <div key={index}>
200
- {index > 0 && <Separator />}
201
- {child}
202
- </div>
203
- ))}
212
+ {isCards ? items : ruleFromLg(items)}
204
213
  </div>
205
214
 
206
215
  {overflowItems.length > 0 && (
207
- <div className="flex flex-col gap-4 pb-2 lg:gap-0 lg:pb-0 lg:pt-2">
208
- {overflowItems.map((child, index) => (
209
- <div key={index}>
210
- {index > 0 && <Separator className="hidden lg:block" />}
211
- {child}
212
- </div>
213
- ))}
214
- </div>
216
+ <div className={cn("flex flex-col lg:pt-2", RULED_STACK)}>{ruleFromLg(overflowItems)}</div>
215
217
  )}
216
218
 
217
219
  {note && <p className="pt-[9px] text-[11.5px] text-muted-foreground">{note}</p>}