@bearmenu/ui 0.8.55 → 0.8.56

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 (63) hide show
  1. package/dist/{chunk-CZ2HC7WY.js → chunk-EGN5DOLQ.js} +2 -2
  2. package/dist/{chunk-YE4ZQUTI.js → chunk-QOXXIYDG.js} +14 -2
  3. package/dist/{chunk-I5V446BX.js → chunk-TDTLEDX6.js} +1 -1
  4. package/dist/{chunk-CBBECMBD.js → chunk-WBTSOARU.js} +55 -2
  5. package/dist/components/alert.d.ts +2 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/banner.d.ts +1 -1
  8. package/dist/components/button.d.ts +2 -2
  9. package/dist/components/card.d.ts +2 -2
  10. package/dist/components/command.d.ts +15 -15
  11. package/dist/components/concept-compare.d.ts +1 -1
  12. package/dist/components/doorway-tiles.d.ts +1 -1
  13. package/dist/components/empty-state.d.ts +2 -2
  14. package/dist/components/event-detail-panel.d.ts +1 -1
  15. package/dist/components/event-detail-panel.js +2 -2
  16. package/dist/components/grade-atom.d.ts +1 -1
  17. package/dist/components/grade-atom.js +2 -2
  18. package/dist/components/kitchen-dossier.d.ts +1 -1
  19. package/dist/components/menu-dead-end.d.ts +1 -1
  20. package/dist/components/menu-family-block.d.ts +1 -1
  21. package/dist/components/menu-family-block.js +3 -3
  22. package/dist/components/menu-family-card.d.ts +1 -1
  23. package/dist/components/menu-filter-chips.d.ts +1 -1
  24. package/dist/components/menu-group-rows.d.ts +1 -1
  25. package/dist/components/menu-index-header.d.ts +1 -1
  26. package/dist/components/menu-index-list.d.ts +1 -1
  27. package/dist/components/menu-intent-hero.d.ts +1 -1
  28. package/dist/components/menu-item.d.ts +34 -1
  29. package/dist/components/menu-item.js +3 -1
  30. package/dist/components/menu-kitchen-list.d.ts +1 -1
  31. package/dist/components/menu-kitchen-list.js +3 -3
  32. package/dist/components/menu-mocks.d.ts +1 -1
  33. package/dist/components/menu-types.d.ts +1 -1
  34. package/dist/components/open-now-list.d.ts +1 -1
  35. package/dist/components/place-about-tab.d.ts +1 -1
  36. package/dist/components/place-card.d.ts +1 -1
  37. package/dist/components/place-card.js +2 -2
  38. package/dist/components/place-details-mobile.d.ts +1 -1
  39. package/dist/components/place-schedule-week.d.ts +1 -1
  40. package/dist/components/place-types.d.ts +1 -1
  41. package/dist/components/qa-block.d.ts +1 -1
  42. package/dist/components/spinner.d.ts +2 -2
  43. package/dist/components/tag.d.ts +2 -2
  44. package/dist/components/text.d.ts +2 -2
  45. package/dist/components/top-comparison.d.ts +1 -1
  46. package/dist/components/top-cross-link.d.ts +1 -1
  47. package/dist/components/top-ranking-entry.d.ts +1 -1
  48. package/dist/components/top-ranking-entry.js +3 -1
  49. package/dist/components/top-ranking-hero.d.ts +1 -1
  50. package/dist/components/top-types.d.ts +1 -1
  51. package/dist/components/venue-grade-dimensions.d.ts +1 -1
  52. package/dist/components/venue-quotes.d.ts +1 -1
  53. package/dist/components/venue-wall.d.ts +1 -1
  54. package/dist/lib/grade.d.ts +1 -1
  55. package/dist/lib/grade.js +1 -1
  56. package/dist/{place-types-Dzy6FH67.d.ts → place-types-CjB4JlH0.d.ts} +2 -2
  57. package/package.json +1 -1
  58. package/src/components/grade-atom.test.tsx +15 -0
  59. package/src/components/grade-atom.tsx +15 -3
  60. package/src/components/menu-item.stories.tsx +108 -0
  61. package/src/components/menu-item.test.tsx +100 -0
  62. package/src/components/menu-item.tsx +89 -6
  63. package/src/lib/grade.ts +2 -2
@@ -1,3 +1,3 @@
1
- export { C as CitedText, l as CitedValue, M as MenuSizeAndPrice, c as PlaceAboutTabLabels, e as PlaceCardLabels, f as PlaceDetailsMobileLabels, a as PlaceGrade, b as PlaceGradeDimensions, m as PlaceHourBlock, d as PlaceLinkKind, n as PlaceMustTry, P as PlacePreview, o as PlacePriceRange, p as PlaceReviewsDigest, g as PlaceSchedule, h as PlaceScheduleWeekLabels, W as WeekdayKey } from '../place-types-Dzy6FH67.js';
1
+ export { C as CitedText, l as CitedValue, M as MenuSizeAndPrice, c as PlaceAboutTabLabels, e as PlaceCardLabels, f as PlaceDetailsMobileLabels, a as PlaceGrade, b as PlaceGradeDimensions, m as PlaceHourBlock, d as PlaceLinkKind, n as PlaceMustTry, P as PlacePreview, o as PlacePriceRange, p as PlaceReviewsDigest, g as PlaceSchedule, h as PlaceScheduleWeekLabels, W as WeekdayKey } from '../place-types-CjB4JlH0.js';
2
2
  import 'react/jsx-runtime';
3
3
  import '../lib/grade.js';
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { QaItem } from './menu-types.js';
3
3
  import 'react';
4
- import '../place-types-Dzy6FH67.js';
4
+ import '../place-types-CjB4JlH0.js';
5
5
  import '../lib/grade.js';
6
6
 
7
7
  /**
@@ -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?: "inherit" | "default" | "destructive" | "primary" | null | undefined;
7
+ tone?: "primary" | "inherit" | "default" | "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?: "inherit" | "default" | "destructive" | "primary" | null | undefined;
11
+ tone?: "primary" | "inherit" | "default" | "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" | "destructive" | "outline" | "warning" | "primary" | "success" | "quiet" | null | undefined;
6
+ variant?: "primary" | "success" | "warning" | "default" | "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" | "destructive" | "outline" | "warning" | "primary" | "success" | "quiet" | null | undefined;
10
+ variant?: "primary" | "success" | "warning" | "default" | "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?: "h2" | "h3" | "label" | "body" | "caption" | "h1" | "h4" | "small" | "display" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
- color?: "inherit" | "default" | "destructive" | "primary" | "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" | "inherit" | "default" | "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;
@@ -2,7 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { MenuLinkComponent } from './menu-types.js';
3
3
  import { TopComparisonRow } from './top-types.js';
4
4
  import 'react';
5
- import '../place-types-Dzy6FH67.js';
5
+ import '../place-types-CjB4JlH0.js';
6
6
  import '../lib/grade.js';
7
7
 
8
8
  type TopComparisonLabels = {
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { MenuLinkComponent } from './menu-types.js';
3
3
  import 'react';
4
- import '../place-types-Dzy6FH67.js';
4
+ import '../place-types-CjB4JlH0.js';
5
5
  import '../lib/grade.js';
6
6
 
7
7
  type TopCrossLinkProps = {
@@ -3,7 +3,7 @@ import { MenuItemLabels } from './menu-item.js';
3
3
  import { MenuItemPreview, MenuLinkComponent } from './menu-types.js';
4
4
  import { TopRankEntry } from './top-types.js';
5
5
  import 'react';
6
- import '../place-types-Dzy6FH67.js';
6
+ import '../place-types-CjB4JlH0.js';
7
7
  import '../lib/grade.js';
8
8
 
9
9
  type TopRankingEntryLabels = {
@@ -1,5 +1,7 @@
1
- import { MenuItem } from '../chunk-CBBECMBD.js';
1
+ import { MenuItem } from '../chunk-WBTSOARU.js';
2
2
  import '../chunk-6PQINSAA.js';
3
+ import '../chunk-QOXXIYDG.js';
4
+ import '../chunk-TDTLEDX6.js';
3
5
  import { Separator } from '../chunk-NWZGSLPU.js';
4
6
  import { Skeleton } from '../chunk-XBBEX4SF.js';
5
7
  import '../chunk-RYPPN2GZ.js';
@@ -2,7 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { MenuLinkComponent } from './menu-types.js';
3
3
  import { TopExit } from './top-types.js';
4
4
  import 'react';
5
- import '../place-types-Dzy6FH67.js';
5
+ import '../place-types-CjB4JlH0.js';
6
6
  import '../lib/grade.js';
7
7
 
8
8
  type TopRankingHeroProps = {
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { MenuItemPreview } from './menu-types.js';
3
- import '../place-types-Dzy6FH67.js';
3
+ import '../place-types-CjB4JlH0.js';
4
4
  import 'react/jsx-runtime';
5
5
  import '../lib/grade.js';
6
6
 
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { b as PlaceGradeDimensions } from '../place-types-Dzy6FH67.js';
2
+ import { b as PlaceGradeDimensions } from '../place-types-CjB4JlH0.js';
3
3
  import '../lib/grade.js';
4
4
 
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { C as CitedText } from '../place-types-Dzy6FH67.js';
2
+ import { C as CitedText } from '../place-types-CjB4JlH0.js';
3
3
  import '../lib/grade.js';
4
4
 
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { MenuLinkComponent, MenuLinkProps } from './menu-types.js';
3
- import { P as PlacePreview } from '../place-types-Dzy6FH67.js';
3
+ import { P as PlacePreview } from '../place-types-CjB4JlH0.js';
4
4
  import 'react';
5
5
  import '../lib/grade.js';
6
6
 
@@ -44,7 +44,7 @@ declare function shouldShowGradeNumber(grade: {
44
44
  declare function showsGradeAtom<T extends {
45
45
  overall?: number | null;
46
46
  show_number?: boolean;
47
- }>(grade: T | null | undefined, size: "xs" | "sm" | "md" | "lg"): grade is T;
47
+ }>(grade: T | null | undefined, size: "2xs" | "xs" | "sm" | "md" | "lg"): grade is T;
48
48
  /** Tone for a single 0–100 dimension score, driving the page dimension-bar
49
49
  * colour: strong (≥75) = accent, adequate (50–74) = muted, low (<50) = amber
50
50
  * (a soft "fair warning", never error-red). */
package/dist/lib/grade.js CHANGED
@@ -1,2 +1,2 @@
1
- export { formatGrade, resolveDimensionTone, resolveGradeBand, shouldShowGradeNumber, showsGradeAtom } from '../chunk-I5V446BX.js';
1
+ export { formatGrade, resolveDimensionTone, resolveGradeBand, shouldShowGradeNumber, showsGradeAtom } from '../chunk-TDTLEDX6.js';
2
2
  import '../chunk-MMUBH76A.js';
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { GradeBand } from './lib/grade.js';
3
3
 
4
- type GradeAtomSize = "xs" | "sm" | "md" | "lg";
4
+ type GradeAtomSize = "2xs" | "xs" | "sm" | "md" | "lg";
5
5
  /** Labels passed to `<GradeAtom>`. All translation happens in the caller. */
6
6
  type GradeAtomLabels = {
7
7
  /** The `show_number=false` state, e.g. "Worth exploring". */
@@ -23,7 +23,7 @@ type GradeAtomLabels = {
23
23
  type GradeAtomProps = {
24
24
  grade: PlaceGrade;
25
25
  labels: GradeAtomLabels;
26
- /** xs = card chip · sm = chat/compact · md = drawer · lg = page hero. */
26
+ /** 2xs = inline in a line of text · xs = card chip · sm = chat/compact · md = drawer · lg = page hero. */
27
27
  size?: GradeAtomSize;
28
28
  /** Force-hide the descriptor even at sizes that would show it (tight rows). */
29
29
  hideDescriptor?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.55",
3
+ "version": "0.8.56",
4
4
  "packageManager": "pnpm@10.28.1",
5
5
  "description": "BearMenu design system \u2014 shared UI tokens, primitives, and components",
6
6
  "license": "UNLICENSED",
@@ -23,6 +23,21 @@ describe("GradeAtom", () => {
23
23
  expect(screen.getByLabelText("Nota 8,7 din 10")).toHaveTextContent("8,7");
24
24
  });
25
25
 
26
+ // A pill that sits inside a line of text (a menu row's name or venue line)
27
+ // must not make the line taller: at `xs` (24px) it pushed rows apart
28
+ // (Adrian, 9 Nov). `2xs` is 18px with an 11px number, and like `xs` it has
29
+ // no room for the soft "worth exploring" sentence.
30
+ it("has an inline size that fits a line of text, number only", () => {
31
+ render(<GradeAtom grade={{ overall: 8.7 }} labels={labels} size="2xs" />);
32
+ const pill = screen.getByLabelText("Grade 8.7 out of 10");
33
+ expect(pill.className).toContain("h-[18px]");
34
+ expect(pill.innerHTML).toContain("text-2xs");
35
+ const { container } = render(
36
+ <GradeAtom grade={{ overall: 8.7, show_number: false }} labels={labels} size="2xs" />
37
+ );
38
+ expect(container.textContent).toBe("");
39
+ });
40
+
26
41
  it("hands the formatter the rounded, clamped grade", () => {
27
42
  const seen: number[] = [];
28
43
  const spy: GradeAtomLabels = { ...labels, value: (n) => (seen.push(n), String(n)) };
@@ -29,7 +29,7 @@ import {
29
29
  } from "../lib/grade";
30
30
  import type { PlaceGrade } from "./place-types";
31
31
 
32
- export type GradeAtomSize = "xs" | "sm" | "md" | "lg";
32
+ export type GradeAtomSize = "2xs" | "xs" | "sm" | "md" | "lg";
33
33
 
34
34
  /** Labels passed to `<GradeAtom>`. All translation happens in the caller. */
35
35
  export type GradeAtomLabels = {
@@ -53,7 +53,7 @@ export type GradeAtomLabels = {
53
53
  export type GradeAtomProps = {
54
54
  grade: PlaceGrade;
55
55
  labels: GradeAtomLabels;
56
- /** xs = card chip · sm = chat/compact · md = drawer · lg = page hero. */
56
+ /** 2xs = inline in a line of text · xs = card chip · sm = chat/compact · md = drawer · lg = page hero. */
57
57
  size?: GradeAtomSize;
58
58
  /** Force-hide the descriptor even at sizes that would show it (tight rows). */
59
59
  hideDescriptor?: boolean;
@@ -136,6 +136,18 @@ const SIZE: Record<
136
136
  showDesc: boolean;
137
137
  }
138
138
  > = {
139
+ // Inline in a line of text (a menu row's name or venue line): 18px fits a
140
+ // 15px name and a 12px venue line without making either taller, which the
141
+ // 24px xs did (Adrian, 2026-11-09). Number only, like xs.
142
+ "2xs": {
143
+ box: "h-[18px] px-1 rounded",
144
+ minW: "min-w-7",
145
+ num: "text-2xs",
146
+ desc: "text-2xs",
147
+ gap: "gap-0.5",
148
+ shield: "h-2.5 w-2.5",
149
+ showDesc: false,
150
+ },
139
151
  xs: {
140
152
  box: "h-6 px-1.5 rounded-md",
141
153
  minW: "min-w-9",
@@ -208,7 +220,7 @@ export function GradeAtom({
208
220
  // caller falls back to its Google star instead (gate on `showsGradeAtom`, and
209
221
  // see its note for why callers can't test `overall != null` any more).
210
222
  if (!shouldShowGradeNumber(grade)) {
211
- if (size === "xs") return null;
223
+ if (size === "xs" || size === "2xs") return null;
212
224
  return (
213
225
  <span
214
226
  className={cn(
@@ -80,3 +80,111 @@ export const CountKinds: Story = {
80
80
  </div>
81
81
  ),
82
82
  };
83
+
84
+ const brisket: MenuItemPreview = {
85
+ id: "i2",
86
+ slug: "brisket",
87
+ name: "Brisket",
88
+ kind: "dish",
89
+ category_slug: "meat",
90
+ mentions: 60,
91
+ sizes_and_prices: [{ size: 0, price: 375 }],
92
+ };
93
+
94
+ const gradeLabels = {
95
+ soft: "Worth exploring",
96
+ bands: {
97
+ exceptional: "Exceptional",
98
+ great: "Great",
99
+ good: "Good",
100
+ decent: "Decent",
101
+ weak: "Weak",
102
+ },
103
+ aria: (value: string) => `Grade ${value} out of 10`,
104
+ capped: "Capped for low confidence",
105
+ };
106
+
107
+ /**
108
+ * The venue line on a cross-venue list (the map's Dishes and Drinks): the place
109
+ * in full ink with its grade, then its neighbourhood and whether it is open,
110
+ * muted. With `emphasis="praise"` the price steps back and the praise leads.
111
+ */
112
+ export const VenueLine: Story = {
113
+ render: (args) => (
114
+ <div className="divide-y divide-border">
115
+ <MenuItem
116
+ {...args}
117
+ item={brisket}
118
+ countKind="praise"
119
+ labels={praiseLabels}
120
+ emphasis="praise"
121
+ subtitle="“Carnea a fost foarte fragedă și avea un gust intens de vită.”"
122
+ venueName="Texas Grill"
123
+ venueGrade={{
124
+ grade: { overall: 8.7, show_number: true, capped: false, confidence: "high" },
125
+ labels: gradeLabels,
126
+ }}
127
+ venueDetail="Centru"
128
+ venueStatus={{ tone: "open", label: "Închide la 23:00" }}
129
+ />
130
+ <MenuItem
131
+ {...args}
132
+ item={{ ...brisket, id: "i3", name: "Ramen Tori Paitan", mentions: 58 }}
133
+ countKind="praise"
134
+ labels={praiseLabels}
135
+ emphasis="praise"
136
+ venueName="YUSO"
137
+ venueGrade={{
138
+ grade: { overall: 9.2, show_number: true, capped: false, confidence: "high" },
139
+ labels: gradeLabels,
140
+ }}
141
+ venueDetail="Mărăști"
142
+ venueStatus={{ tone: "closed", label: "Deschide la 12:00" }}
143
+ />
144
+ <MenuItem
145
+ {...args}
146
+ item={{ ...brisket, id: "i4", name: "Cannoli Siciliani", mentions: 12 }}
147
+ countKind="praise"
148
+ labels={praiseLabels}
149
+ emphasis="praise"
150
+ venueName="Cannoleria Cluj-Napoca Restaurant & Pasticceria"
151
+ venueGrade={{
152
+ grade: { overall: 7.4, show_number: true, capped: false, confidence: "medium" },
153
+ labels: gradeLabels,
154
+ }}
155
+ venueDetail="Zorilor"
156
+ venueStatus={{ tone: "open", label: "Închide la 22:00" }}
157
+ />
158
+ {/* A row whose name is the place (the map's Drinks list): the grade goes
159
+ with the name, and the venue line says what the place is praised for. */}
160
+ <MenuItem
161
+ {...args}
162
+ item={{ id: "f1", slug: "the-soviet", name: "The Soviet", kind: "drink", mentions: 271 }}
163
+ countKind="praise"
164
+ labels={praiseLabels}
165
+ emphasis="praise"
166
+ subtitle="“A very nice place to drink very good cocktails with some unusual names.”"
167
+ nameGrade={{
168
+ grade: { overall: 9.2, show_number: true, capped: false, confidence: "high" },
169
+ labels: gradeLabels,
170
+ }}
171
+ venueName="Praised for cocktails"
172
+ venueDetail="Centru"
173
+ venueStatus={{ tone: "open", label: "Închide la 02:00" }}
174
+ />
175
+ <MenuItem
176
+ {...args}
177
+ item={{ ...brisket, id: "i5", name: "Papanași Cremoși", mentions: 9 }}
178
+ countKind="praise"
179
+ labels={praiseLabels}
180
+ emphasis="praise"
181
+ venueName="Madrugada"
182
+ venueGrade={{
183
+ grade: { overall: 8.1, show_number: false, capped: false, confidence: "low" },
184
+ labels: gradeLabels,
185
+ }}
186
+ venueDetail="Centru"
187
+ />
188
+ </div>
189
+ ),
190
+ };
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
+ import type { GradeAtomLabels } from "./grade-atom";
3
4
  import { MentionSignal, MenuItem, type MenuItemLabels } from "./menu-item";
4
5
  import type { MenuItemPreview } from "./menu-types";
5
6
 
@@ -131,3 +132,102 @@ describe("MenuItem emphasis", () => {
131
132
  expect(price.className).toContain("text-[13px]");
132
133
  });
133
134
  });
135
+
136
+ // On cross-venue surfaces the venue line can say whether the place is open:
137
+ // a dot (success green open, muted closed) and the words. The words carry the
138
+ // meaning; the dot is the glance. A long venue gives way, the status never.
139
+ describe("MenuItem venue status", () => {
140
+ const row = (venueStatus?: { tone: "open" | "closed"; label: string }) =>
141
+ render(
142
+ <MenuItem
143
+ item={wine}
144
+ labels={labels}
145
+ formatPrice={String}
146
+ venueName="Texas Grill · Centru"
147
+ venueStatus={venueStatus}
148
+ onOpen={() => {}}
149
+ />
150
+ );
151
+
152
+ it("says an open place closes, with a green dot", () => {
153
+ const { container } = row({ tone: "open", label: "Closes at 23:00" });
154
+ const status = container.querySelector('[data-slot="venue-status"]')!;
155
+ expect(status.textContent).toBe("Closes at 23:00");
156
+ expect(status.getAttribute("data-tone")).toBe("open");
157
+ expect(status.querySelector("[aria-hidden]")!.className).toContain("bg-success");
158
+ expect(status.className).toContain("shrink-0");
159
+ expect(screen.getByText("Texas Grill · Centru").className).toContain("min-w-0");
160
+ });
161
+
162
+ it("says a closed place opens, with a muted dot", () => {
163
+ const { container } = row({ tone: "closed", label: "Opens at 09:00" });
164
+ const dot = container.querySelector('[data-slot="venue-status"] [aria-hidden]')!;
165
+ expect(dot.className).toContain("bg-muted-foreground");
166
+ });
167
+
168
+ it("draws no status when none is given", () => {
169
+ const { container } = row();
170
+ expect(container.querySelector('[data-slot="venue-status"]')).toBeNull();
171
+ expect(screen.getByText("Texas Grill · Centru")).toBeTruthy();
172
+ });
173
+ });
174
+
175
+ // The venue line has its own order (Adrian, 2026-11-09): the place in full
176
+ // ink, its grade beside it, then its neighbourhood and hours, muted. Before,
177
+ // all of it was one grey string and the place weighed what its details did.
178
+ describe("MenuItem venue line", () => {
179
+ const grade = { overall: 8.7, show_number: true, capped: false, confidence: "high" } as const;
180
+ const gradeLabels: GradeAtomLabels = {
181
+ soft: "Worth exploring",
182
+ bands: { exceptional: "Exceptional", great: "Great", good: "Good", decent: "Decent", weak: "Weak" },
183
+ aria: (value) => `Grade ${value} out of 10`,
184
+ capped: "Capped",
185
+ };
186
+ const venue = (props: Partial<Parameters<typeof MenuItem>[0]>) =>
187
+ render(
188
+ <MenuItem
189
+ item={wine}
190
+ labels={labels}
191
+ formatPrice={String}
192
+ venueName="Texas Grill"
193
+ onOpen={() => {}}
194
+ {...props}
195
+ />
196
+ );
197
+
198
+ it("sets the place in full ink and its neighbourhood muted after it", () => {
199
+ venue({ venueDetail: "Centru" });
200
+ const place = screen.getByText("Texas Grill");
201
+ expect(place.className).toContain("text-foreground");
202
+ expect(place.className).toContain("font-semibold");
203
+ const detail = screen.getByText("Centru");
204
+ expect(detail.className).toContain("text-muted-foreground");
205
+ expect(detail.className).toContain("font-normal");
206
+ });
207
+
208
+ it("puts the place's grade beside its name", () => {
209
+ venue({ venueGrade: { grade, labels: gradeLabels } });
210
+ // The inline size: the pill fits the line instead of making it taller.
211
+ expect(screen.getByLabelText("Grade 8.7 out of 10").className).toContain("h-[18px]");
212
+ });
213
+
214
+ // The map's Drinks rows lead with the bar; their venue line says what it
215
+ // is praised for. The grade goes with the bar's name, never beside
216
+ // "Praised for cocktails" (Adrian, 9 Nov).
217
+ it("can put the grade beside the row's name when the name is the place", () => {
218
+ const { container } = venue({
219
+ item: { ...wine, name: "The Soviet" },
220
+ venueName: "Praised for cocktails",
221
+ nameGrade: { grade, labels: gradeLabels },
222
+ });
223
+ const pill = screen.getByLabelText("Grade 8.7 out of 10");
224
+ expect(container.querySelector("h3")!.parentElement!.contains(pill)).toBe(true);
225
+ expect(screen.getByText("Praised for cocktails").parentElement!.contains(pill)).toBe(false);
226
+ });
227
+
228
+ it("draws no grade without a number to show", () => {
229
+ venue({ venueGrade: { grade: { ...grade, show_number: false }, labels: gradeLabels } });
230
+ expect(screen.queryByLabelText(/^Grade/)).toBeNull();
231
+ expect(screen.queryByText("Worth exploring")).toBeNull();
232
+ });
233
+ });
@@ -1,6 +1,9 @@
1
1
  import { memo } from "react";
2
2
  import { MessageSquareQuote, Star, ThumbsUp } from "lucide-react";
3
+ import { showsGradeAtom } from "../lib/grade";
3
4
  import { cn } from "../lib/utils";
5
+ import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
6
+ import type { PlaceGrade } from "./place-types";
4
7
  import { SignatureChip } from "./signature-chip";
5
8
  import type { MenuItemPreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
6
9
 
@@ -142,6 +145,30 @@ export type MenuItemProps = {
142
145
  * default.
143
146
  */
144
147
  venueName?: string;
148
+ /**
149
+ * Whether the venue is open, on the venue line after its name: a dot
150
+ * (`open` in the success green, `closed` muted) and the caller's words, e.g.
151
+ * "Closes at 23:00" / "Opens at 09:00". Only with `venueName`.
152
+ */
153
+ venueStatus?: { tone: "open" | "closed"; label: string };
154
+ /**
155
+ * A short detail about the venue, muted after its name and grade: its
156
+ * neighbourhood, say. Only with `venueName`.
157
+ */
158
+ venueDetail?: string;
159
+ /**
160
+ * The venue's grade, drawn as `<GradeAtom size="2xs">` (inline, 18px) beside its name. Only
161
+ * a grade with a number shows: the xs atom draws nothing for the soft
162
+ * "worth exploring" state (see `showsGradeAtom`).
163
+ */
164
+ venueGrade?: { grade: PlaceGrade; labels: GradeAtomLabels };
165
+ /**
166
+ * A grade beside the row's NAME, for rows where the name is the place (the
167
+ * map's Drinks list leads with the bar, and its venue line says what the
168
+ * bar is praised for). Use it instead of `venueGrade` there, so the grade
169
+ * never sits beside "Praised for cocktails". Same rule: only with a number.
170
+ */
171
+ nameGrade?: { grade: PlaceGrade; labels: GradeAtomLabels };
145
172
  /**
146
173
  * Explicit subtitle, in italic, in place of the description. Used where the
147
174
  * reviewer's reason IS the row's content rather than the payoff for tapping.
@@ -272,6 +299,10 @@ export const MenuItem = memo(function MenuItem({
272
299
  formatRating = (score) => score.toFixed(1),
273
300
  markGradient,
274
301
  venueName,
302
+ venueStatus,
303
+ venueDetail,
304
+ venueGrade,
305
+ nameGrade,
275
306
  subtitle,
276
307
  href,
277
308
  linkLabel,
@@ -399,6 +430,15 @@ export const MenuItem = memo(function MenuItem({
399
430
  <div className="min-w-0 flex-1">
400
431
  <div className="flex items-start gap-1.5">
401
432
  <h3 className="text-[15px] font-semibold leading-snug text-foreground">{item.name}</h3>
433
+ {nameGrade && showsGradeAtom(nameGrade.grade, "2xs") && (
434
+ <GradeAtom
435
+ grade={nameGrade.grade}
436
+ labels={nameGrade.labels}
437
+ size="2xs"
438
+ hideDescriptor
439
+ className="shrink-0 self-center"
440
+ />
441
+ )}
402
442
  {showSignatureBadge && (
403
443
  <SignatureChip
404
444
  label={signatureTier === "weak" ? labels.mentioned : labels.signature}
@@ -408,13 +448,56 @@ export const MenuItem = memo(function MenuItem({
408
448
  )}
409
449
  </div>
410
450
 
411
- {/* Venue attribution — cross-venue surfaces only. Medium weight at 12px
412
- against the 12px regular muted description below is what makes it
413
- read as a different KIND of fact at a glance. No pin glyph:
414
- repeated down a list it is visual lint, and a pin reads "address"
415
- when the fact is "kitchen". */}
451
+ {/* Venue attribution — cross-venue surfaces only. The place in full
452
+ ink, semibold at 12px, is a different KIND of fact from the muted
453
+ description below, and outranks its own details: the grade beside
454
+ it, then the neighbourhood and the hours, muted and regular. Before
455
+ 0.8.56 the line was one grey string, and the place weighed what its
456
+ details did. No pin glyph: repeated down a list it is visual lint,
457
+ and a pin reads "address" when the fact is "kitchen". */}
416
458
  {venueName && (
417
- <p className="mt-0.5 truncate text-[12px] font-medium text-foreground/70">{venueName}</p>
459
+ <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12px]">
460
+ <span className="min-w-0 truncate font-semibold text-foreground">{venueName}</span>
461
+ {venueGrade && showsGradeAtom(venueGrade.grade, "2xs") && (
462
+ <GradeAtom
463
+ grade={venueGrade.grade}
464
+ labels={venueGrade.labels}
465
+ size="2xs"
466
+ hideDescriptor
467
+ className="shrink-0"
468
+ />
469
+ )}
470
+ {venueDetail && (
471
+ <>
472
+ <span aria-hidden className="text-muted-foreground">
473
+ ·
474
+ </span>
475
+ {/* Short by nature (a neighbourhood): it keeps its width, and
476
+ a long place name gives way instead of leaving "Z…". */}
477
+ <span className="shrink-0 font-normal text-muted-foreground">{venueDetail}</span>
478
+ </>
479
+ )}
480
+ {/* Whether the place is open: a dot for the glance, the words for
481
+ the meaning. Green is the success token, closed stays muted:
482
+ at 10 a.m. most bars are closed, and a list of red rows would
483
+ read as an alarm. A long venue gives way; the status never. */}
484
+ {venueStatus && (
485
+ <span
486
+ data-slot="venue-status"
487
+ data-tone={venueStatus.tone}
488
+ className="inline-flex shrink-0 items-center gap-1 font-normal text-muted-foreground"
489
+ >
490
+ <span
491
+ aria-hidden
492
+ className={cn(
493
+ "size-1.5 shrink-0 rounded-full",
494
+ venueStatus.tone === "open" ? "bg-success" : "bg-muted-foreground"
495
+ )}
496
+ />
497
+ {venueStatus.label}
498
+ </span>
499
+ )}
500
+ </p>
418
501
  )}
419
502
 
420
503
  {/* The description gives up a line to the venue rather than the row
package/src/lib/grade.ts CHANGED
@@ -58,10 +58,10 @@ export function shouldShowGradeNumber(grade: { show_number?: boolean }): boolean
58
58
  */
59
59
  export function showsGradeAtom<T extends { overall?: number | null; show_number?: boolean }>(
60
60
  grade: T | null | undefined,
61
- size: "xs" | "sm" | "md" | "lg"
61
+ size: "2xs" | "xs" | "sm" | "md" | "lg"
62
62
  ): grade is T {
63
63
  if (grade?.overall == null) return false;
64
- return size !== "xs" || shouldShowGradeNumber(grade);
64
+ return (size !== "xs" && size !== "2xs") || shouldShowGradeNumber(grade);
65
65
  }
66
66
 
67
67
  /** Tone for a single 0–100 dimension score, driving the page dimension-bar