@bearmenu/ui 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/components/icon.d.ts +18 -0
  3. package/dist/components/icon.js +31 -0
  4. package/dist/components/motion-icon-controls.d.ts +26 -0
  5. package/dist/components/motion-icon-controls.js +380 -0
  6. package/dist/components/motion-icon-set.d.ts +27 -0
  7. package/dist/components/motion-icon-set.js +139 -0
  8. package/dist/components/motion-icon.d.ts +44 -0
  9. package/dist/components/motion-icon.js +3 -0
  10. package/dist/lib/icon-registry.d.ts +58 -0
  11. package/dist/lib/icon-registry.js +39 -0
  12. package/package.json +2 -1
  13. package/src/components/accordion.stories.tsx +1 -1
  14. package/src/components/combobox.stories.tsx +1 -1
  15. package/src/components/currency-input.stories.tsx +3 -3
  16. package/src/components/empty-state.stories.tsx +3 -3
  17. package/src/components/form.stories.tsx +1 -1
  18. package/src/components/icon.tsx +57 -0
  19. package/src/components/input-otp.stories.tsx +1 -1
  20. package/src/components/markdown-renderer.stories.tsx +1 -1
  21. package/src/components/motion-icon-controls.test.tsx +78 -0
  22. package/src/components/motion-icon-controls.tsx +552 -0
  23. package/src/components/motion-icon-set.stories.tsx +185 -0
  24. package/src/components/motion-icon-set.tsx +219 -0
  25. package/src/components/motion-icon.test.tsx +159 -0
  26. package/src/components/motion-icon.tsx +163 -0
  27. package/src/components/multi-select-combobox.stories.tsx +1 -1
  28. package/src/components/phone-frame.stories.tsx +1 -1
  29. package/src/components/rating.stories.tsx +3 -3
  30. package/src/components/searchable-select.stories.tsx +1 -1
  31. package/src/components/selection-bar.stories.tsx +1 -1
  32. package/src/components/skeleton-card.stories.tsx +3 -3
  33. package/src/components/sliding-panels.stories.tsx +1 -1
  34. package/src/components/stepper.stories.tsx +1 -1
  35. package/src/components/sticky-container.stories.tsx +3 -3
  36. package/src/components/toggle-group.stories.tsx +1 -1
  37. package/src/components/tooltip.stories.tsx +2 -2
  38. package/src/lib/icon-registry.ts +139 -0
  39. package/src/test/setup.ts +5 -1
@@ -0,0 +1,139 @@
1
+ import { beats, MotionIcon, returnTransition } from '../chunk-2WDJR6K7.js';
2
+ import '../chunk-FEK5XGZW.js';
3
+ import { __spreadProps, __spreadValues } from '../chunk-3GWWZKX7.js';
4
+ import * as React from 'react';
5
+ import { motion } from 'framer-motion';
6
+ import { jsxs, jsx } from 'react/jsx-runtime';
7
+
8
+ var HOUSE_TIMES = [0, 0.16, 0.44, 0.72, 1];
9
+ var houseRoof = {
10
+ rest: { y: 0, scaleY: 1, transition: returnTransition },
11
+ active: { y: 0, scaleY: 1, transition: returnTransition },
12
+ play: {
13
+ // Dips into the walls, launches 9 units clear and stretches, falls, lands
14
+ // 0.9 low onto walls that compress under it, settles.
15
+ y: [0, 1.5, -9, 0.9, 0],
16
+ scaleY: [1, 0.92, 1.06, 0.86, 1],
17
+ transition: beats(HOUSE_TIMES)
18
+ }
19
+ };
20
+ var houseWalls = {
21
+ rest: { scaleY: 1, transition: returnTransition },
22
+ active: { scaleY: 1, transition: returnTransition },
23
+ play: { scaleY: [1, 1, 1, 0.9, 1], transition: beats(HOUSE_TIMES) }
24
+ };
25
+ function MotionHouse(props) {
26
+ return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
27
+ /* @__PURE__ */ jsx(
28
+ motion.path,
29
+ {
30
+ variants: houseRoof,
31
+ style: { originX: "12px", originY: "10px" },
32
+ d: "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10"
33
+ }
34
+ ),
35
+ /* @__PURE__ */ jsxs(motion.g, { variants: houseWalls, style: { originX: "12px", originY: "21px" }, children: [
36
+ /* @__PURE__ */ jsx("path", { d: "M21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V10" }),
37
+ /* @__PURE__ */ jsx("path", { d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" })
38
+ ] })
39
+ ] }));
40
+ }
41
+ var UTENSILS_TIMES = [0, 0.18, 0.72, 1];
42
+ var SEPARATION = 5;
43
+ var fork = {
44
+ rest: { rotate: 0, x: 0, transition: returnTransition },
45
+ active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
46
+ play: {
47
+ rotate: [0, 6, -50, -44],
48
+ x: [0, 0.8, -6, -SEPARATION],
49
+ transition: beats(UTENSILS_TIMES, 0.6)
50
+ }
51
+ };
52
+ var knife = {
53
+ rest: { rotate: 0, x: 0, transition: returnTransition },
54
+ active: { rotate: 45, x: SEPARATION, transition: returnTransition },
55
+ play: {
56
+ rotate: [0, -6, 51, 45],
57
+ x: [0, -0.8, 6, SEPARATION],
58
+ transition: beats(UTENSILS_TIMES, 0.6)
59
+ }
60
+ };
61
+ var CROSSING = { originX: "12px", originY: "12px" };
62
+ function MotionUtensils(props) {
63
+ return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
64
+ /* @__PURE__ */ jsx(
65
+ motion.path,
66
+ {
67
+ variants: knife,
68
+ style: CROSSING,
69
+ d: "M15 15 3.3 3.3a4.2 4.2 0 0 0 0 6l7.3 7.3c.7.7 2 .7 2.8 0L15 15Zm0 0 7 7"
70
+ }
71
+ ),
72
+ /* @__PURE__ */ jsxs(motion.g, { variants: fork, style: CROSSING, children: [
73
+ /* @__PURE__ */ jsx("path", { d: "m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8" }),
74
+ /* @__PURE__ */ jsx("path", { d: "m2.1 21.8 6.4-6.3" }),
75
+ /* @__PURE__ */ jsx("path", { d: "m19 5-7 7" })
76
+ ] })
77
+ ] }));
78
+ }
79
+ var PIN_TIMES = [0, 0.16, 0.44, 0.72, 1];
80
+ var pinBody = {
81
+ rest: { y: 0, scaleY: 1, transition: returnTransition },
82
+ active: { y: 0, scaleY: 1, transition: returnTransition },
83
+ play: {
84
+ y: [0, 1.2, -9, 0, 0],
85
+ scaleY: [1, 0.86, 1.08, 0.84, 1],
86
+ transition: beats(PIN_TIMES)
87
+ }
88
+ };
89
+ var pinDot = {
90
+ rest: { fillOpacity: 0, scale: 1, transition: { duration: 0.18 } },
91
+ active: { fillOpacity: 1, scale: 1, transition: { duration: 0.18 } },
92
+ play: {
93
+ fillOpacity: [0, 0, 0, 1, 1],
94
+ scale: [1, 1, 1, 1.35, 1],
95
+ transition: beats(PIN_TIMES)
96
+ }
97
+ };
98
+ function MotionMapPin(props) {
99
+ return /* @__PURE__ */ jsx(MotionIcon, __spreadProps(__spreadValues({}, props), { children: /* @__PURE__ */ jsxs(motion.g, { variants: pinBody, style: { originX: "12px", originY: "22px" }, children: [
100
+ /* @__PURE__ */ jsx("path", { d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }),
101
+ /* @__PURE__ */ jsx(
102
+ motion.circle,
103
+ {
104
+ variants: pinDot,
105
+ style: { originX: "12px", originY: "10px" },
106
+ fill: "currentColor",
107
+ fillOpacity: 0,
108
+ cx: "12",
109
+ cy: "10",
110
+ r: "3"
111
+ }
112
+ )
113
+ ] }) }));
114
+ }
115
+ var ROWS = [14, 18, 22, 26, 30, 34];
116
+ var COLS = [8, 12, 16];
117
+ var reel = {
118
+ rest: { y: 0, transition: returnTransition },
119
+ active: { y: 0, transition: returnTransition },
120
+ play: {
121
+ // Exactly four rows (16 units, ~15px), so arriving rows land where the
122
+ // departing ones began and the end pose is identical to the rest pose.
123
+ y: [0, -4, -8, -12, -17, -16],
124
+ transition: beats([0, 0.18, 0.34, 0.5, 0.85, 1], 0.65)
125
+ }
126
+ };
127
+ function MotionCalendar(props) {
128
+ const clipId = `cal-body-${React.useId().replace(/:/g, "")}`;
129
+ return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
130
+ /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("clipPath", { id: clipId, children: /* @__PURE__ */ jsx("rect", { x: "3.5", y: "10.5", width: "17", height: "11", rx: "1.5" }) }) }),
131
+ /* @__PURE__ */ jsx("path", { d: "M8 2v4" }),
132
+ /* @__PURE__ */ jsx("path", { d: "M16 2v4" }),
133
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "4", rx: "2" }),
134
+ /* @__PURE__ */ jsx("path", { d: "M3 10h18" }),
135
+ /* @__PURE__ */ jsx("g", { clipPath: `url(#${clipId})`, children: /* @__PURE__ */ jsx(motion.g, { variants: reel, children: ROWS.map((y) => COLS.map((x) => /* @__PURE__ */ jsx("path", { d: `M${x} ${y}h.01` }, `${x}-${y}`))) }) })
136
+ ] }));
137
+ }
138
+
139
+ export { MotionCalendar, MotionHouse, MotionMapPin, MotionUtensils };
@@ -0,0 +1,44 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as framer_motion from 'framer-motion';
3
+ import { Transition, Variants } from 'framer-motion';
4
+ import * as React from 'react';
5
+
6
+ type MotionIconProps = {
7
+ /** The held state. Drives `active` vs `rest`, and the stroke weight. */
8
+ active?: boolean;
9
+ /**
10
+ * Bump on pointer down to perform the gesture. Changing this value replays
11
+ * `play` even when `active` has not changed, which is what makes the icon
12
+ * respond to the touch rather than to its consequence.
13
+ */
14
+ replayToken?: number;
15
+ className?: string;
16
+ };
17
+ type MotionIconVariant = "rest" | "active" | "play";
18
+ /**
19
+ * A gesture is a performance with beats, so it is a tween with explicit
20
+ * `times`. A spring interpolates two keyframes and puts its overshoot at the
21
+ * end, which cannot express anticipation.
22
+ */
23
+ declare function beats(times: number[], duration?: number): Transition;
24
+ /** Leaving is not a performance — it returns the glyph, firmly and fast. */
25
+ declare const returnTransition: Transition;
26
+ type PoseValues = Record<string, number>;
27
+ /**
28
+ * Builds the three variants for a moving part.
29
+ * `play` values are keyframe arrays; `rest`/`active` are the two held poses.
30
+ */
31
+ declare function poses(rest: PoseValues, active: PoseValues, play: Record<string, number[]>, transition: Transition): Variants;
32
+ /**
33
+ * Mount poses without playing; pointer down plays; arriving plays unless the
34
+ * pointer already started it; leaving returns.
35
+ *
36
+ * Both effects compare against a ref rather than trusting the dependency array,
37
+ * so they fire once per actual change and not once per render.
38
+ */
39
+ declare function useIconGesture(active: boolean, replayToken: number): framer_motion.LegacyAnimationControls;
40
+ declare function MotionIcon({ active, replayToken, className, children, }: MotionIconProps & {
41
+ children: React.ReactNode;
42
+ }): react_jsx_runtime.JSX.Element;
43
+
44
+ export { MotionIcon, type MotionIconProps, type MotionIconVariant, beats, poses, returnTransition, useIconGesture };
@@ -0,0 +1,3 @@
1
+ export { MotionIcon, beats, poses, returnTransition, useIconGesture } from '../chunk-2WDJR6K7.js';
2
+ import '../chunk-FEK5XGZW.js';
3
+ import '../chunk-3GWWZKX7.js';
@@ -0,0 +1,58 @@
1
+ import { LucideIcon } from 'lucide-react';
2
+
3
+ /**
4
+ * The purpose → glyph registry.
5
+ *
6
+ * One place recording which glyph means what. Before this existed, ~10 ad-hoc
7
+ * glyph maps were scattered across the app and the same purpose ended up drawn
8
+ * two different ways: back as both an arrow and a chevron, "open filters" as
9
+ * both a funnel and sliders (one surface used both, chosen by which view you
10
+ * were in), clear and close sharing a bare X.
11
+ *
12
+ * Only GENERIC action vocabulary lives here. Product taxonomy — cuisine
13
+ * shortcuts, facet dimensions, digest categories — stays in the consuming app.
14
+ *
15
+ * ## The rules this encodes
16
+ *
17
+ * 1. **Arrow leaves, chevron stays.** `ArrowRight` means "you are going to a new
18
+ * destination" (see-all links, banner CTAs). `ChevronRight` means "reveal
19
+ * more, in place" and is scoped to exactly two jobs: carousel paging and
20
+ * row-contained drill-in. Collapsing these is what produced a glyph with
21
+ * four meanings.
22
+ * 2. **Sliders, not a funnel.** A funnel reads as "narrow a list" and collides
23
+ * with search and sort iconography at 24px; three sliders read
24
+ * unambiguously as "adjust parameters" and carry a badge count cleanly.
25
+ * 3. **Containment separates clear from close.** A bare `X` leaves a layer. A
26
+ * contained `XCircle` resets a value in place without leaving. In a dense
27
+ * filter row, bare Xs beside each other cannot say which layer they close.
28
+ * 4. **Tappable has a container or a colour state; information is muted and
29
+ * bare.** This is what disambiguates the overloaded glyphs (`MapPin`,
30
+ * `Star`) rather than picking different glyphs for each sense. An
31
+ * interactive instance sits in a chip or button and takes an active colour;
32
+ * a decorative one is `text-muted-foreground`, `aria-hidden`, outside any
33
+ * button, with no hover or active transition.
34
+ * 5. **Loved and want-to-try are two actions, not one.** `Heart` is
35
+ * retrospective ("I have had this and I love it"), `Bookmark` is prospective
36
+ * ("I want to try this"). They feed different lists and are mutually
37
+ * exclusive per item.
38
+ */
39
+
40
+ type IconPurpose = "back" | "seeAll" | "drillIn" | "carouselPrev" | "carouselNext" | "directions" | "externalLink" | "close" | "clear" | "expand" | "filter" | "sort" | "search" | "share" | "favorite" | "wantToTry" | "confirm" | "add" | "remove" | "refresh" | "call" | "report" | "info";
41
+ declare const iconFor: Record<IconPurpose, LucideIcon>;
42
+ /**
43
+ * Purposes whose glyph must rotate to show state, and by how much.
44
+ * `expand` is the disclosure chevron: pointing down at rest, up when open.
45
+ */
46
+ declare const iconRotation: Partial<Record<IconPurpose, number>>;
47
+ /**
48
+ * Retired glyphs and what replaced them. Kept so a reviewer can tell a
49
+ * deliberate choice from a regression, and so lint/codemods have a source.
50
+ */
51
+ declare const retiredIcons: {
52
+ readonly Filter: "filter → SlidersHorizontal";
53
+ readonly ChevronRightAsSeeAll: "seeAll → ArrowRight";
54
+ readonly ChevronLeftAsBack: "back → ArrowLeft";
55
+ readonly XAsClear: "clear → XCircle";
56
+ };
57
+
58
+ export { type IconPurpose, iconFor, iconRotation, retiredIcons };
@@ -0,0 +1,39 @@
1
+ import '../chunk-3GWWZKX7.js';
2
+ import { Info, Flag, Phone, RefreshCw, Trash2, Plus, Check, Bookmark, Heart, Share2, Search, ArrowUpDown, SlidersHorizontal, ChevronDown, XCircle, X, ExternalLink, Navigation, ChevronRight, ChevronLeft, ArrowRight, ArrowLeft } from 'lucide-react';
3
+
4
+ var iconFor = {
5
+ back: ArrowLeft,
6
+ seeAll: ArrowRight,
7
+ drillIn: ChevronRight,
8
+ carouselPrev: ChevronLeft,
9
+ carouselNext: ChevronRight,
10
+ directions: Navigation,
11
+ externalLink: ExternalLink,
12
+ close: X,
13
+ clear: XCircle,
14
+ expand: ChevronDown,
15
+ filter: SlidersHorizontal,
16
+ sort: ArrowUpDown,
17
+ search: Search,
18
+ share: Share2,
19
+ favorite: Heart,
20
+ wantToTry: Bookmark,
21
+ confirm: Check,
22
+ add: Plus,
23
+ remove: Trash2,
24
+ refresh: RefreshCw,
25
+ call: Phone,
26
+ report: Flag,
27
+ info: Info
28
+ };
29
+ var iconRotation = {
30
+ expand: 180
31
+ };
32
+ var retiredIcons = {
33
+ Filter: "filter \u2192 SlidersHorizontal",
34
+ ChevronRightAsSeeAll: "seeAll \u2192 ArrowRight",
35
+ ChevronLeftAsBack: "back \u2192 ArrowLeft",
36
+ XAsClear: "clear \u2192 XCircle"
37
+ };
38
+
39
+ export { iconFor, iconRotation, retiredIcons };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -102,6 +102,7 @@
102
102
  "@radix-ui/react-tooltip": "^1.2.8",
103
103
  "@storybook/addon-a11y": "^10.3.5",
104
104
  "@storybook/addon-docs": "^10.3.5",
105
+ "@storybook/react": "^10.3.5",
105
106
  "@storybook/react-vite": "^10.3.5",
106
107
  "@tailwindcss/postcss": "^4.2.4",
107
108
  "@tailwindcss/vite": "^4.2.4",
@@ -8,7 +8,7 @@ const meta = {
8
8
  } satisfies Meta<typeof Accordion>;
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof Accordion>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -9,7 +9,7 @@ const meta = {
9
9
  } satisfies Meta<typeof Combobox>;
10
10
 
11
11
  export default meta;
12
- type Story = StoryObj<typeof meta>;
12
+ type Story = StoryObj<typeof Combobox>;
13
13
 
14
14
  const frameworks: ComboboxOption[] = [
15
15
  { value: "next", label: "Next.js", description: "React framework" },
@@ -3,14 +3,14 @@ import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { CurrencyInput } from "./currency-input";
4
4
  import { Label } from "./label";
5
5
 
6
- const meta = {
6
+ const meta: Meta<typeof CurrencyInput> = {
7
7
  title: "Components/CurrencyInput",
8
8
  component: CurrencyInput,
9
9
  tags: ["autodocs"],
10
- } satisfies Meta<typeof CurrencyInput>;
10
+ };
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof CurrencyInput>;
14
14
 
15
15
  function CurrencyInputDemo() {
16
16
  const [value, setValue] = useState<number | null>(null);
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { EmptyState, SearchEmptyState, ErrorEmptyState } from "./empty-state";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof EmptyState> = {
5
5
  title: "Components/EmptyState",
6
6
  component: EmptyState,
7
7
  tags: ["autodocs"],
@@ -15,10 +15,10 @@ const meta = {
15
15
  options: ["sm", "md", "lg"],
16
16
  },
17
17
  },
18
- } satisfies Meta<typeof EmptyState>;
18
+ };
19
19
 
20
20
  export default meta;
21
- type Story = StoryObj<typeof meta>;
21
+ type Story = StoryObj<typeof EmptyState>;
22
22
 
23
23
  export const Default: Story = {
24
24
  args: {
@@ -20,7 +20,7 @@ const meta = {
20
20
  } satisfies Meta<typeof Form>;
21
21
 
22
22
  export default meta;
23
- type Story = StoryObj<typeof meta>;
23
+ type Story = StoryObj<typeof Form>;
24
24
 
25
25
  function FormDemo() {
26
26
  const form = useForm({
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Icon — the static iconography contract.
5
+ *
6
+ * - `strokeWidth` 1.75, locking the global stroke to a refined-but-robust weight
7
+ * - Three canonical sizes:
8
+ * "xs" → 12px inline with small text, filter pills, badge labels
9
+ * "sm" → 16px default: buttons, nav rows, card metadata
10
+ * "md" → 20px nav tabs, dialog headers
11
+ *
12
+ * Usage:
13
+ * import { Icon } from "@bearmenu/ui/components/icon";
14
+ * <Icon icon={Star} size="sm" className="text-primary fill-primary" aria-hidden />
15
+ *
16
+ * Raw lucide imports remain acceptable where all three of strokeWidth, sizing
17
+ * and colouring are intentionally non-standard (an illustrated 48px empty
18
+ * state, a map marker). In those cases the caller sets `strokeWidth` itself.
19
+ *
20
+ * For icons that perform a gesture on interaction, see `motion-icon.tsx`.
21
+ * Which glyph means what is recorded in `lib/icon-registry.ts`.
22
+ */
23
+
24
+ import { type LucideIcon, type LucideProps } from "lucide-react";
25
+
26
+ import { cn } from "../lib/utils";
27
+
28
+ const SIZE_CLASSES = {
29
+ xs: "w-3 h-3",
30
+ sm: "w-4 h-4",
31
+ md: "w-5 h-5",
32
+ } as const;
33
+
34
+ export type IconSize = keyof typeof SIZE_CLASSES;
35
+
36
+ export type IconProps = Omit<LucideProps, "size"> & {
37
+ /** The lucide icon component to render. */
38
+ icon: LucideIcon;
39
+ /** Canonical size stop. "xs" = 12px | "sm" = 16px (default) | "md" = 20px */
40
+ size?: IconSize;
41
+ };
42
+
43
+ export function Icon({
44
+ icon: LucideIconComponent,
45
+ size = "sm",
46
+ strokeWidth = 1.75,
47
+ className,
48
+ ...props
49
+ }: IconProps) {
50
+ return (
51
+ <LucideIconComponent
52
+ strokeWidth={strokeWidth}
53
+ className={cn(SIZE_CLASSES[size], className)}
54
+ {...props}
55
+ />
56
+ );
57
+ }
@@ -8,7 +8,7 @@ const meta = {
8
8
  } satisfies Meta<typeof InputOTP>;
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof InputOTP>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -8,7 +8,7 @@ const meta = {
8
8
  } satisfies Meta<typeof MarkdownRenderer>;
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof MarkdownRenderer>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import * as React from "react";
4
+
5
+ /**
6
+ * First-paint correctness.
7
+ *
8
+ * `MotionIcon` poses itself with `controls.set()` inside an effect, which runs
9
+ * AFTER the first paint. Any sub-part whose rest pose is hidden therefore needs
10
+ * a matching static SVG attribute, or it paints one frame fully visible — a
11
+ * flash of a drawn check on every unselected row of a selection list, or of a
12
+ * moon behind a sun.
13
+ *
14
+ * The mock renders variants inertly, so what these assertions see is exactly
15
+ * what the browser paints before framer takes over.
16
+ */
17
+ vi.mock("framer-motion", () => {
18
+ const passthrough =
19
+ (tag: string) =>
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ ({ variants: _v, style: _s, ...rest }: any) =>
22
+ React.createElement(tag, rest);
23
+ return {
24
+ useAnimationControls: () => ({ set: () => {}, start: () => Promise.resolve() }),
25
+ motion: {
26
+ svg: passthrough("svg"),
27
+ g: passthrough("g"),
28
+ path: passthrough("path"),
29
+ circle: passthrough("circle"),
30
+ rect: passthrough("rect"),
31
+ },
32
+ };
33
+ });
34
+
35
+ import {
36
+ MotionCheck,
37
+ MotionEye,
38
+ MotionCopy,
39
+ MotionTheme,
40
+ MotionSearch,
41
+ MotionPlayPause,
42
+ MotionHeart,
43
+ MotionBookmark,
44
+ MotionStar,
45
+ } from "./motion-icon-controls";
46
+
47
+ /** Every element that should be invisible until the icon becomes active. */
48
+ const HIDDEN_AT_REST: Array<[string, React.ComponentType<Record<string, never>>, number]> = [
49
+ ["MotionCheck", MotionCheck, 1],
50
+ ["MotionEye", MotionEye, 1],
51
+ ["MotionCopy", MotionCopy, 1],
52
+ ["MotionTheme", MotionTheme, 1],
53
+ ["MotionSearch", MotionSearch, 1],
54
+ ["MotionPlayPause", MotionPlayPause, 2],
55
+ ];
56
+
57
+ describe("first paint, before the pose effect runs", () => {
58
+ it.each(HIDDEN_AT_REST)(
59
+ "%s hides its active-only parts with a static attribute",
60
+ (_name, Icon, expectedHidden) => {
61
+ const { container } = render(<Icon />);
62
+ const hidden = container.querySelectorAll('[opacity="0"]');
63
+ expect(hidden.length).toBe(expectedHidden);
64
+ }
65
+ );
66
+
67
+ it.each([
68
+ ["MotionHeart", MotionHeart],
69
+ ["MotionBookmark", MotionBookmark],
70
+ ["MotionStar", MotionStar],
71
+ ] as Array<[string, React.ComponentType<Record<string, never>>]>)(
72
+ "%s starts unfilled rather than solid",
73
+ (_name, Icon) => {
74
+ const { container } = render(<Icon />);
75
+ expect(container.querySelector('[fill-opacity="0"]')).not.toBeNull();
76
+ }
77
+ );
78
+ });