@bearmenu/ui 0.8.9 → 0.8.11
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.
- package/dist/chunk-24RFI4RQ.js +167 -0
- package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
- package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-H3ZZDX5L.js +61 -0
- package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
- package/dist/chunk-WCTHZKU7.js +37 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/avatar.d.ts +3 -3
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/card.d.ts +4 -4
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +2 -2
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +75 -38
- package/dist/components/concept-compare.js +209 -133
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/dialog.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/glass-ground.d.ts +61 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/hub-surface.js +11 -1
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/input-group.d.ts +2 -2
- package/dist/components/input.d.ts +1 -1
- package/dist/components/item.d.ts +4 -4
- package/dist/components/kbd.d.ts +2 -2
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +186 -0
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +98 -0
- package/dist/components/menu-family-block.js +153 -0
- package/dist/components/menu-family-card.d.ts +33 -27
- package/dist/components/menu-family-card.js +41 -85
- package/dist/components/menu-field.js +92 -2
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-hub-hero.js +10 -5
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +37 -2
- package/dist/components/menu-mocks.js +152 -27
- package/dist/components/menu-types.d.ts +182 -6
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +6 -6
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/native-select.d.ts +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/place-types.d.ts +2 -2
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/select.d.ts +1 -1
- package/dist/components/signature-chip.d.ts +2 -2
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/snap-rail.d.ts +26 -3
- package/dist/components/snap-rail.js +3 -109
- package/dist/components/spinner.d.ts +4 -4
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +4 -4
- package/dist/components/text.d.ts +5 -5
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +3 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.js +5 -3
- package/dist/components/weekday-theme-banner.js +1 -1
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/badge.tsx +1 -1
- package/src/components/button.tsx +2 -2
- package/src/components/concept-compare-rail.tsx +132 -0
- package/src/components/concept-compare.stories.tsx +122 -54
- package/src/components/concept-compare.tsx +368 -196
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +104 -0
- package/src/components/hub-surface.tsx +11 -1
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +321 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +303 -0
- package/src/components/menu-family-card.stories.tsx +43 -35
- package/src/components/menu-family-card.tsx +115 -142
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-hub-hero.test.tsx +39 -0
- package/src/components/menu-hub-hero.tsx +17 -5
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +198 -23
- package/src/components/menu-types.ts +189 -6
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +6 -5
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +2 -2
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +3 -3
- package/src/components/snap-rail.tsx +58 -15
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.tsx +2 -2
- package/src/components/venue-quotes.stories.tsx +1 -1
- package/src/components/venue-quotes.tsx +4 -2
- package/src/components/venue-wall.tsx +9 -4
- package/src/components/weekday-theme-banner.tsx +1 -1
- package/dist/chunk-THOVPZNN.js +0 -37
- package/dist/chunk-YS6BHKZO.js +0 -92
- package/dist/components/ranked-venue-list.d.ts +0 -49
- package/dist/components/ranked-venue-list.js +0 -87
- package/dist/components/signature-line-list.d.ts +0 -45
- package/dist/components/signature-line-list.js +0 -99
- package/dist/components/venue-busyness-strip.d.ts +0 -35
- package/dist/components/venue-busyness-strip.js +0 -37
- package/dist/components/venue-signature-block.d.ts +0 -39
- package/dist/components/venue-signature-block.js +0 -6
- package/dist/components/venue-spotlight.d.ts +0 -74
- package/dist/components/venue-spotlight.js +0 -138
- package/src/components/ranked-venue-list.stories.tsx +0 -80
- package/src/components/ranked-venue-list.tsx +0 -177
- package/src/components/signature-line-list.stories.tsx +0 -85
- package/src/components/signature-line-list.tsx +0 -136
- package/src/components/venue-busyness-strip.stories.tsx +0 -66
- package/src/components/venue-busyness-strip.tsx +0 -64
- package/src/components/venue-signature-block.stories.tsx +0 -68
- package/src/components/venue-signature-block.tsx +0 -71
- package/src/components/venue-spotlight.stories.tsx +0 -141
- package/src/components/venue-spotlight.tsx +0 -236
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { ArrowRight, CalendarCheck, Info, LayoutList, Trophy } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
4
|
+
import type { TopExit } from "./top-types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TopRankingHero — the head of an editorial ranking.
|
|
8
|
+
*
|
|
9
|
+
* ## It says when, and it says how, before it says what
|
|
10
|
+
*
|
|
11
|
+
* The trust contract is the point of `/top`: a visible "Updated" date, the size
|
|
12
|
+
* of the pool it chose from, and a method line. Those are not fine print here —
|
|
13
|
+
* they are the reason a reader believes the ten, and they sit where a reader
|
|
14
|
+
* looks for them rather than at the foot of the page.
|
|
15
|
+
*
|
|
16
|
+
* ## The exits are stated, not implied
|
|
17
|
+
*
|
|
18
|
+
* Two links out to the browsing tool — "Browse all 247 Italian kitchens" and
|
|
19
|
+
* "58 open now". The ranking is the editorial pick; the intent page is
|
|
20
|
+
* everything else, and the page says so rather than leaving a reader who wants
|
|
21
|
+
* the directory to bounce. Placed here, in the list, and in the Q&A: three, and
|
|
22
|
+
* never a sticky bar. A sticky CTA on an article is an advert, and this page's
|
|
23
|
+
* job is to be read to the end.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
27
|
+
return <a href={href} {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TopRankingHeroProps = {
|
|
31
|
+
/** e.g. "RANKED · CUISINE". */
|
|
32
|
+
kicker?: string;
|
|
33
|
+
title: string;
|
|
34
|
+
/** One paragraph: the size of the pool, and what the order means. */
|
|
35
|
+
lede?: string;
|
|
36
|
+
/** e.g. "Updated 6 September 2026". */
|
|
37
|
+
updatedLabel?: string;
|
|
38
|
+
/** e.g. "247 kitchens considered · 4,318 reviews read". */
|
|
39
|
+
metaLabel?: string;
|
|
40
|
+
exits?: TopExit[];
|
|
41
|
+
/** Finished CSS gradient for the ground. */
|
|
42
|
+
gradient?: string;
|
|
43
|
+
/** Watermark behind the text — the category's icon, resolved by the caller. */
|
|
44
|
+
icon?: React.ReactNode;
|
|
45
|
+
linkComponent?: MenuLinkComponent;
|
|
46
|
+
className?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function TopRankingHero({
|
|
50
|
+
kicker,
|
|
51
|
+
title,
|
|
52
|
+
lede,
|
|
53
|
+
updatedLabel,
|
|
54
|
+
metaLabel,
|
|
55
|
+
exits,
|
|
56
|
+
gradient,
|
|
57
|
+
icon,
|
|
58
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
59
|
+
className,
|
|
60
|
+
}: TopRankingHeroProps) {
|
|
61
|
+
return (
|
|
62
|
+
<header
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative overflow-hidden rounded-[18px] px-5 py-6 lg:px-8 lg:py-8",
|
|
65
|
+
!gradient && "bg-muted",
|
|
66
|
+
className
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{gradient && (
|
|
70
|
+
<span aria-hidden className="absolute inset-0" style={{ backgroundImage: gradient }} />
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{icon && (
|
|
74
|
+
<span
|
|
75
|
+
aria-hidden
|
|
76
|
+
className="pointer-events-none absolute -right-6 -top-6 opacity-[0.14] [&>svg]:h-[150px] [&>svg]:w-[150px] lg:[&>svg]:h-[190px] lg:[&>svg]:w-[190px]"
|
|
77
|
+
>
|
|
78
|
+
{icon}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
<div className="relative z-[1]">
|
|
83
|
+
{kicker && (
|
|
84
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-background/70 px-3 py-1 text-[11px] font-extrabold uppercase tracking-[0.09em] backdrop-blur-[2px]">
|
|
85
|
+
<Trophy className="h-3 w-3 shrink-0" aria-hidden />
|
|
86
|
+
{kicker}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<h1 className="mt-3 max-w-[22ch] font-serif text-[30px] font-bold leading-[1.08] tracking-[-0.025em] lg:text-[44px]">
|
|
91
|
+
{title}
|
|
92
|
+
</h1>
|
|
93
|
+
|
|
94
|
+
{lede && (
|
|
95
|
+
<p className="mt-3 max-w-[62ch] text-[14.5px] leading-relaxed opacity-85">{lede}</p>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{(updatedLabel || metaLabel) && (
|
|
99
|
+
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12.5px]">
|
|
100
|
+
{updatedLabel && (
|
|
101
|
+
<span className="inline-flex items-center gap-1.5 font-semibold">
|
|
102
|
+
<CalendarCheck className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
103
|
+
{updatedLabel}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
{updatedLabel && metaLabel && (
|
|
107
|
+
<span aria-hidden className="h-1 w-1 rounded-full bg-current opacity-40" />
|
|
108
|
+
)}
|
|
109
|
+
{metaLabel && <span className="opacity-75">{metaLabel}</span>}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{/*
|
|
114
|
+
Desktop only, and not for want of room.
|
|
115
|
+
|
|
116
|
+
On a phone the hero already fills the first screen with the kicker,
|
|
117
|
+
a three-line serif headline, the date and the pool size — everything
|
|
118
|
+
that says what this page is. Two full-width buttons under that put a
|
|
119
|
+
call to action above the first entry, so the reader meets an advert
|
|
120
|
+
before they meet the ranking, and the page's whole argument is that it
|
|
121
|
+
is worth reading. The exits are stated again under the list, where a
|
|
122
|
+
reader who has read the argument is actually deciding what to do next.
|
|
123
|
+
|
|
124
|
+
Beside a 44px headline on a wide hero they are a row of two, cost one
|
|
125
|
+
line, and read as orientation rather than as a pitch.
|
|
126
|
+
*/}
|
|
127
|
+
{exits && exits.length > 0 && (
|
|
128
|
+
<div className="mt-5 hidden flex-col gap-2 sm:flex-row lg:flex">
|
|
129
|
+
{exits.map((exit, index) => (
|
|
130
|
+
<LinkComponent
|
|
131
|
+
key={exit.href}
|
|
132
|
+
href={exit.href}
|
|
133
|
+
className={cn(
|
|
134
|
+
"inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none",
|
|
135
|
+
index === 0
|
|
136
|
+
? "bg-background text-foreground hover:bg-background/90"
|
|
137
|
+
: "border border-current/25 hover:bg-background/25"
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{exit.icon ?? <LayoutList className="h-4 w-4 shrink-0" aria-hidden />}
|
|
141
|
+
{exit.label}
|
|
142
|
+
<ArrowRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
143
|
+
</LinkComponent>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</header>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* TopMethodNote — how the ranking was computed, in one block.
|
|
154
|
+
*
|
|
155
|
+
* Under the hero rather than at the foot: a reader deciding whether to trust a
|
|
156
|
+
* list decides it before reading the list, not after.
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* The note's surface, exported so a caller can put the same look on a TRIGGER.
|
|
160
|
+
*
|
|
161
|
+
* On `/top` the banner opens the grade explainer, and that explainer owns its
|
|
162
|
+
* own `<button>` (Radix clones it, so it cannot be handed a ready-made
|
|
163
|
+
* element). The caller therefore needs the surface without the element — hence
|
|
164
|
+
* a class string rather than a second component. `cn` at the call site lets the
|
|
165
|
+
* button's own `inline-flex` lose to the `flex` here.
|
|
166
|
+
*/
|
|
167
|
+
export const topMethodNoteSurface =
|
|
168
|
+
"flex w-full items-start gap-3 rounded-[14px] bg-muted p-4 text-left";
|
|
169
|
+
|
|
170
|
+
export type TopMethodNoteProps = {
|
|
171
|
+
/** The heading fragment, e.g. "How this is ranked." Rendered bold, inline. */
|
|
172
|
+
heading: string;
|
|
173
|
+
children: React.ReactNode;
|
|
174
|
+
/** Rendered after the prose — a chevron where the note is a disclosure trigger. */
|
|
175
|
+
affordance?: React.ReactNode;
|
|
176
|
+
className?: string;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The note's CONTENT, with no element of its own.
|
|
181
|
+
*
|
|
182
|
+
* Every tag here is phrasing content — spans, not paragraphs — because this
|
|
183
|
+
* renders inside a `<button>` when the note is a trigger, and a `<p>` inside a
|
|
184
|
+
* button is invalid. Keeping one definition of the content means the static
|
|
185
|
+
* note and the interactive one cannot drift apart.
|
|
186
|
+
*/
|
|
187
|
+
export function TopMethodNoteBody({
|
|
188
|
+
heading,
|
|
189
|
+
children,
|
|
190
|
+
affordance,
|
|
191
|
+
}: Omit<TopMethodNoteProps, "className">) {
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
195
|
+
<span className="block text-[13px] leading-relaxed text-muted-foreground">
|
|
196
|
+
<b className="font-bold text-foreground">{heading}</b> {children}
|
|
197
|
+
</span>
|
|
198
|
+
{affordance}
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function TopMethodNote({ heading, children, affordance, className }: TopMethodNoteProps) {
|
|
204
|
+
return (
|
|
205
|
+
<aside className={cn(topMethodNoteSurface, className)}>
|
|
206
|
+
<TopMethodNoteBody heading={heading} affordance={affordance}>
|
|
207
|
+
{children}
|
|
208
|
+
</TopMethodNoteBody>
|
|
209
|
+
</aside>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
* `TopExitRow` used to live here. It is now `TopCrossLink` in
|
|
215
|
+
* `top-cross-link.tsx`, with a `panel` variant — the same object is needed in
|
|
216
|
+
* both directions (ranking → browse, and browse → ranking), and its old name
|
|
217
|
+
* described only the outbound half.
|
|
218
|
+
*/
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TopRankingLegend } from "./top-ranking-legend";
|
|
3
|
+
|
|
4
|
+
const groups = [
|
|
5
|
+
{
|
|
6
|
+
key: "exceptional",
|
|
7
|
+
label: "Exceptional",
|
|
8
|
+
entries: [
|
|
9
|
+
{ rank: 1, slug: "a", name: "PASQUALE | brunch & dinner" },
|
|
10
|
+
{ rank: 2, slug: "b", name: "Alle Bollicine Vino & Pasta" },
|
|
11
|
+
{ rank: 3, slug: "c", name: "Mare Nera Seafood Restaurant" },
|
|
12
|
+
{ rank: 4, slug: "d", name: "La 502" },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
key: "excellent",
|
|
17
|
+
label: "Excellent",
|
|
18
|
+
entries: [
|
|
19
|
+
{ rank: 5, slug: "e", name: "Tortelli Pasta Bar" },
|
|
20
|
+
{ rank: 6, slug: "f", name: "Il forno - vera pizza napoletana" },
|
|
21
|
+
{ rank: 7, slug: "g", name: "Perfetto Cucina Italiana" },
|
|
22
|
+
{ rank: 8, slug: "h", name: "CUCINA TUREA BACANIE cu de toate" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: "very-good",
|
|
27
|
+
label: "Very good",
|
|
28
|
+
entries: [
|
|
29
|
+
{ rank: 9, slug: "i", name: "GARLIC - bites&tales" },
|
|
30
|
+
{ rank: 10, slug: "j", name: "Pizzeria Dino" },
|
|
31
|
+
{ rank: 11, slug: "k", name: "Motor Rock Bistro" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const meta: Meta<typeof TopRankingLegend> = {
|
|
37
|
+
title: "Top/TopRankingLegend",
|
|
38
|
+
component: TopRankingLegend,
|
|
39
|
+
tags: ["autodocs"],
|
|
40
|
+
args: { groups, heading: "The ranking", meta: "25 places · updated September 2026" },
|
|
41
|
+
decorators: [
|
|
42
|
+
(Story) => (
|
|
43
|
+
<div className="w-[280px] bg-background p-4">
|
|
44
|
+
<Story />
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
parameters: {
|
|
49
|
+
docs: {
|
|
50
|
+
description: {
|
|
51
|
+
component:
|
|
52
|
+
"The ranking as a place you are inside of, not a table. It was rank / name / grade with a rule under every row: the **grade column** was the loudest thing in it and the least useful for navigating — twenty-five near-identical numbers duplicating a grade shown at full size beside them, eating the width that truncated the names. The **dividers** ran full-bleed to the column edge so nothing ever ended the aside. And there was **no active row at all**, in a component whose whole job is orientation. Now: no grade, no rules, grouped by grade band, and the current entry tracked by an `IntersectionObserver` over a narrow band near the top of the viewport. The **rank numeral is the rail** — a status ring would promise completion state we do not track and would never fill. Below `lg` it is the flat chip row, unchanged.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default meta;
|
|
59
|
+
type Story = StoryObj<typeof TopRankingLegend>;
|
|
60
|
+
|
|
61
|
+
/** No entry is active without a scrolling article behind it — that is correct here. */
|
|
62
|
+
export const Default: Story = {};
|
|
63
|
+
|
|
64
|
+
/** Long names wrap rather than truncate: the label you navigate by is never cut. */
|
|
65
|
+
export const LongNames: Story = {
|
|
66
|
+
args: { groups: [groups[1]] },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The phone keeps the flat chip row. */
|
|
70
|
+
export const Mobile: Story = {
|
|
71
|
+
decorators: [
|
|
72
|
+
(Story) => (
|
|
73
|
+
<div className="w-[390px] bg-background p-3">
|
|
74
|
+
<Story />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TopRankingLegend — the ranking as a place you are inside of, not a table.
|
|
8
|
+
*
|
|
9
|
+
* ## What it stopped being
|
|
10
|
+
*
|
|
11
|
+
* It was a two-column table: rank, name, grade, with a rule under every row.
|
|
12
|
+
* Three things were wrong with that, and they compounded.
|
|
13
|
+
*
|
|
14
|
+
* The **grade column** was the loudest thing in it and the least useful thing
|
|
15
|
+
* for navigating — twenty-five near-identical numbers in accent colour,
|
|
16
|
+
* duplicating a grade shown at full size two inches to the right, and eating
|
|
17
|
+
* the width that was truncating the names. The one field you navigate by was
|
|
18
|
+
* the one being cut. It is gone.
|
|
19
|
+
*
|
|
20
|
+
* The **dividers** ran full-bleed to the column's right edge, so every rule was
|
|
21
|
+
* a small arrow pointing at the article and nothing ever visually ended the
|
|
22
|
+
* aside. Space separates now; contrast is spent on state instead.
|
|
23
|
+
*
|
|
24
|
+
* And there was **no active row at all** — twenty-five identical entries in a
|
|
25
|
+
* component whose entire job is orientation. That is what the observer below
|
|
26
|
+
* is for.
|
|
27
|
+
*
|
|
28
|
+
* ## The rank numeral is the rail
|
|
29
|
+
*
|
|
30
|
+
* The reference this is drawn from uses a status ring down the left edge. A
|
|
31
|
+
* ring is wrong here: it promises completion state we do not track and would
|
|
32
|
+
* never fill. The rank numeral is already present, already tabular, and is
|
|
33
|
+
* itself the thing being ranked — a better rail, for free.
|
|
34
|
+
*
|
|
35
|
+
* ## Grouped, because twenty-five is not a list you can hold
|
|
36
|
+
*
|
|
37
|
+
* By grade band, which is the one honest grouping available: the tiers are
|
|
38
|
+
* already printed on the entries, so the aside is not inventing a taxonomy. It
|
|
39
|
+
* also says something true — that the top few are a different class.
|
|
40
|
+
*
|
|
41
|
+
* The column will still exceed the viewport at twenty-five, and that is
|
|
42
|
+
* accepted rather than solved: an internally-scrolling sidebar is a second page,
|
|
43
|
+
* and grouping plus a live active row makes the overflow navigable.
|
|
44
|
+
*
|
|
45
|
+
* ## The phone keeps its chips
|
|
46
|
+
*
|
|
47
|
+
* Below `lg` this is a horizontal scroller of the flat list, unchanged. None of
|
|
48
|
+
* the above applies to a chip row — there is nothing to group, no column to
|
|
49
|
+
* inset, and the active state is a press away rather than a scroll away.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export type TopRankingLegendEntry = {
|
|
53
|
+
rank: number;
|
|
54
|
+
slug: string;
|
|
55
|
+
name: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type TopRankingLegendGroup = {
|
|
59
|
+
key: string;
|
|
60
|
+
/** Grade band, e.g. "Exceptional". Letterspaced caps — structure, not content. */
|
|
61
|
+
label: string;
|
|
62
|
+
entries: TopRankingLegendEntry[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type TopRankingLegendProps = {
|
|
66
|
+
groups: TopRankingLegendGroup[];
|
|
67
|
+
/** e.g. "The ranking". */
|
|
68
|
+
heading: string;
|
|
69
|
+
/** The size and freshness of the thing, e.g. "25 places · updated September 2026". */
|
|
70
|
+
meta?: string;
|
|
71
|
+
className?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Only entries crossing the upper fifth of the viewport count as current.
|
|
76
|
+
*
|
|
77
|
+
* A plain `isIntersecting` marks every entry on screen — three or four at
|
|
78
|
+
* desktop height — and the legend flickers between them. Narrowing the band to
|
|
79
|
+
* a strip near the top means "current" is the entry you are reading rather than
|
|
80
|
+
* the entries you can see.
|
|
81
|
+
*/
|
|
82
|
+
const ACTIVE_BAND = "-20% 0px -75% 0px";
|
|
83
|
+
|
|
84
|
+
export function TopRankingLegend({ groups, heading, meta, className }: TopRankingLegendProps) {
|
|
85
|
+
const entries = groups.flatMap((group) => group.entries);
|
|
86
|
+
const [activeSlug, setActiveSlug] = useState<string | null>(null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (typeof IntersectionObserver === "undefined") return undefined;
|
|
90
|
+
|
|
91
|
+
const nodes = entries
|
|
92
|
+
.map((entry) => document.getElementById(entry.slug))
|
|
93
|
+
.filter((node): node is HTMLElement => Boolean(node));
|
|
94
|
+
if (nodes.length === 0) return undefined;
|
|
95
|
+
|
|
96
|
+
const observer = new IntersectionObserver(
|
|
97
|
+
(observed) => {
|
|
98
|
+
const inBand = observed.filter((record) => record.isIntersecting);
|
|
99
|
+
// Nothing in the band means the reader is BETWEEN entries — mid-way
|
|
100
|
+
// through a long one. Holding the last value is right; clearing it
|
|
101
|
+
// would blink the active row off on every gap.
|
|
102
|
+
if (inBand.length === 0) return;
|
|
103
|
+
const topmost = inBand.reduce((best, record) =>
|
|
104
|
+
record.boundingClientRect.top < best.boundingClientRect.top ? record : best
|
|
105
|
+
);
|
|
106
|
+
setActiveSlug(topmost.target.id);
|
|
107
|
+
},
|
|
108
|
+
{ rootMargin: ACTIVE_BAND }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
nodes.forEach((node) => observer.observe(node));
|
|
112
|
+
return () => observer.disconnect();
|
|
113
|
+
// Re-observes when the ranking itself changes, not on every render.
|
|
114
|
+
}, [entries.map((entry) => entry.slug).join("|")]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
/*
|
|
118
|
+
* The height cap is a safety net, not the navigation model.
|
|
119
|
+
*
|
|
120
|
+
* `position: sticky` on an element TALLER than the viewport leaves its
|
|
121
|
+
* bottom permanently unreachable — at twenty-five rows on a 900px laptop
|
|
122
|
+
* you could never see ranks 20–25, however you scrolled. That is broken
|
|
123
|
+
* rather than merely tight, so the column scrolls internally once it
|
|
124
|
+
* exceeds the space available.
|
|
125
|
+
*
|
|
126
|
+
* It only ever engages on a viewport too short to fit the list: `max-height`
|
|
127
|
+
* with `overflow-y-auto` is inert when the content already fits, so nothing
|
|
128
|
+
* changes on a tall screen. `overscroll-contain` stops a flick inside the
|
|
129
|
+
* column from carrying on into the article behind it.
|
|
130
|
+
*/
|
|
131
|
+
<nav
|
|
132
|
+
aria-label={heading}
|
|
133
|
+
className={cn(
|
|
134
|
+
/*
|
|
135
|
+
* Sticky at BOTH widths now, with a different offset and a different
|
|
136
|
+
* shape at each.
|
|
137
|
+
*
|
|
138
|
+
* On desktop it is the grouped column, pinned beside the article. On a
|
|
139
|
+
* phone it is the flat chip rail, pinned for the same reason the column
|
|
140
|
+
* is: this page is twenty-five long entries, so a reader at rank 14 is
|
|
141
|
+
* several screens from any means of jumping, and the rail scrolled away
|
|
142
|
+
* inside the first entry.
|
|
143
|
+
*
|
|
144
|
+
* The phone offset comes from `--legend-sticky-top`, which the CALLER
|
|
145
|
+
* sets, because it is that app's chrome — the consumer app pins under a
|
|
146
|
+
* fixed header at `calc(var(--header-offset) + env(safe-area-inset-top))`,
|
|
147
|
+
* exactly as its events day rail does. Reading the app's own
|
|
148
|
+
* `--header-offset` here would resolve to nothing in Storybook and in
|
|
149
|
+
* the other three consumers, and an invalid `calc` silently kills
|
|
150
|
+
* `top`, which turns the rail from mispositioned into not-sticky.
|
|
151
|
+
*/
|
|
152
|
+
"sticky top-[var(--legend-sticky-top,0px)] z-20 lg:top-4 lg:z-auto",
|
|
153
|
+
"lg:max-h-[calc(100vh-2rem)] lg:self-start lg:overflow-y-auto lg:overscroll-contain",
|
|
154
|
+
"lg:[scrollbar-width:thin]",
|
|
155
|
+
className
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{/* Header block. The reference opens with the size of the thing before a
|
|
159
|
+
single row — ours is the count and the date, which were previously
|
|
160
|
+
buried in a footnote under twenty-five rows nobody scrolls past. */}
|
|
161
|
+
<div className="hidden lg:block">
|
|
162
|
+
<h2 className="text-[15px] font-bold tracking-[-0.01em]">{heading}</h2>
|
|
163
|
+
{meta && <p className="mt-1 text-[11.5px] text-muted-foreground">{meta}</p>}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Phone: the flat chip rail.
|
|
167
|
+
|
|
168
|
+
The negative margin bleeds the band to both edges, so once the `<nav>`
|
|
169
|
+
is pinned it covers the article passing underneath — untinted, the
|
|
170
|
+
entries would scroll straight through the chips.
|
|
171
|
+
|
|
172
|
+
OPAQUE, not the events day rail's translucent-plus-blur. That rail
|
|
173
|
+
passes over body text, where a blur reads as depth; this one passes
|
|
174
|
+
over 25 photographs, and at 85% a cover's dark edge showed straight
|
|
175
|
+
through the chips — the strip stopped being a surface and started
|
|
176
|
+
looking like a rendering fault. `pb-3` is the rail's own breathing
|
|
177
|
+
room: at `py-0` the chips sat hard against the section heading below
|
|
178
|
+
and read as part of it. */}
|
|
179
|
+
<ul className="scrollbar-hide -mx-[var(--page-gutter)] flex gap-2 overflow-x-auto bg-background px-[var(--page-gutter)] pb-3 pt-1 lg:hidden">
|
|
180
|
+
{entries.map((entry) => (
|
|
181
|
+
<li key={entry.slug} className="shrink-0">
|
|
182
|
+
<a
|
|
183
|
+
href={`#${entry.slug}`}
|
|
184
|
+
className="inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-full border border-border bg-card px-3.5 text-[13px] transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
|
|
185
|
+
>
|
|
186
|
+
<span className="font-bold tabular-nums text-muted-foreground">{entry.rank}</span>
|
|
187
|
+
{entry.name}
|
|
188
|
+
</a>
|
|
189
|
+
</li>
|
|
190
|
+
))}
|
|
191
|
+
</ul>
|
|
192
|
+
|
|
193
|
+
{/* Desktop: grouped column. `-mx-3` so the active pill reaches the column
|
|
194
|
+
edge while the text stays inset — the separation lives inside the
|
|
195
|
+
column, which is most of why the reference breathes. */}
|
|
196
|
+
<div className="hidden lg:mt-5 lg:-mx-3 lg:block">
|
|
197
|
+
{groups.map((group) => (
|
|
198
|
+
<div key={group.key} className="mb-4 last:mb-0">
|
|
199
|
+
<p className="px-3 pb-1.5 text-[10.5px] font-bold uppercase tracking-[0.11em] text-muted-foreground">
|
|
200
|
+
{group.label}
|
|
201
|
+
</p>
|
|
202
|
+
<ul>
|
|
203
|
+
{group.entries.map((entry) => {
|
|
204
|
+
const isActive = entry.slug === activeSlug;
|
|
205
|
+
return (
|
|
206
|
+
<li key={entry.slug}>
|
|
207
|
+
<a
|
|
208
|
+
href={`#${entry.slug}`}
|
|
209
|
+
aria-current={isActive ? "location" : undefined}
|
|
210
|
+
className={cn(
|
|
211
|
+
"flex gap-2.5 rounded-lg px-3 py-2 text-[13px] leading-snug transition-colors motion-reduce:transition-none",
|
|
212
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
213
|
+
isActive
|
|
214
|
+
? "bg-accent-subtle font-semibold text-foreground"
|
|
215
|
+
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
<span
|
|
219
|
+
className={cn(
|
|
220
|
+
"w-5 shrink-0 text-right font-bold tabular-nums",
|
|
221
|
+
isActive ? "text-accent" : "text-muted-foreground/70"
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{entry.rank}
|
|
225
|
+
</span>
|
|
226
|
+
{/* Wraps rather than truncates: the label you navigate by
|
|
227
|
+
is the one thing that must never be cut. */}
|
|
228
|
+
<span className="min-w-0 flex-1">{entry.name}</span>
|
|
229
|
+
</a>
|
|
230
|
+
</li>
|
|
231
|
+
);
|
|
232
|
+
})}
|
|
233
|
+
</ul>
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
</nav>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editorial ranking's view model — `/top/[category]`.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `menu-types.ts` on purpose. The menu surfaces describe what a
|
|
5
|
+
* city HAS; these describe an argument the product is making about it, and the
|
|
6
|
+
* shapes reflect that: an entry carries a cited quote and a dated price because
|
|
7
|
+
* a rank is a claim and the page has to show why.
|
|
8
|
+
*
|
|
9
|
+
* Same contract as everywhere else: no app schemas, no hooks, no i18n. Strings
|
|
10
|
+
* arrive through labels, destinations through `href`, numbers through injected
|
|
11
|
+
* formatters.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
|
|
16
|
+
import type { MenuItemPreview } from './menu-types'
|
|
17
|
+
|
|
18
|
+
/** One entry in the ranking. */
|
|
19
|
+
export type TopRankEntry = {
|
|
20
|
+
rank: number
|
|
21
|
+
slug: string
|
|
22
|
+
name: string
|
|
23
|
+
/** The venue page. */
|
|
24
|
+
href: string
|
|
25
|
+
/** Neighbourhood, e.g. "Centru". */
|
|
26
|
+
district?: string
|
|
27
|
+
/** Pre-resolved venue photograph. */
|
|
28
|
+
coverUrl?: string
|
|
29
|
+
/** The app's grade, already formatted — "9.4". Absent where none exists. */
|
|
30
|
+
gradeLabel?: string
|
|
31
|
+
/** The band that grade falls in, e.g. "Exceptional". Composed by the caller. */
|
|
32
|
+
tierLabel?: string
|
|
33
|
+
/** Third-party star rating, shown only where there is no grade. */
|
|
34
|
+
rating?: number
|
|
35
|
+
/**
|
|
36
|
+
* The editorial line — why this one is here, in the product's own voice.
|
|
37
|
+
*
|
|
38
|
+
* Distinct from `evidence`, which is a reviewer's words. Conflating them was
|
|
39
|
+
* the old page's problem: a quote presented as editorial reads as invented,
|
|
40
|
+
* and editorial presented as a quote reads as dishonest.
|
|
41
|
+
*/
|
|
42
|
+
editorial?: string
|
|
43
|
+
/**
|
|
44
|
+
* The dishes this venue is named for, as PRODUCTION MENU ROWS.
|
|
45
|
+
*
|
|
46
|
+
* `MenuItemPreview`, so the entry renders the same `<MenuItem>` a venue's own
|
|
47
|
+
* menu and every hub surface renders. The first pass here hand-rolled a row —
|
|
48
|
+
* a bold name, a muted description, a price — which drifted from the real one
|
|
49
|
+
* immediately and meant a dish looked like a different object on this page
|
|
50
|
+
* than on every other.
|
|
51
|
+
*
|
|
52
|
+
* More than one, because a rank is a claim about a kitchen and one dish
|
|
53
|
+
* rarely carries it: the live page shows the group and two or three of its
|
|
54
|
+
* most-named plates.
|
|
55
|
+
*/
|
|
56
|
+
menuItems?: MenuItemPreview[]
|
|
57
|
+
/** The group those items sit in, e.g. "Pizza". From the venue's own menu. */
|
|
58
|
+
menuGroupLabel?: string
|
|
59
|
+
/** A reviewer's own words, and how many said it. */
|
|
60
|
+
evidence?: TopEvidence
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TopEvidence = {
|
|
64
|
+
/**
|
|
65
|
+
* A reviewer's own words. OPTIONAL: a venue can carry a mention count with no
|
|
66
|
+
* quotable snippet behind it, and the count is evidence in its own right —
|
|
67
|
+
* dropping the whole block for want of a quote loses the number that
|
|
68
|
+
* justifies the rank.
|
|
69
|
+
*/
|
|
70
|
+
quote?: string
|
|
71
|
+
/**
|
|
72
|
+
* How many reviews back it — the figure alone, e.g. "37". Rendered as its own
|
|
73
|
+
* element beside `sourceLabel` so the number reads as a figure rather than as
|
|
74
|
+
* the first word of a sentence.
|
|
75
|
+
*/
|
|
76
|
+
count?: string
|
|
77
|
+
/** What the count is evidence OF, e.g. "reviews praise the shawarma here". */
|
|
78
|
+
sourceLabel: string
|
|
79
|
+
/**
|
|
80
|
+
* The rest of the cited reviews, behind a disclosure.
|
|
81
|
+
*
|
|
82
|
+
* A `<details>`, not a drawer, and that is the whole point: its contents stay
|
|
83
|
+
* in the HTML, so the evidence is crawlable and readable with no JavaScript.
|
|
84
|
+
* A page whose entire argument is "here is why" cannot ship most of the why
|
|
85
|
+
* in a modal.
|
|
86
|
+
*/
|
|
87
|
+
extraQuotes?: string[]
|
|
88
|
+
/** Summary text for the disclosure, e.g. "2 more reviews". Pluralised by the caller. */
|
|
89
|
+
extraLabel?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One row of the side-by-side comparison. */
|
|
93
|
+
export type TopComparisonRow = {
|
|
94
|
+
rank: number
|
|
95
|
+
name: string
|
|
96
|
+
href: string
|
|
97
|
+
gradeLabel?: string
|
|
98
|
+
signature?: string
|
|
99
|
+
priceLabel?: string
|
|
100
|
+
/** Kitchen serving past midnight. */
|
|
101
|
+
late?: boolean
|
|
102
|
+
terrace?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A way out of the article and into the browsing tool. */
|
|
106
|
+
export type TopExit = {
|
|
107
|
+
label: string
|
|
108
|
+
href: string
|
|
109
|
+
icon?: ReactNode
|
|
110
|
+
}
|
|
@@ -24,7 +24,7 @@ import type { PlaceGradeDimensions } from "./place-types";
|
|
|
24
24
|
export type VenueGradeDimensionKey = keyof PlaceGradeDimensions;
|
|
25
25
|
|
|
26
26
|
/** Canonical order. Quality leads, and the three quality keys are mutually exclusive. */
|
|
27
|
-
const
|
|
27
|
+
export const DIMENSION_ORDER: VenueGradeDimensionKey[] = [
|
|
28
28
|
"food",
|
|
29
29
|
"coffee_quality",
|
|
30
30
|
"drinks_quality",
|
|
@@ -50,7 +50,7 @@ export type VenueGradeDimensionsProps = {
|
|
|
50
50
|
export function VenueGradeDimensions({
|
|
51
51
|
dimensions,
|
|
52
52
|
labels,
|
|
53
|
-
order =
|
|
53
|
+
order = DIMENSION_ORDER,
|
|
54
54
|
formatScore,
|
|
55
55
|
className,
|
|
56
56
|
}: VenueGradeDimensionsProps) {
|
|
@@ -19,7 +19,7 @@ const meta: Meta<typeof VenueQuotes> = {
|
|
|
19
19
|
docs: {
|
|
20
20
|
description: {
|
|
21
21
|
component:
|
|
22
|
-
"The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in serif
|
|
22
|
+
"The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in quoted serif against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
|
|
23
23
|
},
|
|
24
24
|
},
|
|
25
25
|
},
|