@bearmenu/ui 0.8.9 → 0.8.10
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-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- 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-MZL7CGCL.js +109 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/command.d.ts +7 -7
- 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/date-input.d.ts +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 +1 -1
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- 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/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- 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/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- 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 +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +33 -27
- package/dist/components/menu-family-card.js +43 -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-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-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/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 +2 -2
- 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/snap-rail.d.ts +24 -1
- package/dist/components/snap-rail.js +3 -109
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/text.d.ts +3 -3
- 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/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +122 -54
- package/src/components/concept-compare.tsx +363 -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 +129 -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 +322 -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 +314 -0
- package/src/components/menu-family-card.stories.tsx +43 -35
- package/src/components/menu-family-card.tsx +122 -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-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/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/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,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Wallet } from "lucide-react";
|
|
3
|
+
import { HueChip } from "./hue-chip";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof HueChip> = {
|
|
6
|
+
title: "Menu/HueChip",
|
|
7
|
+
component: HueChip,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: { children: "Sweet Pastry", hue: "oklch(0.62 0.14 82)" },
|
|
10
|
+
parameters: {
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
"A pill wearing a category's colour as a pale wash. It exists because the wash has one non-obvious rule — the mix must happen `in oklab`, never `in oklch` — and that rule has to live in one place. oklch is polar, so mixing a hue into an achromatic `--card` interpolates the hue ANGLE toward 0: honey 82° arrives at 16° (pink), a bar's 205° blue at 41° (orange). The hue is runtime data from ~40 cuisines, so it is an inline custom property rather than a Tailwind class — Tailwind can only emit what it sees at build time.",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof HueChip>;
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The bug this component was extracted to prevent. Every hue survives the mix
|
|
27
|
+
* with its own identity — the cool families especially, which under `in oklch`
|
|
28
|
+
* came out orange and mustard.
|
|
29
|
+
*/
|
|
30
|
+
export const EveryFamily: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<div className="flex flex-wrap gap-1.5">
|
|
33
|
+
{[
|
|
34
|
+
["Breakfast & Brunch", "oklch(0.62 0.14 82)"],
|
|
35
|
+
["Romanian", "oklch(0.48 0.16 25)"],
|
|
36
|
+
["Italian", "oklch(0.56 0.15 38)"],
|
|
37
|
+
["Vegetarian", "oklch(0.52 0.14 148)"],
|
|
38
|
+
["Bars", "oklch(0.47 0.10 205)"],
|
|
39
|
+
["Clubs", "oklch(0.42 0.11 300)"],
|
|
40
|
+
].map(([label, hue]) => (
|
|
41
|
+
<HueChip key={label} hue={hue}>
|
|
42
|
+
{label}
|
|
43
|
+
</HueChip>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** No hue — `--muted`, the right neutral for a facet with no family behind it. */
|
|
50
|
+
export const Untinted: Story = {
|
|
51
|
+
args: { hue: undefined },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A price band: an icon, a bare figure, and an accessible name for it. */
|
|
55
|
+
export const WithIcon: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
hue: undefined,
|
|
58
|
+
className: "font-bold tabular-nums",
|
|
59
|
+
"aria-label": "kitchen price band: 22–86 lei",
|
|
60
|
+
icon: <Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />,
|
|
61
|
+
children: "22–86 lei",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** In a list, which is where most of these live. */
|
|
66
|
+
export const InAList: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<ul className="flex flex-wrap items-center gap-1.5">
|
|
69
|
+
{["Sweet Pastry", "Salad", "Cake"].map((label) => (
|
|
70
|
+
<HueChip key={label} as="li" hue="oklch(0.62 0.14 82)">
|
|
71
|
+
{label}
|
|
72
|
+
</HueChip>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
),
|
|
76
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { HueChip, hueTint } from "./hue-chip";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The whole reason this component exists is one character of CSS, so that is
|
|
7
|
+
* what is pinned. `color-mix(in oklch, …)` interpolates the hue ANGLE, and the
|
|
8
|
+
* paper it mixes into is achromatic (`--card` is `oklch(1 0 0)`, hue 0), so
|
|
9
|
+
* every family colour was dragged a proportional fraction of the way to red:
|
|
10
|
+
* honey 82° arrived at 16°, pink. It is invisible in a diff and obvious on
|
|
11
|
+
* screen, which is exactly the kind of thing a test should hold.
|
|
12
|
+
*/
|
|
13
|
+
describe("hueTint", () => {
|
|
14
|
+
it("mixes in the rectangular space, never the polar one", () => {
|
|
15
|
+
const tint = hueTint("oklch(0.62 0.14 82)");
|
|
16
|
+
expect(tint).toContain("in oklab");
|
|
17
|
+
expect(tint).not.toContain("in oklch,");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("mixes into the surface the chip sits on", () => {
|
|
21
|
+
expect(hueTint("oklch(0.62 0.14 82)")).toContain("var(--card)");
|
|
22
|
+
expect(hueTint("oklch(0.62 0.14 82)", "chip", "var(--background)")).toContain(
|
|
23
|
+
"var(--background)"
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("gives a subtle chip less of the hue than a plain one", () => {
|
|
28
|
+
expect(hueTint("oklch(0.62 0.14 82)", "chip")).toContain("20%");
|
|
29
|
+
expect(hueTint("oklch(0.62 0.14 82)", "subtle")).toContain("12%");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("HueChip", () => {
|
|
34
|
+
it("wears the tint when it has a hue", () => {
|
|
35
|
+
render(<HueChip hue="oklch(0.62 0.14 82)">Sweet Pastry</HueChip>);
|
|
36
|
+
const chip = screen.getByText("Sweet Pastry");
|
|
37
|
+
expect(chip.getAttribute("style")).toContain("oklab");
|
|
38
|
+
expect(chip.className).not.toContain("bg-muted");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to the neutral rather than to a colour", () => {
|
|
42
|
+
// A facet with no family behind it must not borrow another family's colour.
|
|
43
|
+
render(<HueChip>Fast Food</HueChip>);
|
|
44
|
+
const chip = screen.getByText("Fast Food");
|
|
45
|
+
expect(chip.className).toContain("bg-muted");
|
|
46
|
+
expect(chip.getAttribute("style")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders as a list item when asked, for the lists it lives in", () => {
|
|
50
|
+
render(
|
|
51
|
+
<ul>
|
|
52
|
+
<HueChip as="li">Salad</HueChip>
|
|
53
|
+
</ul>
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByText("Salad").tagName).toBe("LI");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("takes an accessible name for a chip whose text is a bare figure", () => {
|
|
59
|
+
render(<HueChip aria-label="kitchen price band: 22–86 lei">22–86 lei</HueChip>);
|
|
60
|
+
expect(screen.getByLabelText("kitchen price band: 22–86 lei")).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HueChip — a pill that wears a category's colour as a pale wash.
|
|
5
|
+
*
|
|
6
|
+
* Menu surfaces label things with a family's hue: a cuisine's dish groups, a
|
|
7
|
+
* going-out family's tags, a price band. Every one of them was an inline
|
|
8
|
+
* `style` with a hand-written `color-mix` next to a hand-written pill class
|
|
9
|
+
* list, which is how the two below drifted apart and how the mix space bug
|
|
10
|
+
* survived as long as it did — it had to be found and fixed in each copy.
|
|
11
|
+
*
|
|
12
|
+
* ## The mix space is the whole reason this is a component
|
|
13
|
+
*
|
|
14
|
+
* **`in oklab`, and never `in oklch`.** They describe the same colours, but
|
|
15
|
+
* oklch is POLAR, so `color-mix(in oklch, …)` interpolates the hue ANGLE — and
|
|
16
|
+
* the paper being mixed into is achromatic (`--card` is `oklch(1 0 0)`, hue 0).
|
|
17
|
+
* Mixing 20% of a family colour into that lands at a fifth of the family's hue:
|
|
18
|
+
* honey 82° came out at 16°, which is pink, so a Breakfast & Brunch card wore
|
|
19
|
+
* pink chips under an amber mosaic. Cool families were worse — a bar's 205°
|
|
20
|
+
* blue mixes to 41°, orange; a club's 300° violet to 60°, mustard.
|
|
21
|
+
*
|
|
22
|
+
* oklab is rectangular: mixing toward an achromatic base scales chroma along
|
|
23
|
+
* the SAME hue instead of rotating it, which is what "a pale version of this
|
|
24
|
+
* colour" has to mean.
|
|
25
|
+
*
|
|
26
|
+
* ## Why an inline style and not a Tailwind class
|
|
27
|
+
*
|
|
28
|
+
* The hue is DATA — one of ~40 cuisine values resolved by the app at request
|
|
29
|
+
* time — not one of a fixed set of variants. Tailwind can only emit classes it
|
|
30
|
+
* can see at build time, so a class-based version would mean either a
|
|
31
|
+
* safelisted colour per cuisine (a palette that must be edited to add a
|
|
32
|
+
* cuisine) or an arbitrary-value class built from a runtime string, which
|
|
33
|
+
* Tailwind cannot compile. A CSS custom property set inline is the honest
|
|
34
|
+
* mechanism; this component is the one place that writes it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Mix strengths, so callers say what a chip IS rather than picking a number. */
|
|
38
|
+
const STRENGTH = {
|
|
39
|
+
/** A labelled facet: readable at 11.5px, still clearly the family's colour. */
|
|
40
|
+
chip: 20,
|
|
41
|
+
/** A quieter wash, for a chip that sits beside a stronger one. */
|
|
42
|
+
subtle: 12,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export type HueChipTone = keyof typeof STRENGTH;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The tint itself, exported for the surfaces that need the colour rather than
|
|
49
|
+
* the pill — a section ground, a hover state. Same rule: `in oklab`.
|
|
50
|
+
*/
|
|
51
|
+
export function hueTint(hue: string, tone: HueChipTone = "chip", base = "var(--card)"): string {
|
|
52
|
+
return `color-mix(in oklab, ${hue} ${STRENGTH[tone]}%, ${base})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type HueChipProps = {
|
|
56
|
+
/**
|
|
57
|
+
* A finished CSS colour — the DS owns no category table, so the app resolves
|
|
58
|
+
* it. Without one the chip falls back to `--muted`, which is the right
|
|
59
|
+
* neutral for a facet that has no family behind it.
|
|
60
|
+
*/
|
|
61
|
+
hue?: string;
|
|
62
|
+
tone?: HueChipTone;
|
|
63
|
+
/** The paper the wash is mixed into. Must be the surface the chip sits on. */
|
|
64
|
+
base?: string;
|
|
65
|
+
/** Drawn before the label, e.g. a wallet on a price band. */
|
|
66
|
+
icon?: React.ReactNode;
|
|
67
|
+
/** `li` inside a list, which is where most of these live. */
|
|
68
|
+
as?: "span" | "li" | "div";
|
|
69
|
+
children: React.ReactNode;
|
|
70
|
+
className?: string;
|
|
71
|
+
/** For a chip whose visible text is a bare figure, e.g. "22–86 lei". */
|
|
72
|
+
"aria-label"?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function HueChip({
|
|
76
|
+
hue,
|
|
77
|
+
tone = "chip",
|
|
78
|
+
base = "var(--card)",
|
|
79
|
+
icon,
|
|
80
|
+
as: Tag = "span",
|
|
81
|
+
children,
|
|
82
|
+
className,
|
|
83
|
+
"aria-label": ariaLabel,
|
|
84
|
+
}: HueChipProps) {
|
|
85
|
+
return (
|
|
86
|
+
<Tag
|
|
87
|
+
aria-label={ariaLabel}
|
|
88
|
+
className={cn(
|
|
89
|
+
"inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-foreground",
|
|
90
|
+
!hue && "bg-muted",
|
|
91
|
+
className
|
|
92
|
+
)}
|
|
93
|
+
style={hue ? { backgroundColor: hueTint(hue, tone, base) } : undefined}
|
|
94
|
+
>
|
|
95
|
+
{icon}
|
|
96
|
+
{children}
|
|
97
|
+
</Tag>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ArrowRight } from "lucide-react";
|
|
3
|
+
import { KitchenDossier } from "./kitchen-dossier";
|
|
4
|
+
import { mockVenues } from "./menu-mocks";
|
|
5
|
+
|
|
6
|
+
function photo(hue: number) {
|
|
7
|
+
return (
|
|
8
|
+
"data:image/svg+xml;utf8," +
|
|
9
|
+
encodeURIComponent(
|
|
10
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="960" height="640" fill="url(#g)"/><circle cx="240" cy="180" r="120" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="520" y="360" width="360" height="220" fill="hsl(${hue} 15% 20%)" opacity=".7"/></svg>`
|
|
11
|
+
)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const venue = mockVenues[0];
|
|
16
|
+
|
|
17
|
+
const labels = {
|
|
18
|
+
knownFor: "Known for",
|
|
19
|
+
whatPeopleSay: "What people say",
|
|
20
|
+
theGrade: "The grade",
|
|
21
|
+
currency: "lei",
|
|
22
|
+
mentions: (count: number) => `${count} name it`,
|
|
23
|
+
reviewCount: "427 reviews",
|
|
24
|
+
openUntil: "Open until 22:00",
|
|
25
|
+
part: (index: number, count: number) => `Part ${index + 1} of ${count}`,
|
|
26
|
+
next: "Next",
|
|
27
|
+
dimensions: {
|
|
28
|
+
food: "Food",
|
|
29
|
+
service: "Service",
|
|
30
|
+
value: "Value",
|
|
31
|
+
ambiance: "Ambiance",
|
|
32
|
+
portion: "Portion",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const action = (
|
|
37
|
+
<a
|
|
38
|
+
href={venue.href}
|
|
39
|
+
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-5 text-[13.5px] font-bold text-[oklch(0.21_0.012_55)]"
|
|
40
|
+
>
|
|
41
|
+
Open the menu
|
|
42
|
+
<ArrowRight className="h-4 w-4" aria-hidden />
|
|
43
|
+
</a>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const meta: Meta<typeof KitchenDossier> = {
|
|
47
|
+
title: "Menu/KitchenDossier",
|
|
48
|
+
component: KitchenDossier,
|
|
49
|
+
tags: ["autodocs"],
|
|
50
|
+
decorators: [
|
|
51
|
+
(Story) => (
|
|
52
|
+
<div className="w-[390px] bg-background p-4">
|
|
53
|
+
<Story />
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
args: {
|
|
58
|
+
place: { ...venue.place, thumbnail_url: photo(30) },
|
|
59
|
+
href: venue.href,
|
|
60
|
+
meta: "Restaurant · Centru",
|
|
61
|
+
signature: venue.signature,
|
|
62
|
+
quotes: [
|
|
63
|
+
{ text: "Complimentary still or sparkling water with every meal, plus free coffee with brunch dishes." },
|
|
64
|
+
{ text: "Incredibly attentive and friendly service that creates a truly relaxing experience." },
|
|
65
|
+
],
|
|
66
|
+
grade: {
|
|
67
|
+
overall: "9.4",
|
|
68
|
+
dimensions: { food: 91, service: 90, value: 91, ambiance: 87, portion: 85 },
|
|
69
|
+
},
|
|
70
|
+
labels,
|
|
71
|
+
formatPrice: (amount: number) => String(amount),
|
|
72
|
+
formatScore: (value: number) => (value / 10).toFixed(1),
|
|
73
|
+
action,
|
|
74
|
+
},
|
|
75
|
+
parameters: {
|
|
76
|
+
docs: {
|
|
77
|
+
description: {
|
|
78
|
+
component:
|
|
79
|
+
"One kitchen, told as a story on its own photograph. The room is clear at the top; a frosted caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default meta;
|
|
86
|
+
type Story = StoryObj<typeof KitchenDossier>;
|
|
87
|
+
|
|
88
|
+
export const Phone: Story = {};
|
|
89
|
+
|
|
90
|
+
export const Desktop: Story = {
|
|
91
|
+
decorators: [
|
|
92
|
+
(Story) => (
|
|
93
|
+
<div className="w-[1100px] bg-background p-4">
|
|
94
|
+
<Story />
|
|
95
|
+
</div>
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** One part only: no segments, no tap-to-advance, the panel just is. */
|
|
101
|
+
export const OnePart: Story = {
|
|
102
|
+
args: { quotes: undefined, grade: undefined },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** No photograph: the ground with the initial. */
|
|
106
|
+
export const NoPhoto: Story = {
|
|
107
|
+
args: { place: venue.place },
|
|
108
|
+
};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { MessageSquareQuote, Star } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { GlassGround } from "./glass-ground";
|
|
5
|
+
import type { MenuLinkComponent, MenuLinkProps, MenuVenueSignature } from "./menu-types";
|
|
6
|
+
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
7
|
+
import type { CitedText, PlaceGradeDimensions, PlacePreview } from "./place-types";
|
|
8
|
+
import { Separator } from "./separator";
|
|
9
|
+
import { StoryPanel, StoryProvider, StorySegments } from "./story";
|
|
10
|
+
import {
|
|
11
|
+
DIMENSION_ORDER,
|
|
12
|
+
VenueGradeDimensions,
|
|
13
|
+
type VenueGradeDimensionKey,
|
|
14
|
+
} from "./venue-grade-dimensions";
|
|
15
|
+
import { VenueQuotes } from "./venue-quotes";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* KitchenDossier — one kitchen, told as a story on its own photograph.
|
|
19
|
+
*
|
|
20
|
+
* The hub used to say this three times: a spotlight with everything the
|
|
21
|
+
* digest knows, a ranked row with a grade, a card with the signature dish —
|
|
22
|
+
* and the same kitchen led all three. This is the one card: the venue's
|
|
23
|
+
* photograph clear at the top, a frosted caption rising from the foot with
|
|
24
|
+
* the identity (rating, name, type · area · reviews), and under it a PANEL
|
|
25
|
+
* that says the three or four things the reviews established one at a time —
|
|
26
|
+
* the dish it is known for, what people say, the grade — with segments at the
|
|
27
|
+
* top edge showing which is up. The story runs on its own while the card is
|
|
28
|
+
* in view; a tap advances.
|
|
29
|
+
*
|
|
30
|
+
* ## A photograph with a caption, not a dark card
|
|
31
|
+
*
|
|
32
|
+
* `ConceptCompare` is a dark card with a photograph showing through two glass
|
|
33
|
+
* bands. This is the reverse — the room is the subject and the facts are its
|
|
34
|
+
* caption — which is `GlassGround` at its lightest and smallest: anchored to
|
|
35
|
+
* the CAPTION, not the card, so it covers the identity, the panel and the
|
|
36
|
+
* action plus a short lead above them and nothing else; a `sm` blur that
|
|
37
|
+
* only takes the edge off the detail; ink that never passes 0.45; a text
|
|
38
|
+
* shadow under the identity doing the rest. Everything above the caption is
|
|
39
|
+
* the photograph, untouched. The panel is its own glass, so its rule reads
|
|
40
|
+
* as a panel and not a hole. The same layout at every width; the desktop
|
|
41
|
+
* card is wider, not turned on its side.
|
|
42
|
+
*
|
|
43
|
+
* ## Two targets, and everything else is "next"
|
|
44
|
+
*
|
|
45
|
+
* The name and the action open the venue; the dish name in the first part
|
|
46
|
+
* opens the item. A tap anywhere else on the card advances the story.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
|
|
50
|
+
export type KitchenDossierLinkProps = MenuLinkProps;
|
|
51
|
+
|
|
52
|
+
function DefaultLink({ href, ...props }: KitchenDossierLinkProps) {
|
|
53
|
+
return <a href={href} {...props} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type KitchenDossierLabels = {
|
|
57
|
+
/** Kickers over the three parts. */
|
|
58
|
+
knownFor: string;
|
|
59
|
+
whatPeopleSay: string;
|
|
60
|
+
theGrade: string;
|
|
61
|
+
/** Currency beside the price, e.g. "lei". */
|
|
62
|
+
currency: string;
|
|
63
|
+
/** Beside the mention count, e.g. "4 name it". */
|
|
64
|
+
mentions: (count: number) => string;
|
|
65
|
+
/** "427 reviews", composed by the caller. */
|
|
66
|
+
reviewCount?: string;
|
|
67
|
+
/** "Open until 22:00", composed by the caller. Omit when closed or unknown. */
|
|
68
|
+
openUntil?: string;
|
|
69
|
+
/** Accessible name per segment, e.g. "Part 2 of 3". */
|
|
70
|
+
part: (index: number, count: number) => string;
|
|
71
|
+
/** Accessible name of the tap-to-advance. */
|
|
72
|
+
next: string;
|
|
73
|
+
/** Dimension key → translated label. */
|
|
74
|
+
dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type KitchenDossierProps = {
|
|
78
|
+
place: PlacePreview;
|
|
79
|
+
/** The venue's destination. */
|
|
80
|
+
href: string;
|
|
81
|
+
/** "Restaurant · Centru", composed by the caller. */
|
|
82
|
+
meta?: string;
|
|
83
|
+
/** Part one. */
|
|
84
|
+
signature?: MenuVenueSignature;
|
|
85
|
+
/** Part two — the digest's highlights; two are shown. */
|
|
86
|
+
quotes?: CitedText[];
|
|
87
|
+
/** Part three — the grade, with the overall already formatted. */
|
|
88
|
+
grade?: { overall?: string; dimensions?: PlaceGradeDimensions };
|
|
89
|
+
labels: KitchenDossierLabels;
|
|
90
|
+
formatPrice: (amount: number) => string;
|
|
91
|
+
/** 0–100 → display numeral. */
|
|
92
|
+
formatScore: (value: number) => string;
|
|
93
|
+
/** How long each part shows. */
|
|
94
|
+
partDurationMs?: number;
|
|
95
|
+
/** The foot's action — pass a rendered link or button. */
|
|
96
|
+
action?: ReactNode;
|
|
97
|
+
linkComponent?: MenuLinkComponent;
|
|
98
|
+
className?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How far above the caption the glass begins — the ramp from clear photograph
|
|
103
|
+
* to frost, ending exactly where the identity starts. Long, so the edge is a
|
|
104
|
+
* gradient and not a line. The glass wrapper's `-top-32` is this same 128px;
|
|
105
|
+
* the two must agree, or the identity sits on half-frost.
|
|
106
|
+
*/
|
|
107
|
+
const GLASS_LEAD = "128px";
|
|
108
|
+
|
|
109
|
+
/** How many dimensions the grade part shows: the three strongest. */
|
|
110
|
+
const DIMENSION_LIMIT = 3;
|
|
111
|
+
|
|
112
|
+
/** How many quotes the second part shows. */
|
|
113
|
+
const QUOTE_LIMIT = 2;
|
|
114
|
+
|
|
115
|
+
const initialOf = (name: string) => (name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
116
|
+
|
|
117
|
+
export function KitchenDossier({
|
|
118
|
+
place,
|
|
119
|
+
href,
|
|
120
|
+
meta,
|
|
121
|
+
signature,
|
|
122
|
+
quotes,
|
|
123
|
+
grade,
|
|
124
|
+
labels,
|
|
125
|
+
formatPrice,
|
|
126
|
+
formatScore,
|
|
127
|
+
partDurationMs,
|
|
128
|
+
action,
|
|
129
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
130
|
+
className,
|
|
131
|
+
}: KitchenDossierProps) {
|
|
132
|
+
const rating = place.rating?.rating;
|
|
133
|
+
const parts: ReactNode[] = [];
|
|
134
|
+
|
|
135
|
+
if (signature) {
|
|
136
|
+
const item = signature.item;
|
|
137
|
+
const firstEntry = item.sizes_and_prices?.[0];
|
|
138
|
+
const mentions = item.mentions ?? item.review_count ?? 0;
|
|
139
|
+
parts.push(
|
|
140
|
+
<Part key="signature" kicker={labels.knownFor}>
|
|
141
|
+
{signature.href ? (
|
|
142
|
+
<LinkComponent
|
|
143
|
+
href={signature.href}
|
|
144
|
+
className="w-max max-w-full text-[16px] font-bold leading-tight tracking-[-0.01em] text-white hover:underline focus-visible:outline-none focus-visible:underline"
|
|
145
|
+
>
|
|
146
|
+
{item.name}
|
|
147
|
+
</LinkComponent>
|
|
148
|
+
) : (
|
|
149
|
+
<span className="text-[16px] font-bold leading-tight tracking-[-0.01em] text-white">{item.name}</span>
|
|
150
|
+
)}
|
|
151
|
+
{signature.why && (
|
|
152
|
+
<p className="mt-1 line-clamp-2 font-serif text-[13px] italic leading-snug text-white/75">
|
|
153
|
+
{signature.why}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
{(firstEntry || mentions > 0) && (
|
|
157
|
+
<>
|
|
158
|
+
<Separator className="my-2.5 via-white/20" />
|
|
159
|
+
<div className="flex items-end justify-between gap-3">
|
|
160
|
+
<span className="flex items-baseline gap-1.5">
|
|
161
|
+
{firstEntry && (
|
|
162
|
+
<>
|
|
163
|
+
<span className="text-[18px] font-extrabold leading-none tabular-nums text-white">
|
|
164
|
+
{formatPrice(firstEntry.price)}
|
|
165
|
+
<span className="ml-1 text-[12px] font-bold">{labels.currency}</span>
|
|
166
|
+
</span>
|
|
167
|
+
{firstEntry.size > 0 && (
|
|
168
|
+
<span className="text-[11.5px] tabular-nums text-white/60">
|
|
169
|
+
{firstEntry.size}
|
|
170
|
+
{item.kind === "dish" ? "g" : "ml"}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
</span>
|
|
176
|
+
{mentions > 0 && (
|
|
177
|
+
<span className="inline-flex items-center gap-1 text-[12px] font-semibold tabular-nums text-white/85">
|
|
178
|
+
<MessageSquareQuote className="h-3.5 w-3.5" aria-hidden />
|
|
179
|
+
{labels.mentions(mentions)}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</Part>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (quotes && quotes.length > 0) {
|
|
190
|
+
parts.push(
|
|
191
|
+
<Part key="quotes" kicker={labels.whatPeopleSay}>
|
|
192
|
+
<VenueQuotes quotes={quotes} limit={QUOTE_LIMIT} className="lg:grid-cols-1" />
|
|
193
|
+
</Part>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* The three strongest, ties broken by the canonical order so that food
|
|
198
|
+
beats cleanliness at an equal score — the reader asked about a kitchen. */
|
|
199
|
+
const dimensions = grade?.dimensions;
|
|
200
|
+
const strongest = dimensions
|
|
201
|
+
? DIMENSION_ORDER.filter((key) => typeof dimensions[key] === "number")
|
|
202
|
+
.sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
|
|
203
|
+
.slice(0, DIMENSION_LIMIT)
|
|
204
|
+
: [];
|
|
205
|
+
if (grade && (grade.overall || strongest.length > 0)) {
|
|
206
|
+
parts.push(
|
|
207
|
+
<Part key="grade" kicker={labels.theGrade}>
|
|
208
|
+
<div className="flex items-end gap-4">
|
|
209
|
+
{grade.overall && (
|
|
210
|
+
<span className="text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums text-[oklch(0.82_0.15_85)]">
|
|
211
|
+
{grade.overall}
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
{dimensions && strongest.length > 0 && (
|
|
215
|
+
<VenueGradeDimensions
|
|
216
|
+
className="min-w-0 flex-1"
|
|
217
|
+
dimensions={dimensions}
|
|
218
|
+
labels={labels.dimensions}
|
|
219
|
+
order={strongest}
|
|
220
|
+
formatScore={formatScore}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</Part>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<article
|
|
230
|
+
className={cn(
|
|
231
|
+
/* `flex flex-col` so the story fills the card's height: in a rail
|
|
232
|
+
the cards are stretched to the tallest, and a story that stopped
|
|
233
|
+
at its own min-height left a band of bare photograph under the
|
|
234
|
+
action on every shorter card. */
|
|
235
|
+
"dark relative isolate flex flex-col overflow-hidden rounded-[18px] border border-border text-foreground shadow-[var(--shadow-card)]",
|
|
236
|
+
PHOTO_GROUND,
|
|
237
|
+
className
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
<PhotoGround className="-z-20" photos={[place.thumbnail_url]} initials={initialOf(place.name)} loading="eager" />
|
|
241
|
+
|
|
242
|
+
<StoryProvider
|
|
243
|
+
count={parts.length}
|
|
244
|
+
durationMs={partDurationMs}
|
|
245
|
+
nextLabel={labels.next}
|
|
246
|
+
className="relative flex min-h-[560px] flex-1 flex-col p-4 lg:min-h-[440px] lg:p-5"
|
|
247
|
+
>
|
|
248
|
+
<StorySegments
|
|
249
|
+
labels={parts.map((_, index) => labels.part(index, parts.length))}
|
|
250
|
+
className="-mt-1"
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
{/* The clear photograph, with the one fact that belongs on it. */}
|
|
254
|
+
<div className="relative min-h-[184px] flex-1">
|
|
255
|
+
{labels.openUntil && (
|
|
256
|
+
<span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-3 text-[11.5px] font-bold text-white backdrop-blur-[2px]">
|
|
257
|
+
<i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
|
|
258
|
+
{labels.openUntil}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{/* The caption: identity, the story's panel, the action — and the
|
|
264
|
+
glass, anchored to the caption rather than to the card, so it
|
|
265
|
+
covers the caption and the `GLASS_LEAD` above it and nothing more.
|
|
266
|
+
Bled to the card's edges past the story's padding. */}
|
|
267
|
+
<div className="relative flex flex-col gap-3.5">
|
|
268
|
+
<div
|
|
269
|
+
aria-hidden
|
|
270
|
+
className="pointer-events-none absolute -inset-x-4 -bottom-4 -top-32 -z-10 lg:-inset-x-5 lg:-bottom-5"
|
|
271
|
+
>
|
|
272
|
+
<GlassGround
|
|
273
|
+
stops={{ direction: "to bottom", clear: "0px", full: GLASS_LEAD }}
|
|
274
|
+
peak={0.4}
|
|
275
|
+
blur="sm"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="[text-shadow:0_1px_12px_oklch(0_0_0/0.55)]">
|
|
279
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
280
|
+
<h3 className="min-w-0 font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[32px]">
|
|
281
|
+
<LinkComponent href={href} className="focus-visible:outline-none focus-visible:underline">
|
|
282
|
+
{place.name}
|
|
283
|
+
</LinkComponent>
|
|
284
|
+
</h3>
|
|
285
|
+
{rating !== undefined && (
|
|
286
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
|
|
287
|
+
{rating.toFixed(1)}
|
|
288
|
+
<Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
{(meta || labels.reviewCount) && (
|
|
293
|
+
<p className="mt-1 truncate text-[12.5px] text-white/70">
|
|
294
|
+
{[meta, labels.reviewCount].filter(Boolean).join(" · ")}
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{parts.length > 0 && (
|
|
300
|
+
<StoryPanel
|
|
301
|
+
parts={parts}
|
|
302
|
+
className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.35)] p-4 backdrop-blur-md"
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{action && <div className="flex [&>*]:w-full lg:[&>*]:w-auto">{action}</div>}
|
|
307
|
+
</div>
|
|
308
|
+
</StoryProvider>
|
|
309
|
+
</article>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function Part({ kicker, children }: { kicker: string; children: ReactNode }) {
|
|
314
|
+
return (
|
|
315
|
+
<div className="flex flex-col">
|
|
316
|
+
<span className="mb-1.5 block text-[9.5px] font-bold uppercase tracking-[0.12em] text-white/60">
|
|
317
|
+
{kicker}
|
|
318
|
+
</span>
|
|
319
|
+
{children}
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|