@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,53 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ConceptConvergence } from "./concept-convergence";
|
|
3
|
+
|
|
4
|
+
function photo(hue: number) {
|
|
5
|
+
return (
|
|
6
|
+
"data:image/svg+xml;utf8," +
|
|
7
|
+
encodeURIComponent(
|
|
8
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="400"><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="320" height="400" fill="url(#g)"/><circle cx="90" cy="120" r="60" fill="hsl(${hue} 40% 80%)" opacity=".6"/></svg>`
|
|
9
|
+
)
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof ConceptConvergence> = {
|
|
14
|
+
title: "Menu/ConceptConvergence",
|
|
15
|
+
component: ConceptConvergence,
|
|
16
|
+
tags: ["autodocs"],
|
|
17
|
+
decorators: [
|
|
18
|
+
(Story) => (
|
|
19
|
+
<div className="w-[360px] bg-[oklch(0.21_0.012_55)] p-4">
|
|
20
|
+
<Story />
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
args: {
|
|
25
|
+
aliases: [
|
|
26
|
+
{ label: "Papanași", price: "18 lei" },
|
|
27
|
+
{ label: "Papanasi cu smantana si dulceata", price: "24 lei" },
|
|
28
|
+
{ label: "Papanași de casă", price: "28 lei" },
|
|
29
|
+
{ label: "Papanasi (2 buc)", price: "32 lei" },
|
|
30
|
+
{ label: "Papanași cu fineți", price: "44 lei" },
|
|
31
|
+
],
|
|
32
|
+
concept: { title: "Papanași", photoUrl: photo(30), badge: "32 names" },
|
|
33
|
+
scale: ["8 lei", "45 kitchens", "44 lei"],
|
|
34
|
+
},
|
|
35
|
+
parameters: {
|
|
36
|
+
docs: {
|
|
37
|
+
description: {
|
|
38
|
+
component:
|
|
39
|
+
"Many names, one dish, drawn on the explainer's dark stage. The kitchens' own spellings of a dish, each with its price, converge on the concept they were matched into — the proof the concepts explainer opens with. Chips are white on ink; the concept card is a room's real photograph on the shared `PhotoGround`.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default meta;
|
|
46
|
+
type Story = StoryObj<typeof ConceptConvergence>;
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {};
|
|
49
|
+
|
|
50
|
+
/** No photograph for the lead room: the ground with the initial. */
|
|
51
|
+
export const NoPhoto: Story = {
|
|
52
|
+
args: { concept: { title: "Ciorbă de burtă", badge: "12 names" } },
|
|
53
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import { Badge } from "./badge";
|
|
3
|
+
import { Convergence } from "./convergence";
|
|
4
|
+
import { PhotoGround } from "./photo-ground";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ConceptConvergence — many names, one dish, drawn on a dark stage.
|
|
8
|
+
*
|
|
9
|
+
* The explainer's proof: the kitchens' OWN spellings of a dish, each with its
|
|
10
|
+
* price, converging on the one concept they were matched into. Five visibly
|
|
11
|
+
* different strings under one photograph with one price column say what a
|
|
12
|
+
* paragraph about "matching" cannot.
|
|
13
|
+
*
|
|
14
|
+
* Drawn for the explainer's dark stage, and only there: the chips are white
|
|
15
|
+
* on ink, not a family's hue — the tint was doing no work once the stage
|
|
16
|
+
* went dark, and the spellings are the point, not the kitchens' cuisines.
|
|
17
|
+
* The concept card is the venue's real photograph, never the item's (which
|
|
18
|
+
* is generated), on the same ground as every photographed card.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type ConceptConvergenceAlias = {
|
|
22
|
+
/** The kitchen's spelling, verbatim. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Pre-formatted, e.g. "32 lei". */
|
|
25
|
+
price: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ConceptConvergenceProps = {
|
|
29
|
+
aliases: ConceptConvergenceAlias[];
|
|
30
|
+
concept: {
|
|
31
|
+
title: string;
|
|
32
|
+
/** A room's photograph — venue photography only. */
|
|
33
|
+
photoUrl?: string;
|
|
34
|
+
/** "32 names" — the badge on the card. */
|
|
35
|
+
badge: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Low, middle and high of the ruler under the diagram, e.g.
|
|
39
|
+
* `["8 lei", "45 kitchens", "44 lei"]`. All three, or none.
|
|
40
|
+
*/
|
|
41
|
+
scale?: [string, string, string];
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function ConceptConvergence({ aliases, concept, scale, className }: ConceptConvergenceProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div className={cn("flex flex-col gap-3 text-white", className)}>
|
|
48
|
+
<Convergence
|
|
49
|
+
linesClassName="text-white/30"
|
|
50
|
+
sources={aliases.map((alias, index) => (
|
|
51
|
+
<span
|
|
52
|
+
key={index}
|
|
53
|
+
className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-full bg-white/10 px-2.5 text-[11.5px] text-white"
|
|
54
|
+
>
|
|
55
|
+
<span className="min-w-0 truncate">{alias.label}</span>
|
|
56
|
+
<span className="shrink-0 tabular-nums text-white/60">{alias.price}</span>
|
|
57
|
+
</span>
|
|
58
|
+
))}
|
|
59
|
+
target={
|
|
60
|
+
<span className="relative isolate block aspect-[4/5] w-[112px] overflow-hidden rounded-[16px] shadow-[0_8px_24px_oklch(0_0_0/0.35)]">
|
|
61
|
+
<PhotoGround
|
|
62
|
+
className="-z-10"
|
|
63
|
+
mode="stack"
|
|
64
|
+
photos={[concept.photoUrl]}
|
|
65
|
+
initials={concept.title.charAt(0).toLocaleUpperCase()}
|
|
66
|
+
overlay
|
|
67
|
+
loading="eager"
|
|
68
|
+
/>
|
|
69
|
+
<Badge variant="overlay" className="absolute left-2 top-2">
|
|
70
|
+
{concept.badge}
|
|
71
|
+
</Badge>
|
|
72
|
+
<span className="absolute inset-x-2 bottom-2 block text-balance font-serif text-[17px] font-semibold leading-tight tracking-[-0.02em] [text-shadow:0_1px_8px_oklch(0_0_0/0.5)]">
|
|
73
|
+
{concept.title}
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
{scale && (
|
|
80
|
+
<div className="flex items-center gap-2 text-[11px] tabular-nums text-white/60">
|
|
81
|
+
<span>{scale[0]}</span>
|
|
82
|
+
<span aria-hidden className="h-px flex-1 bg-white/20" />
|
|
83
|
+
<span className="font-semibold text-white">{scale[1]}</span>
|
|
84
|
+
<span aria-hidden className="h-px flex-1 bg-white/20" />
|
|
85
|
+
<span>{scale[2]}</span>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MessageSquareQuote } from "lucide-react";
|
|
3
|
+
import { Convergence } from "./convergence";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Convergence> = {
|
|
6
|
+
title: "Menu/Convergence",
|
|
7
|
+
component: Convergence,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[360px] bg-background p-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"Many things on the left becoming one thing on the right. The DS draws the shape — the fanned stack, the hairlines curving into one point — and the caller draws the nodes. `ConceptConvergence` is the dish version; this story is the reviews version, redacted lines converging on a mention count.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof Convergence>;
|
|
28
|
+
|
|
29
|
+
export const Reviews: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<Convergence
|
|
32
|
+
linesClassName="text-white/30"
|
|
33
|
+
sources={Array.from({ length: 6 }, (_, index) => (
|
|
34
|
+
<span key={index} className="flex items-center gap-2" aria-hidden>
|
|
35
|
+
<MessageSquareQuote className="h-4 w-4 text-white/40" />
|
|
36
|
+
<span className="h-2 rounded-full bg-white/20" style={{ width: 48 + ((index * 37) % 60) }} />
|
|
37
|
+
</span>
|
|
38
|
+
))}
|
|
39
|
+
target={
|
|
40
|
+
<span className="flex flex-col items-center rounded-[18px] bg-white/10 px-5 py-4 text-white shadow-[0_8px_24px_oklch(0_0_0/0.35)]">
|
|
41
|
+
<span className="text-[40px] font-extrabold leading-none tabular-nums">56</span>
|
|
42
|
+
<span className="mt-1 text-[11px] text-white/70">mentions</span>
|
|
43
|
+
</span>
|
|
44
|
+
}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
decorators: [
|
|
48
|
+
(Story) => (
|
|
49
|
+
<div className="w-[360px] bg-[oklch(0.21_0.012_55)] p-4">
|
|
50
|
+
<Story />
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
],
|
|
54
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { duration, ease, springSnappy, stagger } from "../lib/motion";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convergence — many things on the left becoming one thing on the right.
|
|
10
|
+
*
|
|
11
|
+
* The diagram the explainers are built on: several spellings of a dish
|
|
12
|
+
* converging on one concept; a column of reviews converging on one mention
|
|
13
|
+
* count. The DS draws the SHAPE — a stack of sources, hairlines curving into
|
|
14
|
+
* a single point, the target — and the caller draws the nodes, because what a
|
|
15
|
+
* source is (a chip, a redacted line, a thumbnail) is the caller's data.
|
|
16
|
+
*
|
|
17
|
+
* ## The motion tells the story
|
|
18
|
+
*
|
|
19
|
+
* Three beats on mount, overlapping, under 700ms: the many arrive (each
|
|
20
|
+
* source slides in from the left, staggered), the lines draw toward the one
|
|
21
|
+
* (each curve starts once its source is about half visible, so cause reads
|
|
22
|
+
* before effect), and the one lands (the target scales in on a snappy spring
|
|
23
|
+
* while the last lines are still in flight — it catches them rather than
|
|
24
|
+
* waiting for a clean hand-off). Self-contained: the sheet this sits in
|
|
25
|
+
* animates by CSS `data-state`, which nothing here could await, and the
|
|
26
|
+
* `delayChildren` already puts the content a beat behind the surface.
|
|
27
|
+
*
|
|
28
|
+
* `useReducedMotion` owns the fallback — the stylesheet's global rule only
|
|
29
|
+
* shortens CSS transitions and does not reach Framer's own scheduler. Under
|
|
30
|
+
* it the diagram renders settled and fades in once. `animate={false}` is a
|
|
31
|
+
* separate switch for static reuse (a docs frame), independent of the
|
|
32
|
+
* reader's setting.
|
|
33
|
+
*
|
|
34
|
+
* ## Why the lines are an SVG and not borders
|
|
35
|
+
*
|
|
36
|
+
* A curve from each source to one point cannot be a border on anything. The
|
|
37
|
+
* SVG stretches to the sources column's height (`preserveAspectRatio: none`)
|
|
38
|
+
* and keeps its hairlines at one pixel through `vector-effect`, so the curves
|
|
39
|
+
* follow however tall the stack is without the stroke thickening with it.
|
|
40
|
+
* `pathLength` is normalised by Framer to the path's own space, so the draw
|
|
41
|
+
* is unaffected by the stretch.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** The first line starts once the first source is about half visible. */
|
|
45
|
+
const LINE_LEAD = 0.15;
|
|
46
|
+
/** The target lands before the last line finishes — it catches them. */
|
|
47
|
+
const TARGET_DELAY = 0.38;
|
|
48
|
+
|
|
49
|
+
const sourcesContainer: Variants = {
|
|
50
|
+
hidden: {},
|
|
51
|
+
show: { transition: { delayChildren: 0.06, staggerChildren: stagger.tight } },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sourceItem: Variants = {
|
|
55
|
+
hidden: { opacity: 0, x: -8 },
|
|
56
|
+
show: { opacity: 1, x: 0, transition: { duration: duration.quick, ease: ease.enter } },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const targetItem: Variants = {
|
|
60
|
+
hidden: { opacity: 0, scale: 0.92 },
|
|
61
|
+
show: { opacity: 1, scale: 1, transition: { delay: TARGET_DELAY, ...springSnappy } },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ConvergeLinesProps = {
|
|
65
|
+
/** How many sources the lines leave from. */
|
|
66
|
+
count: number;
|
|
67
|
+
/** Draw the curves in, source by source. Off: drawn already. */
|
|
68
|
+
animate?: boolean;
|
|
69
|
+
className?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const LINES_WIDTH = 40;
|
|
73
|
+
const LINES_HEIGHT = 100;
|
|
74
|
+
|
|
75
|
+
export function ConvergeLines({ count, animate = true, className }: ConvergeLinesProps) {
|
|
76
|
+
const mid = LINES_HEIGHT / 2;
|
|
77
|
+
return (
|
|
78
|
+
<svg
|
|
79
|
+
aria-hidden
|
|
80
|
+
viewBox={`0 0 ${LINES_WIDTH} ${LINES_HEIGHT}`}
|
|
81
|
+
preserveAspectRatio="none"
|
|
82
|
+
className={cn("h-full w-10 text-border", className)}
|
|
83
|
+
>
|
|
84
|
+
{Array.from({ length: count }, (_, index) => {
|
|
85
|
+
const y = ((index + 0.5) / count) * LINES_HEIGHT;
|
|
86
|
+
const d = `M0 ${y} C ${LINES_WIDTH / 2} ${y}, ${LINES_WIDTH / 2} ${mid}, ${LINES_WIDTH} ${mid}`;
|
|
87
|
+
const stroke = {
|
|
88
|
+
d,
|
|
89
|
+
fill: "none",
|
|
90
|
+
stroke: "currentColor",
|
|
91
|
+
strokeWidth: 1.5,
|
|
92
|
+
strokeLinecap: "round" as const,
|
|
93
|
+
vectorEffect: "non-scaling-stroke" as const,
|
|
94
|
+
};
|
|
95
|
+
return animate ? (
|
|
96
|
+
<motion.path
|
|
97
|
+
key={index}
|
|
98
|
+
{...stroke}
|
|
99
|
+
initial={{ pathLength: 0, opacity: 0 }}
|
|
100
|
+
animate={{ pathLength: 1, opacity: 1 }}
|
|
101
|
+
transition={{
|
|
102
|
+
delay: LINE_LEAD + index * stagger.tight,
|
|
103
|
+
duration: duration.standard,
|
|
104
|
+
ease: ease.standard,
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
) : (
|
|
108
|
+
<path key={index} {...stroke} />
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</svg>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type ConvergenceProps = {
|
|
116
|
+
/** The many. One node each; the stack fans slightly so it reads as a spread, not a list. */
|
|
117
|
+
sources: ReactNode[];
|
|
118
|
+
/** The one. */
|
|
119
|
+
target: ReactNode;
|
|
120
|
+
/** The entrance choreography. Off for static reuse; the reader's reduced-motion setting is honoured either way. */
|
|
121
|
+
animate?: boolean;
|
|
122
|
+
/** The curves' colour, e.g. `"text-white/30"` on a dark stage. Defaults to the border. */
|
|
123
|
+
linesClassName?: string;
|
|
124
|
+
className?: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export function Convergence({
|
|
128
|
+
sources,
|
|
129
|
+
target,
|
|
130
|
+
animate = true,
|
|
131
|
+
linesClassName,
|
|
132
|
+
className,
|
|
133
|
+
}: ConvergenceProps) {
|
|
134
|
+
const reduce = useReducedMotion();
|
|
135
|
+
const run = animate && !reduce;
|
|
136
|
+
const fadeOnly = animate && reduce;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<motion.div
|
|
140
|
+
className={cn("grid grid-cols-[minmax(0,1fr)_auto_auto] items-center", className)}
|
|
141
|
+
initial={fadeOnly ? { opacity: 0 } : false}
|
|
142
|
+
animate={fadeOnly ? { opacity: 1 } : undefined}
|
|
143
|
+
transition={{ duration: duration.quick, ease: ease.standard }}
|
|
144
|
+
>
|
|
145
|
+
<motion.div
|
|
146
|
+
className={cn(
|
|
147
|
+
"flex min-w-0 flex-col items-start justify-center gap-2 py-1",
|
|
148
|
+
/* The fan: the middle sources sit a little further right than the
|
|
149
|
+
ends, so five chips read as one hand of cards, not as a menu. */
|
|
150
|
+
"[&>*:nth-child(2)]:translate-x-1.5 [&>*:nth-child(3)]:translate-x-3 [&>*:nth-child(4)]:translate-x-1.5"
|
|
151
|
+
)}
|
|
152
|
+
variants={run ? sourcesContainer : undefined}
|
|
153
|
+
initial={run ? "hidden" : false}
|
|
154
|
+
animate={run ? "show" : undefined}
|
|
155
|
+
>
|
|
156
|
+
{sources.map((node, index) => (
|
|
157
|
+
<motion.div key={index} className="flex max-w-full" variants={run ? sourceItem : undefined}>
|
|
158
|
+
{node}
|
|
159
|
+
</motion.div>
|
|
160
|
+
))}
|
|
161
|
+
</motion.div>
|
|
162
|
+
|
|
163
|
+
<div className="self-stretch">
|
|
164
|
+
<ConvergeLines count={sources.length} animate={run} className={linesClassName} />
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<motion.div
|
|
168
|
+
className="flex items-center"
|
|
169
|
+
variants={run ? targetItem : undefined}
|
|
170
|
+
initial={run ? "hidden" : false}
|
|
171
|
+
animate={run ? "show" : undefined}
|
|
172
|
+
>
|
|
173
|
+
{target}
|
|
174
|
+
</motion.div>
|
|
175
|
+
</motion.div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ExplainerSteps } from "./explainer-steps";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ExplainerSteps> = {
|
|
5
|
+
title: "Menu/ExplainerSteps",
|
|
6
|
+
component: ExplainerSteps,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => (
|
|
10
|
+
<div className="w-[360px] bg-background p-4">
|
|
11
|
+
<Story />
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
],
|
|
15
|
+
args: {
|
|
16
|
+
steps: [
|
|
17
|
+
{ text: "We read 32 spellings of papanași and matched them to one dish.", figure: "32", unit: "names" },
|
|
18
|
+
{ text: "Prices compare like for like: every one is a portion of the same dish.", figure: "8–44", unit: "lei" },
|
|
19
|
+
{ text: "Open any dish and see what every other kitchen charges for it.", figure: "45", unit: "kitchens" },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
parameters: {
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"How something works, as numbered rules: a numeral in a ring, one line, and the live figure that proves it with its unit. A thread joins the numerals; the last is filled, because the rules end where the reader is asked to act. A step without a figure shows none.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof ExplainerSteps>;
|
|
34
|
+
|
|
35
|
+
export const Default: Story = {};
|
|
36
|
+
|
|
37
|
+
/** The third rule has no number, so it shows none. */
|
|
38
|
+
export const UnevenFigures: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
steps: [
|
|
41
|
+
{ text: "The reviews of every place in Cluj-Napoca, read in full.", figure: "791", unit: "places" },
|
|
42
|
+
{ text: "A mention is one review naming the item.", figure: "68", unit: "here" },
|
|
43
|
+
{ text: "The most-named lists are ranked on it, so the top is what the city keeps talking about." },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ExplainerSteps — how something works, as numbered rules a reader can scan.
|
|
6
|
+
*
|
|
7
|
+
* Each step is a numeral in a ring, one line of prose, and the live figure
|
|
8
|
+
* that proves the line, right-aligned with its unit. A thread runs from one
|
|
9
|
+
* numeral to the next so the three read as a sequence, and the last numeral
|
|
10
|
+
* is filled: the rules end where the reader is asked to do something.
|
|
11
|
+
*
|
|
12
|
+
* A step without a figure shows none — an invented number in the column is
|
|
13
|
+
* worse than an empty one. But a list where no step has a figure should be
|
|
14
|
+
* prose, not steps.
|
|
15
|
+
*
|
|
16
|
+
* Server-safe. Data in, not children: a server component composes these from
|
|
17
|
+
* translated strings and numbers, and an array is easier to number than a
|
|
18
|
+
* child list is to clone.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type ExplainerStepItem = {
|
|
22
|
+
/** One line. Two at most on a phone; a third is a paragraph and belongs elsewhere. */
|
|
23
|
+
text: ReactNode;
|
|
24
|
+
/** The live figure, pre-formatted. */
|
|
25
|
+
figure?: string;
|
|
26
|
+
/** A unit or caption beside the figure, e.g. "names". */
|
|
27
|
+
unit?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ExplainerStepsProps = {
|
|
31
|
+
steps: ExplainerStepItem[];
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function ExplainerSteps({ steps, className }: ExplainerStepsProps) {
|
|
36
|
+
return (
|
|
37
|
+
<ol className={cn("flex flex-col", className)}>
|
|
38
|
+
{steps.map((step, index) => {
|
|
39
|
+
const isLast = index === steps.length - 1;
|
|
40
|
+
return (
|
|
41
|
+
<li key={index} className="relative flex gap-3.5 pb-5 last:pb-0">
|
|
42
|
+
{/* The thread, from this numeral to the next. */}
|
|
43
|
+
{!isLast && (
|
|
44
|
+
<span
|
|
45
|
+
aria-hidden
|
|
46
|
+
className="absolute bottom-0 left-[11px] top-7 w-px bg-border"
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
<span
|
|
50
|
+
aria-hidden
|
|
51
|
+
className={cn(
|
|
52
|
+
"relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold tabular-nums",
|
|
53
|
+
isLast
|
|
54
|
+
? "bg-primary text-primary-foreground"
|
|
55
|
+
: "border border-border bg-background text-muted-foreground"
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{index + 1}
|
|
59
|
+
</span>
|
|
60
|
+
<p className="min-w-0 flex-1 pt-0.5 text-[14px] leading-[1.45] text-foreground">{step.text}</p>
|
|
61
|
+
{step.figure && (
|
|
62
|
+
<span className="shrink-0 pt-0.5 text-right text-[15px] font-bold leading-[1.45] tabular-nums text-foreground">
|
|
63
|
+
{step.figure}
|
|
64
|
+
{step.unit && (
|
|
65
|
+
<span className="ml-1 text-[11px] font-normal text-muted-foreground">{step.unit}</span>
|
|
66
|
+
)}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
</li>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</ol>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GlassGround — a frosted, inked caption rising out of one edge of a
|
|
6
|
+
* photograph.
|
|
7
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* ## Why two blurs, and why the ramps are eased
|
|
15
|
+
*
|
|
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.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the stops are custom properties
|
|
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
|
|
29
|
+
* interpolated the quarter stops in JS (`clear` and `full` must share a
|
|
30
|
+
* unit). Same mechanism as `HueChip`'s hue. The ink's colour is the
|
|
31
|
+
* package's one scrim literal, `oklch(0.16 0.012 55)`, never `var(--scrim)`.
|
|
32
|
+
*
|
|
33
|
+
* ## The ramp ends where the type begins
|
|
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.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export type GlassStops = {
|
|
41
|
+
/** Which way the ink runs — toward the type. */
|
|
42
|
+
direction: "to bottom" | "to right";
|
|
43
|
+
/** Where the frost begins, e.g. `"0px"` or `"24%"`. */
|
|
44
|
+
clear: string;
|
|
45
|
+
/** Where frost and ink are solid — the seam the content starts at. Same unit as `clear`. */
|
|
46
|
+
full: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type GlassGroundProps = {
|
|
50
|
+
stops: GlassStops;
|
|
51
|
+
/** Stops from `lg`. Omit to keep the same ramp at every width. */
|
|
52
|
+
lgStops?: GlassStops;
|
|
53
|
+
/**
|
|
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
|
|
56
|
+
* drawn on — the type is lifted, the room stays a room.
|
|
57
|
+
*/
|
|
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";
|
|
65
|
+
className?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/* Ease-in through the ramp: the fraction of the way from `clear` to `full`
|
|
69
|
+
at which each stop sits, and the share of the peak it carries. */
|
|
70
|
+
const STOPS = [0, 0.25, 0.5, 0.75, 1] as const;
|
|
71
|
+
const EASE = [0, 0.06, 0.25, 0.56, 1] as const;
|
|
72
|
+
|
|
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
|
+
const INK =
|
|
80
|
+
"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
|
+
|
|
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
|
+
/** "72px" → [72, "px"]; "24%" → [24, "%"]. */
|
|
91
|
+
function measure(value: string): [number, string] {
|
|
92
|
+
const match = value.trim().match(/^(-?\d*\.?\d+)\s*([a-z%]*)$/i);
|
|
93
|
+
if (!match) throw new Error(`GlassGround: cannot read stop "${value}"`);
|
|
94
|
+
return [Number(match[1]), match[2] || "px"];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The five ramp positions between `clear` and `full`, as CSS lengths. */
|
|
98
|
+
function positions(clear: string, full: string): string[] {
|
|
99
|
+
const [from, unit] = measure(clear);
|
|
100
|
+
const [to] = measure(full);
|
|
101
|
+
return STOPS.map((fraction) => `${from + (to - from) * fraction}${unit}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function GlassGround({
|
|
105
|
+
stops,
|
|
106
|
+
lgStops = stops,
|
|
107
|
+
peak = 0.96,
|
|
108
|
+
blur = "xl",
|
|
109
|
+
className,
|
|
110
|
+
}: GlassGroundProps) {
|
|
111
|
+
const base = positions(stops.clear, stops.full);
|
|
112
|
+
const lg = positions(lgStops.clear, lgStops.full);
|
|
113
|
+
const style = {
|
|
114
|
+
"--glass-dir": stops.direction,
|
|
115
|
+
"--glass-lg-dir": lgStops.direction,
|
|
116
|
+
...Object.fromEntries(base.map((position, index) => [`--glass-s${index}`, position])),
|
|
117
|
+
...Object.fromEntries(lg.map((position, index) => [`--glass-lg-s${index}`, position])),
|
|
118
|
+
...Object.fromEntries(EASE.slice(1).map((share, index) => [`--glass-a${index + 1}`, String(peak * 0.8 * share)])),
|
|
119
|
+
"--glass-a-peak": String(peak),
|
|
120
|
+
} as CSSProperties;
|
|
121
|
+
|
|
122
|
+
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>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -30,7 +30,17 @@ export function HubSurface({ flush, className, children }: HubSurfaceProps) {
|
|
|
30
30
|
className={cn(
|
|
31
31
|
"rounded-[18px] border border-border bg-card shadow-none",
|
|
32
32
|
flush ? "overflow-hidden" : "px-3.5 py-4",
|
|
33
|
-
|
|
33
|
+
/*
|
|
34
|
+
* `lg:border-0` is load-bearing and was missing.
|
|
35
|
+
*
|
|
36
|
+
* Above `lg` this surface stops being a card — the background, the
|
|
37
|
+
* radius, the padding and the shadow are all dropped so the section
|
|
38
|
+
* sits on the page. The border was not, so every section on every
|
|
39
|
+
* desktop page kept a 1px rectangle around content that no longer had
|
|
40
|
+
* a card behind it. A caller that genuinely wants an edge at desktop
|
|
41
|
+
* adds `lg:border` in `className`, which lands after this in `cn`.
|
|
42
|
+
*/
|
|
43
|
+
"lg:overflow-visible lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none",
|
|
34
44
|
className
|
|
35
45
|
)}
|
|
36
46
|
>
|