@bearmenu/ui 0.8.10 → 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 (101) hide show
  1. package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
  2. package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
  3. package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
  4. package/dist/{chunk-UDN7UHJW.js → chunk-7A42J73Q.js} +3 -2
  5. package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
  6. package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
  7. package/dist/{chunk-H3BV4VAG.js → chunk-VAXOQS7W.js} +14 -2
  8. package/dist/{chunk-SY6RV476.js → chunk-WCTHZKU7.js} +10 -21
  9. package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
  10. package/dist/components/alert-dialog.js +1 -1
  11. package/dist/components/badge.js +1 -1
  12. package/dist/components/button.d.ts +2 -2
  13. package/dist/components/button.js +1 -1
  14. package/dist/components/calendar.js +1 -1
  15. package/dist/components/carousel.js +1 -1
  16. package/dist/components/combobox.js +1 -1
  17. package/dist/components/concept-compare-rail.js +2 -2
  18. package/dist/components/concept-compare.d.ts +1 -1
  19. package/dist/components/concept-compare.js +31 -10
  20. package/dist/components/concept-convergence.js +2 -2
  21. package/dist/components/empty-state.js +1 -1
  22. package/dist/components/event-detail-panel.js +1 -1
  23. package/dist/components/event-list-row-wide.js +1 -1
  24. package/dist/components/filter-category-row.js +1 -1
  25. package/dist/components/glass-ground.d.ts +26 -31
  26. package/dist/components/glass-ground.js +1 -1
  27. package/dist/components/haptic-button.js +1 -1
  28. package/dist/components/input-group.d.ts +2 -2
  29. package/dist/components/input.d.ts +1 -1
  30. package/dist/components/item.d.ts +15 -5
  31. package/dist/components/item.js +15 -10
  32. package/dist/components/kitchen-dossier.d.ts +9 -7
  33. package/dist/components/kitchen-dossier.js +31 -25
  34. package/dist/components/link-button.js +1 -1
  35. package/dist/components/menu-family-block.d.ts +22 -17
  36. package/dist/components/menu-family-block.js +62 -61
  37. package/dist/components/menu-family-card.js +2 -4
  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 +18 -11
  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-item.js +3 -3
  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 +2 -2
  49. package/dist/components/pagination.js +1 -1
  50. package/dist/components/photo-ground.d.ts +8 -2
  51. package/dist/components/photo-ground.js +1 -1
  52. package/dist/components/place-details-mobile.js +1 -1
  53. package/dist/components/searchable-select.js +1 -1
  54. package/dist/components/signature-chip.d.ts +2 -2
  55. package/dist/components/signature-chip.js +2 -2
  56. package/dist/components/sliding-panels.js +1 -1
  57. package/dist/components/top-ranking-entry.js +3 -3
  58. package/dist/components/venue-grade-dimensions.js +1 -1
  59. package/dist/components/venue-quotes.d.ts +1 -1
  60. package/dist/components/venue-quotes.js +1 -1
  61. package/dist/components/venue-wall.js +5 -3
  62. package/dist/components/weekday-theme-banner.d.ts +24 -3
  63. package/dist/components/weekday-theme-banner.js +63 -15
  64. package/package.json +1 -1
  65. package/src/components/badge.tsx +1 -1
  66. package/src/components/button.stories.tsx +25 -2
  67. package/src/components/button.tsx +14 -2
  68. package/src/components/colour-dictionary.stories.tsx +1 -1
  69. package/src/components/concept-compare-rail.tsx +2 -1
  70. package/src/components/concept-compare.stories.tsx +3 -3
  71. package/src/components/concept-compare.tsx +50 -14
  72. package/src/components/glass-ground.tsx +31 -56
  73. package/src/components/item.tsx +69 -45
  74. package/src/components/kitchen-dossier.stories.tsx +1 -1
  75. package/src/components/kitchen-dossier.tsx +60 -40
  76. package/src/components/menu-family-block.stories.tsx +3 -3
  77. package/src/components/menu-family-block.tsx +94 -98
  78. package/src/components/menu-family-card.stories.tsx +2 -2
  79. package/src/components/menu-family-card.tsx +6 -13
  80. package/src/components/menu-family-tiles.stories.tsx +54 -0
  81. package/src/components/menu-family-tiles.test.tsx +30 -0
  82. package/src/components/menu-family-tiles.tsx +132 -0
  83. package/src/components/menu-hub-hero.test.tsx +39 -0
  84. package/src/components/menu-hub-hero.tsx +30 -12
  85. package/src/components/menu-index-list.stories.tsx +6 -1
  86. package/src/components/menu-index-list.tsx +101 -54
  87. package/src/components/menu-intent-hero.tsx +1 -1
  88. package/src/components/menu-mocks.ts +32 -9
  89. package/src/components/menu-types.ts +32 -9
  90. package/src/components/photo-ground.tsx +14 -2
  91. package/src/components/signature-chip.tsx +3 -3
  92. package/src/components/venue-grade-dimensions.tsx +9 -2
  93. package/src/components/venue-quotes.stories.tsx +1 -1
  94. package/src/components/venue-quotes.tsx +4 -2
  95. package/src/components/venue-wall.tsx +9 -4
  96. package/src/components/weekday-theme-banner.stories.tsx +43 -4
  97. package/src/components/weekday-theme-banner.tsx +95 -18
  98. package/dist/components/menu-intent-tiles.d.ts +0 -31
  99. package/dist/components/menu-intent-tiles.js +0 -50
  100. package/src/components/menu-intent-tiles.stories.tsx +0 -54
  101. package/src/components/menu-intent-tiles.tsx +0 -76
@@ -6,6 +6,8 @@ function DefaultLink(_a) {
6
6
  var _b = _a, { href } = _b, props = __objRest(_b, ["href"]);
7
7
  return /* @__PURE__ */ jsx("a", __spreadValues({ href }, props));
8
8
  }
9
+ var WALL_CELLS = 18;
10
+ var PHONE_CELLS = 6;
9
11
  function VenueWall({
10
12
  places,
11
13
  hrefs,
@@ -28,7 +30,7 @@ function VenueWall({
28
30
  ),
29
31
  children: [
30
32
  (tileUrls == null ? void 0 : tileUrls.length) ? /* @__PURE__ */ jsxs(Fragment, { children: [
31
- /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-6", children: Array.from({ length: 18 }, (_, cell) => (
33
+ /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "absolute inset-0 grid auto-rows-fr grid-cols-3 gap-0.5 lg:grid-cols-6", children: Array.from({ length: WALL_CELLS }, (_, cell) => (
32
34
  // eslint-disable-next-line @next/next/no-img-element
33
35
  /* @__PURE__ */ jsx(
34
36
  "img",
@@ -37,7 +39,7 @@ function VenueWall({
37
39
  alt: "",
38
40
  loading: "lazy",
39
41
  decoding: "async",
40
- className: "h-full w-full object-cover"
42
+ className: cn("h-full w-full object-cover", cell >= PHONE_CELLS && "hidden lg:block")
41
43
  },
42
44
  cell
43
45
  )
@@ -94,7 +96,7 @@ function VenueWall({
94
96
  ) }),
95
97
  /* @__PURE__ */ jsx("span", { className: "truncate text-[12.5px] font-medium", children: place.name })
96
98
  ] });
97
- const chipClasses = "inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/10 py-1 pl-1 pr-3.5 backdrop-blur-[6px]";
99
+ const chipClasses = "inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/[0.14] py-1 pl-1 pr-3.5";
98
100
  return /* @__PURE__ */ jsx("li", { children: (hrefs == null ? void 0 : hrefs[place.slug]) ? /* @__PURE__ */ jsx(
99
101
  LinkComponent,
100
102
  {
@@ -26,8 +26,15 @@ type WeekdayThemeBannerLinkProps = {
26
26
  type WeekdayThemeDay = {
27
27
  /** Short code, e.g. "THU". Already localised. */
28
28
  code: string;
29
- /** Theme name, e.g. "Burger Thursday". */
29
+ /** Theme name, e.g. "Burger Thursday in Cluj". */
30
30
  name: string;
31
+ /**
32
+ * The phone's title, e.g. "Burger Thursday" — one line where `name` takes
33
+ * two, so the photograph behind it stays a photograph. A separate string,
34
+ * never `name` with a clause stripped: word order is the locale's. Falls
35
+ * back to `name`.
36
+ */
37
+ shortHeadline?: string;
31
38
  /** Short form for the rail, e.g. "Burger". Falls back to `name`. */
32
39
  shortName?: string;
33
40
  /** Items under the theme. Omit where there is no count to show. */
@@ -44,10 +51,24 @@ type WeekdayThemeBannerProps = {
44
51
  body?: string;
45
52
  /** Heading over the week rail, e.g. "THE WEEK". */
46
53
  weekLabel?: string;
47
- /** The primary action — pass a rendered button or link. */
54
+ /**
55
+ * The primary action — pass a rendered button or link. From `lg` up only
56
+ * when `compactAction` is also given: on a phone the banner is a row, and a
57
+ * row does not carry a labelled button.
58
+ */
48
59
  action?: React.ReactNode;
49
60
  /** Note beside the action, e.g. "Bruno picks nine, ranked on mentions". */
50
61
  actionNote?: string;
62
+ /**
63
+ * The phone's action: the whole banner becomes the target and a chevron on
64
+ * the right is the signal — a disclosure row, not a call to action, so the
65
+ * banner stays as tall as its title. `label` names the target for a screen
66
+ * reader. Hidden from `lg`, where `action` takes over.
67
+ */
68
+ compactAction?: {
69
+ label: string;
70
+ onClick: () => void;
71
+ };
51
72
  /** Destination per day index. Renders the rail as links. */
52
73
  dayHref?: (day: WeekdayThemeDay, index: number) => string;
53
74
  /** Selects a day in place. Renders the rail as buttons. */
@@ -65,6 +86,6 @@ type WeekdayThemeBannerProps = {
65
86
  linkComponent?: React.ComponentType<WeekdayThemeBannerLinkProps>;
66
87
  className?: string;
67
88
  };
68
- declare function WeekdayThemeBanner({ days, activeDay, kicker, kickerIcon, body, weekLabel, action, actionNote, dayHref, onDayChange, imageUrl, linkComponent: LinkComponent, className, }: WeekdayThemeBannerProps): react_jsx_runtime.JSX.Element | null;
89
+ declare function WeekdayThemeBanner({ days, activeDay, kicker, kickerIcon, body, weekLabel, action, actionNote, compactAction, dayHref, onDayChange, imageUrl, linkComponent: LinkComponent, className, }: WeekdayThemeBannerProps): react_jsx_runtime.JSX.Element | null;
69
90
 
70
91
  export { WeekdayThemeBanner, type WeekdayThemeBannerLinkProps, type WeekdayThemeBannerProps, type WeekdayThemeDay };
@@ -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
  {
@@ -76,7 +124,7 @@ function WeekdayThemeBanner({
76
124
  "relative z-[1] hidden border-t border-white/15 p-4 lg:block lg:min-w-[240px] lg:border-l lg:border-t-0 lg:p-5",
77
125
  // Over a photograph the rail needs its own ground, or seven lines of
78
126
  // small type land on whatever the plate happens to be.
79
- imageUrl && "bg-black/45 backdrop-blur-[2px]"
127
+ imageUrl && "bg-black/55"
80
128
  ),
81
129
  children: [
82
130
  weekLabel && /* @__PURE__ */ jsx("div", { className: "mb-2 text-[10px] font-bold tracking-[0.12em] opacity-70", children: weekLabel }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -14,7 +14,7 @@ const badgeVariants = cva(
14
14
  "border-transparent bg-destructive text-destructive-foreground",
15
15
  outline: "text-foreground border-border",
16
16
  overlay:
17
- "border-transparent bg-foreground/70 text-background backdrop-blur-sm",
17
+ "border-transparent bg-foreground/80 text-background",
18
18
  glass: "glass text-foreground border-border/50",
19
19
  success:
20
20
  "border-transparent bg-success text-success-foreground",
@@ -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
  };
@@ -28,14 +28,24 @@ const buttonVariants = cva(
28
28
  * into a dark photo.
29
29
  *
30
30
  * Deliberately NOT theme-aware, and so deliberately different from
31
- * `Badge`'s `overlay` (`bg-foreground/70 text-background`, which
31
+ * `Badge`'s `overlay` (`bg-foreground/80 text-background`, which
32
32
  * inverts). A photo is equally a photo in both themes — this chrome has
33
33
  * to contrast with the image beneath it, not with the app canvas, so it
34
34
  * stays dark-on-light-glyph either way. Reconcile Badge to this, not the
35
35
  * reverse, if the two are ever unified.
36
36
  */
37
37
  overlay:
38
- "bg-black/40 text-white backdrop-blur-sm hover:bg-black/60 focus-visible:ring-white/70",
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>
@@ -67,7 +67,8 @@ export type ConceptCompareRailProps = {
67
67
  * not `var(--scrim)`, which is the app's token and not this package's.
68
68
  */
69
69
  const FOOT_GLASS = cn(
70
- "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:backdrop-blur-lg",
70
+ // Ink only no backdrop-blur; see `GlassGround` for the measurement.
71
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
71
72
  "before:bg-[oklch(0.16_0.012_55_/_0.9)]",
72
73
  "lg:before:[mask-image:linear-gradient(to_top,black_60%,transparent_100%)] lg:before:bg-[linear-gradient(to_top,oklch(0.16_0.012_55_/_0.88)_0%,oklch(0.16_0.012_55_/_0.84)_60%,oklch(0.16_0.012_55_/_0.4)_85%,oklch(0.16_0.012_55_/_0)_100%)]"
73
74
  );
@@ -12,7 +12,7 @@ const labels = {
12
12
 
13
13
  const formatPrice = (amount: number) => `${amount} lei`;
14
14
 
15
- /** A stand-in venue photograph with enough variance that the glass has something to blur. */
15
+ /** A stand-in venue photograph with enough variance that the ink bands have something to sit on. */
16
16
  function room(hue: number, label: string) {
17
17
  return (
18
18
  "data:image/svg+xml;utf8," +
@@ -79,7 +79,7 @@ const hrefs = Object.fromEntries(rooms.map((r) => [r.place_slug, `/places/${r.pl
79
79
  const seeAll = (
80
80
  <a
81
81
  href="/menu/dishes/papanasi"
82
- className="inline-flex h-10 items-center justify-center gap-1.5 rounded-full border border-white/20 bg-white/[0.12] px-4 text-[13.5px] font-bold text-white backdrop-blur-md"
82
+ className="inline-flex h-10 items-center justify-center gap-1.5 rounded-full border border-white/20 bg-white/[0.16] px-4 text-[13.5px] font-bold text-white"
83
83
  >
84
84
  See all 45 kitchens
85
85
  </a>
@@ -111,7 +111,7 @@ const meta: Meta<typeof ConceptCompare> = {
111
111
  docs: {
112
112
  description: {
113
113
  component:
114
- "One dish across the kitchens that make it — and **no surfaces at all**. The card is a contact sheet of venue photographs, one per room, and the three readable things float on it through glass: the identity across the top, each kitchen's line over its own photograph, the foot along the bottom. Read ACROSS, not down: four rooms side by side, each with its own name and price. The photographs are placed as cells of the same grid as the rooms, spanning from the head down, so the head's glass genuinely blurs them and every seam lands on a room boundary. Nothing in it is themed — the edge is the only thing that adapts to the page.",
114
+ "One dish across the kitchens that make it — and **no surfaces at all**. The card is a contact sheet of venue photographs, one per room, and the three readable things float on it on ink bands: the identity across the top, each kitchen's line over its own photograph, the foot along the bottom. Read ACROSS, not down: four rooms side by side, each with its own name and price. The photographs are placed as cells of the same grid as the rooms, spanning from the head down, so the head's ink fades into them and every seam lands on a room boundary. Nothing in it is themed — the edge is the only thing that adapts to the page.",
115
115
  },
116
116
  },
117
117
  },
@@ -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;
@@ -135,11 +135,16 @@ const ROOM_SCRIM =
135
135
  "absolute inset-0 bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0)_30%,oklch(0.16_0.012_55_/_0.55)_58%,oklch(0.16_0.012_55_/_0.9)_100%)]";
136
136
 
137
137
  /**
138
- * The head band's glass. Mask and ink both run the band's full height and go
139
- * to ZERO at its underside on the same curve — a soft edge, not a line.
138
+ * The head band's ink. It runs the band's full height and goes to ZERO at its
139
+ * underside on an eased curve — a soft edge, not a line. Ink only: the
140
+ * backdrop-blur AND the mask that used to sit under it were removed for the
141
+ * measurement recorded in `GlassGround`. Without the mask the gradient alone
142
+ * shapes the fade, so its lower half is darker than before (~0.28 at 75 %
143
+ * where mask×gradient gave ~0.08) — deliberate, that is where the room
144
+ * names begin.
140
145
  */
141
146
  const HEAD_GLASS =
142
- "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:backdrop-blur-lg before:[mask-image:linear-gradient(to_bottom,black_35%,transparent_100%)] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.8)_0%,oklch(0.16_0.012_55_/_0.6)_35%,oklch(0.16_0.012_55_/_0.22)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
147
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.86)_0%,oklch(0.16_0.012_55_/_0.7)_35%,oklch(0.16_0.012_55_/_0.28)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
143
148
 
144
149
  /* The foot band's glass lives in `concept-compare-rail.tsx`, with the foot. */
145
150
 
@@ -272,7 +277,15 @@ export function ConceptCompare({
272
277
  >
273
278
  {rooms.map((room, index) => {
274
279
  const href = hrefs?.[room.place_slug];
275
- 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);
276
289
  const roomClass = cn(
277
290
  /* Transparent on the phone — the card behind it is the room's
278
291
  photograph — with only its scrim and its line. A photograph of
@@ -303,11 +316,11 @@ export function ConceptCompare({
303
316
  "inline-flex h-[22px] w-max items-center gap-1 rounded-full px-2 text-[10px] font-extrabold uppercase tracking-[0.08em]",
304
317
  isCheapest
305
318
  ? "bg-success text-white"
306
- : "bg-white/15 text-white/85 backdrop-blur-[2px]"
319
+ : "bg-white/20 text-white/85"
307
320
  )}
308
321
  >
309
322
  {isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
310
- {isCheapest ? labels.cheapest : rankOf(index + 1)}
323
+ {badge}
311
324
  </span>
312
325
 
313
326
  {/* Name and rating on one line: the pin leads, the name takes
@@ -317,11 +330,25 @@ export function ConceptCompare({
317
330
  <b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
318
331
  {room.place_name}
319
332
  </b>
320
- {room.rating !== undefined && (
321
- <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
322
- <Star className="h-3 w-3 shrink-0" aria-hidden />
323
- <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}
324
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
+ )
325
352
  )}
326
353
  </span>
327
354
  {room.place_area && (
@@ -330,7 +357,7 @@ export function ConceptCompare({
330
357
 
331
358
  {/* The kitchen's own wording — the evidence that these are
332
359
  matched rows rather than one row repeated. */}
333
- <span className="mt-2 block truncate font-serif text-[14px] italic text-white/90">
360
+ <span className="mt-2 block truncate font-serif text-[14px] text-white/90">
334
361
  {room.alias}
335
362
  </span>
336
363
 
@@ -338,7 +365,11 @@ export function ConceptCompare({
338
365
  is a 0.32-lightness grey that vanishes on inked photography. */}
339
366
  <Separator className="my-2.5 via-white/20" />
340
367
 
341
- <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">
342
373
  <b
343
374
  className={cn(
344
375
  "text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
@@ -348,7 +379,12 @@ export function ConceptCompare({
348
379
  {formatPrice(room.price)}
349
380
  </b>
350
381
  {room.mentions !== undefined && room.mentions > 0 && (
351
- <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
+ >
352
388
  {labels.mentions(room.mentions)}
353
389
  </span>
354
390
  )}
@@ -2,47 +2,47 @@ import type { CSSProperties } from "react";
2
2
  import { cn } from "../lib/utils";
3
3
 
4
4
  /**
5
- * GlassGround — a frosted, inked caption rising out of one edge of a
6
- * photograph.
5
+ * GlassGround — an inked caption rising out of one edge of a photograph.
7
6
  *
8
- * Layers over a `PhotoGround`: two masked `backdrop-blur`s (the frost) and
9
- * an ink gradient (the contrast), running in one direction absent where the
10
- * photograph is the subject, solid where the type is. `MenuFamilyBlock` drew
11
- * this first; this is the same idea with the stops as data, so a card can say
12
- * "clear for 128px, then glass" without a sixth copy of the gradient strings.
7
+ * One layer over a `PhotoGround`: an ink gradient running in one direction —
8
+ * absent where the photograph is the subject, solid where the type is.
9
+ * `MenuFamilyBlock` drew this first; this is the same idea with the stops as
10
+ * data, so a card can say "clear for 128px, then ink" without a sixth copy of
11
+ * the gradient string.
13
12
  *
14
- * ## Why two blurs, and why the ramps are eased
13
+ * ## Why there is no blur any more
15
14
  *
16
- * One blur under a linear mask has an edge: the eye reads the line where
17
- * sharp becomes soft, however gently the mask fades it in. Two blurs — a
18
- * light one that starts early and a heavier one that starts late — make the
19
- * softening itself a gradient (the "progressive blur" of a phone's lock
20
- * screen), and every ramp here is an ease-in through five stops rather than
21
- * a straight line, because a straight ramp has a visible crease at each end
22
- * where its slope changes.
15
+ * This used to be two masked `backdrop-blur`s under the ink a progressive
16
+ * frost. Measured on `/menu` in production (2026-09-14): the page carried 73
17
+ * backdrop-filter composited layers, six dossiers' worth of these among them,
18
+ * and the main thread spent 9 ms per scrolled frame re-layerising them
19
+ * (2.7 s over a six-second fling; 56 ms with backdrop-filter disabled). A
20
+ * frost is not worth a page that cannot scroll on a phone, so the caption
21
+ * rests on the ink alone, eased through five stops so the ramp has no crease.
22
+ * See `docs/audits/audit-menu-page-performance-2026-09-14.md` in the app.
23
23
  *
24
24
  * ## Why the stops are custom properties
25
25
  *
26
- * Tailwind emits only the classes it can read verbatim, so the gradients
27
- * cannot be assembled from props into a class. The classes below are literal
28
- * and read `--glass-*` variables; the component writes those inline, having
26
+ * Tailwind emits only the classes it can read verbatim, so the gradient
27
+ * cannot be assembled from props into a class. The class below is literal and
28
+ * reads `--glass-*` variables; the component writes those inline, having
29
29
  * interpolated the quarter stops in JS (`clear` and `full` must share a
30
30
  * unit). Same mechanism as `HueChip`'s hue. The ink's colour is the
31
31
  * package's one scrim literal, `oklch(0.16 0.012 55)`, never `var(--scrim)`.
32
32
  *
33
33
  * ## The ramp ends where the type begins
34
34
  *
35
- * `clear` is where the frost starts, `full` where it and the ink are solid.
36
- * Set `full` to the exact seam the content starts at — a ramp that runs past
37
- * the seam puts the first row of type on half-frost.
35
+ * `clear` is where the ink starts, `full` where it is solid. Set `full` to
36
+ * the exact seam the content starts at — a ramp that runs past the seam puts
37
+ * the first row of type on half-ink.
38
38
  */
39
39
 
40
40
  export type GlassStops = {
41
41
  /** Which way the ink runs — toward the type. */
42
42
  direction: "to bottom" | "to right";
43
- /** Where the frost begins, e.g. `"0px"` or `"24%"`. */
43
+ /** Where the ink begins, e.g. `"0px"` or `"24%"`. */
44
44
  clear: string;
45
- /** Where frost and ink are solid — the seam the content starts at. Same unit as `clear`. */
45
+ /** Where the ink is solid — the seam the content starts at. Same unit as `clear`. */
46
46
  full: string;
47
47
  };
48
48
 
@@ -52,16 +52,11 @@ export type GlassGroundProps = {
52
52
  lgStops?: GlassStops;
53
53
  /**
54
54
  * How dark the ink gets at the far edge, 0–1. `0.96` is a caption the
55
- * photograph is only a texture under; `0.4` is a photograph the caption is
55
+ * photograph is only a texture under; `0.6` is a photograph the caption is
56
56
  * drawn on — the type is lifted, the room stays a room.
57
57
  */
58
58
  peak?: number;
59
- /**
60
- * How much the frost softens the photograph where it is solid. `xl` turns
61
- * a room into colour; `sm` keeps it a room and only takes the edge off the
62
- * detail behind the type.
63
- */
64
- blur?: "sm" | "md" | "lg" | "xl";
59
+ /** Placement only. The ink is a `bg-*` on this same element, so a background here would replace it. */
65
60
  className?: string;
66
61
  };
67
62
 
@@ -70,23 +65,9 @@ export type GlassGroundProps = {
70
65
  const STOPS = [0, 0.25, 0.5, 0.75, 1] as const;
71
66
  const EASE = [0, 0.06, 0.25, 0.56, 1] as const;
72
67
 
73
- /** The light blur starts at once and is solid halfway; the heavy one starts halfway. */
74
- const FROST_SOFT =
75
- "[mask-image:linear-gradient(var(--glass-dir),transparent_var(--glass-s0),rgba(0,0,0,0.25)_var(--glass-s1),black_var(--glass-s2))] lg:[mask-image:linear-gradient(var(--glass-lg-dir),transparent_var(--glass-lg-s0),rgba(0,0,0,0.25)_var(--glass-lg-s1),black_var(--glass-lg-s2))]";
76
- const FROST_HEAVY =
77
- "[mask-image:linear-gradient(var(--glass-dir),transparent_var(--glass-s1),rgba(0,0,0,0.25)_var(--glass-s2),rgba(0,0,0,0.6)_var(--glass-s3),black_var(--glass-s4))] lg:[mask-image:linear-gradient(var(--glass-lg-dir),transparent_var(--glass-lg-s1),rgba(0,0,0,0.25)_var(--glass-lg-s2),rgba(0,0,0,0.6)_var(--glass-lg-s3),black_var(--glass-lg-s4))]";
78
-
79
68
  const INK =
80
69
  "bg-[linear-gradient(var(--glass-dir),oklch(0.16_0.012_55_/_0)_var(--glass-s0),oklch(0.16_0.012_55_/_var(--glass-a1))_var(--glass-s1),oklch(0.16_0.012_55_/_var(--glass-a2))_var(--glass-s2),oklch(0.16_0.012_55_/_var(--glass-a3))_var(--glass-s3),oklch(0.16_0.012_55_/_var(--glass-a4))_var(--glass-s4),oklch(0.16_0.012_55_/_var(--glass-a-peak))_100%)] lg:bg-[linear-gradient(var(--glass-lg-dir),oklch(0.16_0.012_55_/_0)_var(--glass-lg-s0),oklch(0.16_0.012_55_/_var(--glass-a1))_var(--glass-lg-s1),oklch(0.16_0.012_55_/_var(--glass-a2))_var(--glass-lg-s2),oklch(0.16_0.012_55_/_var(--glass-a3))_var(--glass-lg-s3),oklch(0.16_0.012_55_/_var(--glass-a4))_var(--glass-lg-s4),oklch(0.16_0.012_55_/_var(--glass-a-peak))_100%)]";
81
70
 
82
- /** Literal, for the scanner: the light and the heavy blur per strength. */
83
- const BLUR = {
84
- sm: ["backdrop-blur-[2px]", "backdrop-blur-sm"],
85
- md: ["backdrop-blur-sm", "backdrop-blur-md"],
86
- lg: ["backdrop-blur-md", "backdrop-blur-lg"],
87
- xl: ["backdrop-blur-lg", "backdrop-blur-xl"],
88
- } as const;
89
-
90
71
  /** "72px" → [72, "px"]; "24%" → [24, "%"]. */
91
72
  function measure(value: string): [number, string] {
92
73
  const match = value.trim().match(/^(-?\d*\.?\d+)\s*([a-z%]*)$/i);
@@ -101,13 +82,7 @@ function positions(clear: string, full: string): string[] {
101
82
  return STOPS.map((fraction) => `${from + (to - from) * fraction}${unit}`);
102
83
  }
103
84
 
104
- export function GlassGround({
105
- stops,
106
- lgStops = stops,
107
- peak = 0.96,
108
- blur = "xl",
109
- className,
110
- }: GlassGroundProps) {
85
+ export function GlassGround({ stops, lgStops = stops, peak = 0.96, className }: GlassGroundProps) {
111
86
  const base = positions(stops.clear, stops.full);
112
87
  const lg = positions(lgStops.clear, lgStops.full);
113
88
  const style = {
@@ -120,10 +95,10 @@ export function GlassGround({
120
95
  } as CSSProperties;
121
96
 
122
97
  return (
123
- <span aria-hidden className={cn("pointer-events-none absolute inset-0 block", className)} style={style}>
124
- <span className={cn("absolute inset-0", BLUR[blur][0], FROST_SOFT)} />
125
- <span className={cn("absolute inset-0", BLUR[blur][1], FROST_HEAVY)} />
126
- <span className={cn("absolute inset-0", INK)} />
127
- </span>
98
+ <span
99
+ aria-hidden
100
+ className={cn("pointer-events-none absolute inset-0 block", INK, className)}
101
+ style={style}
102
+ />
128
103
  );
129
104
  }