@beydesign/storybook 0.4.6 → 0.4.8

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.
@@ -248,7 +248,7 @@ const StyledButton = styled.button `
248
248
  var(--global-shadows-shadow-xs-color);
249
249
  }
250
250
  `;
251
- export const MainButton = ({ onClick, text, disabled = false, loading = false, type = ButtonType.Button, style, size = ButtonSize.xl, hierarchy = ButtonHierarchy.Primary, displayIcon, iconWeight = 'regular', displayIconMode = ButtonDisplayMode.Default, state = ButtonState.Default, shape = ButtonShape.Square, destructive = false, showLeadingIcon = false, showTrailingIcon = false, leadingIcon = 'Circle', trailingIcon = 'Circle', testId, }) => {
251
+ export const MainButton = ({ onClick, text, disabled = false, loading = false, type = ButtonType.Button, style, size = ButtonSize.xl, hierarchy = ButtonHierarchy.Primary, displayIcon, iconWeight = 'regular', displayIconMode = ButtonDisplayMode.Default, state = ButtonState.Default, shape = ButtonShape.Square, destructive = false, showLeadingIcon = false, showTrailingIcon = false, leadingIcon = 'Circle', trailingIcon = 'Circle', trailingIconSlot, testId, }) => {
252
252
  // Figma draws a 20px icon at every button size.
253
253
  const iconSize = 20;
254
254
  // Vertical padding comes from TEXT_BUTTON_HEIGHT; only the horizontal value
@@ -329,8 +329,8 @@ export const MainButton = ({ onClick, text, disabled = false, loading = false, t
329
329
  }, children: [showLeadingIcon &&
330
330
  leadingIcon &&
331
331
  getIconComponent(leadingIcon, iconProps), text && (_jsx(Typography, { size: getTextSize(), weight: TypographyWeight.SemiBold, children: text })), showTrailingIcon &&
332
- trailingIcon &&
333
- getIconComponent(trailingIcon, iconProps)] }));
332
+ (trailingIconSlot ??
333
+ (trailingIcon && getIconComponent(trailingIcon, iconProps)))] }));
334
334
  };
335
335
  return (_jsx(StyledButton, { onClick: onClick, type: type, disabled: state === ButtonState.Disabled || disabled, "$size": size, "$hierarchy": hierarchy, "$state": state, "$shape": shape, "$destructive": destructive, "$disabled": disabled, "$displayIconMode": displayIconMode, "$sizeStyles": sizeStyles, style: style, "data-testid": testId, children: renderContent() }));
336
336
  };
@@ -105,6 +105,13 @@ export interface MainButtonProps {
105
105
  leadingIcon?: IconName;
106
106
  /** Icon to show at the end of the button */
107
107
  trailingIcon?: IconName;
108
+ /**
109
+ * Custom node rendered in place of the name-based trailing icon. When
110
+ * provided (and `showTrailingIcon` is true) this takes precedence over
111
+ * `trailingIcon`, letting callers slot in an animated/custom icon while
112
+ * keeping the button's icon slot and spacing intact.
113
+ */
114
+ trailingIconSlot?: React.ReactNode;
108
115
  /** Test Id */
109
116
  testId?: string;
110
117
  }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ /**
3
+ * The avatar bust silhouette shown as the empty-state placeholder (no image),
4
+ * mirroring the Figma "Empty" state. Fills its container and is anchored to the
5
+ * bottom, so the shoulders bleed off the card edge. Uses `currentColor` so the
6
+ * fill can be driven by a design token from the parent.
7
+ */
8
+ export declare const AvatarSilhouette: React.FC;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The avatar bust silhouette shown as the empty-state placeholder (no image),
4
+ * mirroring the Figma "Empty" state. Fills its container and is anchored to the
5
+ * bottom, so the shoulders bleed off the card edge. Uses `currentColor` so the
6
+ * fill can be driven by a design token from the parent.
7
+ */
8
+ export const AvatarSilhouette = () => (_jsx("svg", { viewBox: '0 0 206 224', width: '100%', height: '100%', preserveAspectRatio: 'none', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', style: { display: 'block' }, "aria-hidden": 'true', focusable: 'false', children: _jsx("path", { d: 'M99.3964 0.0456982C113.077 -0.455929 125.202 3.14652 135.525 12.1236C148.651 23.5376 147.776 38.5586 145.834 54.3326C145.588 56.3287 145.199 58.4767 144.991 60.4808C149.483 60.7505 150.277 64.6019 149.882 68.4009C149.333 73.6891 147.427 80.6122 143.514 84.4206C142.672 85.1879 140.346 86.2549 140.073 86.8043C139.095 88.7733 138.467 91.5773 137.716 93.6661C135.881 98.7729 133.492 103.4 129.918 107.52C128.811 108.796 127.72 109.764 126.634 110.938C125.107 116.921 126.767 127.746 129.952 133.133C134.695 141.159 144.185 143.686 152.658 146.232C158.101 147.813 163.51 149.514 168.878 151.329C178.708 154.699 189.396 158.77 194.713 168.238C197.991 174.074 199.3 179.931 200.599 186.435C201.789 192.393 202.659 198.373 203.549 204.377C204.13 208.372 204.663 212.376 205.146 216.386C205.449 218.83 205.845 221.556 206 224H0L0.0225427 223.877C0.483937 221.262 0.77993 216.99 1.11962 214.171C1.77914 208.761 2.57988 203.368 3.5213 198C4.96167 189.348 6.76585 179.033 10.363 171.015C12.2654 166.773 15.904 162.193 19.855 159.538C29.5517 153.12 41.2795 150.131 52.2751 146.656C59.2908 144.439 66.4763 142.872 72.352 138.147C79.5078 132.395 81.5098 119.718 79.8167 111.052L79.7425 110.962C78.8337 109.888 77.6063 108.78 76.5931 107.611C73.4593 103.966 71.0023 99.7957 69.3371 95.2959C68.78 93.7779 68.2501 92.2503 67.7476 90.7137C66.0041 85.3616 66.4977 87.0427 62.1073 84.1196C56.9385 80.6782 51.0027 60.6731 61.2827 60.467C61.0959 57.2634 60.4807 53.6568 60.1419 50.3502C58.9309 38.5311 59.2264 24.9218 67.4846 15.453C70.7115 11.7529 73.9771 10.0768 78.4209 8.59052C79.0166 8.39123 81.4647 5.42845 82.4273 5.1527C87.3821 1.81482 93.4351 0.264287 99.3964 0.0456982Z', fill: 'currentColor' }) }));
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box } from '@mui/material';
4
4
  import { keyframes } from '@emotion/react';
5
- import { UserCircle, FilmSlate, ArrowsClockwise, Warning, } from '@phosphor-icons/react';
5
+ import { ArrowsClockwise, Warning } from '@phosphor-icons/react';
6
+ import { AvatarSilhouette } from './AvatarSilhouette';
6
7
  import { Typography, TypographySize, TypographyWeight } from '../Typography';
7
8
  import { Menu, Badge, BadgeColor, BadgeSize } from '../UI';
8
9
  import { MainButton, ButtonHierarchy, ButtonSize, ButtonDisplayMode, } from '../Buttons';
@@ -37,8 +38,8 @@ export const EntityCard = ({ view = 'avatar', name, image, description = '', sho
37
38
  const isProcessing = processing && !error;
38
39
  const canPreview = !!onPreview && !isProcessing && !error;
39
40
  const showActions = canPreview || hasMenu;
40
- const showBadge = showStatusBadge && !!statusBadgeText;
41
- const showImage = !!image && !imageError;
41
+ const showBadge = showStatusBadge && !!statusBadgeText && !error;
42
+ const showImage = !!image && !imageError && !error;
42
43
  const resolvedTestId = testId ?? `${entityKey}-${isVideo ? 'video' : 'entity'}-card`;
43
44
  const handlePreviewButtonClick = (event) => {
44
45
  event.stopPropagation();
@@ -60,12 +61,19 @@ export const EntityCard = ({ view = 'avatar', name, image, description = '', sho
60
61
  opacity: 1,
61
62
  pointerEvents: 'auto',
62
63
  },
63
- }, "data-testid": resolvedTestId, children: [_jsxs(Box, { position: 'relative', width: '100%', borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', overflow: 'hidden', sx: { aspectRatio: imageAspectRatio }, children: [showImage ? (_jsx("img", { src: image, alt: name, width: '100%', height: '100%', style: { objectFit: 'cover', height: '100%', width: '100%' }, onError: () => setImageError(true) })) : (_jsx(Box, { width: '100%', height: '100%', bgcolor: 'var(--color-background-bg-element)', display: 'flex', justifyContent: 'center', alignItems: 'center', children: isVideo ? (_jsx(FilmSlate, { size: 64, weight: 'thin', color: 'var(--color-text-text-subtle)' })) : (_jsx(UserCircle, { size: 74, weight: 'thin', color: 'var(--color-text-text-subtle)' })) })), _jsx(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', sx: {
64
+ }, "data-testid": resolvedTestId, children: [_jsxs(Box, { position: 'relative', width: '100%', borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', overflow: 'hidden', sx: { aspectRatio: imageAspectRatio }, children: [error ? (_jsx(Box, { width: '100%', height: '100%', bgcolor: 'var(--color-background-bg-badge-accent-red)', display: 'flex', justifyContent: 'center', alignItems: 'center', role: 'img', "aria-label": errorText, title: errorText, children: _jsx(Warning, { size: 50, color: 'var(--color-foreground-fg-error)' }) })) : showImage ? (_jsx("img", { src: image, alt: name, width: '100%', height: '100%', style: { objectFit: 'cover', height: '100%', width: '100%' }, onError: () => setImageError(true) })) : (_jsx(Box, { position: 'relative', width: '100%', height: '100%', overflow: 'hidden', bgcolor: 'var(--color-background-bg-button-disabled)', children: _jsx(Box, { position: 'absolute', bottom: 0, left: '50%', sx: {
65
+ transform: 'translateX(-50%)',
66
+ color: 'var(--color-border-border-subtle)',
67
+ aspectRatio: '206 / 224',
68
+ ...(isVideo
69
+ ? { height: '100%', maxWidth: '100%' }
70
+ : { width: '206px', height: '224px' }),
71
+ }, children: _jsx(AvatarSilhouette, {}) }) })), _jsx(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', sx: {
64
72
  backgroundImage: 'linear-gradient(180deg, rgba(21, 0, 73, 0) 0%, rgba(72, 29, 180, 0.152) 16.76%, rgba(32, 13, 82, 0.36) 100%)',
65
73
  opacity: hovered && !isProcessing && !error ? 1 : 0,
66
74
  transition: 'opacity 0.15s ease-in-out',
67
75
  pointerEvents: 'none',
68
- } }), isProcessing && (_jsxs(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', bgcolor: 'var(--color-background-bg-overlay-2)', sx: { zIndex: 1 }, children: [_jsx(Spinner, {}), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.SemiBold, color: 'var(--color-text-text-white)', children: processingText })] })), error && (_jsxs(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', bgcolor: 'var(--color-background-bg-error-solid)', sx: { zIndex: 3, opacity: 0.7 }, children: [_jsx(Warning, { size: 74, color: 'var(--color-text-text-white)' }), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.SemiBold, color: 'var(--color-text-text-white)', children: errorText })] })), showBadge && (_jsx(Box, { position: 'absolute', top: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', left: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', sx: { zIndex: 2 }, children: _jsx(Badge, { text: statusBadgeText, color: statusBadgeColor, size: BadgeSize.md, showIcon: true, icon: 'Circle', iconWeight: 'fill', iconSize: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)' }) })), showActions && (_jsxs(Box, { className: ACTIONS_CLASS, position: 'absolute', top: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', right: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', sx: {
76
+ } }), isProcessing && (_jsxs(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', bgcolor: 'var(--color-background-bg-overlay-2)', sx: { zIndex: 1 }, children: [_jsx(Spinner, {}), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.SemiBold, color: 'var(--color-text-text-white)', children: processingText })] })), showBadge && (_jsx(Box, { position: 'absolute', top: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', left: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', sx: { zIndex: 2 }, children: _jsx(Badge, { text: statusBadgeText, color: statusBadgeColor, size: BadgeSize.md, showIcon: true, icon: 'Circle', iconWeight: 'fill', iconSize: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)' }) })), showActions && (_jsxs(Box, { className: ACTIONS_CLASS, position: 'absolute', top: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', right: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', sx: {
69
77
  zIndex: 4,
70
78
  transition: 'opacity 0.15s ease-in-out',
71
79
  '@media (hover: hover)': {
@@ -69,12 +69,12 @@ const meta = {
69
69
  },
70
70
  error: {
71
71
  control: 'boolean',
72
- description: 'Show the error overlay (warning icon + label)',
72
+ description: 'Show the error state (tinted media area + warning icon)',
73
73
  table: { type: { summary: 'boolean' } },
74
74
  },
75
75
  errorText: {
76
76
  control: 'text',
77
- description: 'Error overlay label',
77
+ description: "Accessible label for the error state (the warning icon's aria-label/title)",
78
78
  table: {
79
79
  type: { summary: 'string' },
80
80
  defaultValue: { summary: 'Something went wrong' },
@@ -303,7 +303,7 @@ export const ErrorState = {
303
303
  parameters: {
304
304
  docs: {
305
305
  description: {
306
- story: 'An error overlay (warning icon + label) covers the image while the preview action is suppressed. The menu stays accessible so the item can still be retried or removed. Works across every view.',
306
+ story: 'The media area is replaced by a tinted error surface with a warning icon; the name stays visible and the preview action is suppressed. The menu stays accessible so the item can still be retried or removed. Works across every view.',
307
307
  },
308
308
  },
309
309
  },
@@ -29,9 +29,9 @@ export interface EntityCardProps {
29
29
  processing?: boolean;
30
30
  /** Processing overlay label. Defaults to "Please wait" */
31
31
  processingText?: string;
32
- /** Show the error overlay (warning icon + label) over the image */
32
+ /** Show the error state (tinted media area + warning icon) in place of the image */
33
33
  error?: boolean;
34
- /** Error overlay label. Defaults to "Something went wrong" */
34
+ /** Accessible label for the error state — exposed as the warning icon's aria-label/title. Defaults to "Something went wrong" */
35
35
  errorText?: string;
36
36
  /** Preview handler — renders the action button on hover */
37
37
  onPreview?: () => void;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box } from '@mui/material';
2
+ import { Box, useMediaQuery } from '@mui/material';
3
+ import { keyframes } from '@emotion/react';
3
4
  import { Typography, TypographySize, TypographyWeight } from '../Typography';
4
5
  import { MainButton, ButtonHierarchy, ButtonSize } from '../Buttons';
5
6
  import { BANNER_AVATARS, AVATAR_TILE_BACKGROUND, AVATAR_TILE_WIDTH, } from './bannerAvatars';
@@ -24,7 +25,35 @@ const IMAGE_COLUMN = [
24
25
  BANNER_AVATARS.micheal,
25
26
  BANNER_AVATARS.yuruo,
26
27
  ];
27
- export const ConversationalAvatarsBanner = ({ title, description, actions = [], background = DEFAULT_BACKGROUND, style: customStyle, testId, }) => {
28
+ // A longer, de-duplicated sequence used only when the collage animates: every
29
+ // avatar appears, no two adjacent tiles repeat, and the first tile differs from
30
+ // the last so doubling it for the seamless loop never places identical tiles
31
+ // next to each other at the seam.
32
+ const MARQUEE_COLUMN = [
33
+ BANNER_AVATARS.fjolla,
34
+ BANNER_AVATARS.micheal,
35
+ BANNER_AVATARS.yuruo,
36
+ BANNER_AVATARS.jerome,
37
+ BANNER_AVATARS.nelly,
38
+ BANNER_AVATARS.laura,
39
+ ];
40
+ // One full loop of the marquee — slow enough to read as ambient motion.
41
+ const MARQUEE_DURATION = '360s';
42
+ // The animated track is top-aligned (so it always overflows both edges of the
43
+ // clipped window and can never expose a gap) and nudged up by this much so the
44
+ // top tile clips into a sliver, matching the "bleed" look of the static column.
45
+ const MARQUEE_TOP_BLEED = 90;
46
+ // Vertical marquee: the track stacks two identical image sets, so translating by
47
+ // exactly one set height (-50% of the doubled track) lands on a pixel-identical
48
+ // frame and loops seamlessly.
49
+ const marqueeUp = keyframes `
50
+ from { transform: translateY(0); }
51
+ to { transform: translateY(-50%); }
52
+ `;
53
+ export const ConversationalAvatarsBanner = ({ title, description, actions = [], animateAvatars = false, background = DEFAULT_BACKGROUND, style: customStyle, testId, }) => {
54
+ // Users who ask their OS to minimize motion get the static collage.
55
+ const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
56
+ const avatarsAnimated = animateAvatars && !prefersReducedMotion;
28
57
  const renderAction = (action, index) => (_jsx(MainButton, { text: action.text, size: ButtonSize.xxs, hierarchy: action.primary
29
58
  ? ButtonHierarchy.Primary
30
59
  : ButtonHierarchy.SecondaryColor, onClick: action.onClick, testId: testId ? `${testId}-action-${index}` : undefined }, `${action.text}-${index}`));
@@ -46,10 +75,34 @@ export const ConversationalAvatarsBanner = ({ title, description, actions = [],
46
75
  // Push the strip past the right edge so the tile is clipped there.
47
76
  marginRight: `calc(-1 * var(--spacing-tokens-size-in-px-spacing-spacing-lg) - ${MOBILE_TILE_CUTOFF}px)`,
48
77
  },
49
- }, children: _jsx(Box, { display: "flex", flexDirection: "column", gap: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", position: "absolute", left: 0, right: 0, top: "50%", sx: { transform: 'translateY(-50%)' }, children: IMAGE_COLUMN.map((image, index) => (_jsx(Box, { component: "img", src: image, alt: "", width: "100%", height: `${TILE_HEIGHT}px`, flexShrink: 0, sx: {
50
- objectFit: 'cover',
51
- borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)',
52
- background: AVATAR_TILE_BACKGROUND,
53
- display: 'block',
54
- } }, index))) }) })] }));
78
+ }, children: _jsx(Box, { display: "flex", flexDirection: "column", position: "absolute", left: 0, right: 0,
79
+ // Static: vertically centered (Figma hero tile). Animated: top-aligned
80
+ // and nudged up by the bleed so the top tile clips into a sliver — a
81
+ // top-anchored track always overflows both edges, so the marquee can
82
+ // never scroll a gap into view.
83
+ top: avatarsAnimated ? 0 : '50%', sx: {
84
+ transform: avatarsAnimated
85
+ ? `translateY(-${MARQUEE_TOP_BLEED}px)`
86
+ : 'translateY(-50%)',
87
+ }, children: _jsx(Box, { display: "flex", flexDirection: "column",
88
+ // Animated tracks space tiles with marginBottom (on every tile,
89
+ // including the last) so the duplicated seam is uniform and the -50%
90
+ // loop is pixel-perfect; the static column uses gap.
91
+ gap: avatarsAnimated
92
+ ? 0
93
+ : 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', sx: {
94
+ animation: avatarsAnimated
95
+ ? `${marqueeUp} ${MARQUEE_DURATION} linear infinite`
96
+ : undefined,
97
+ }, children: (avatarsAnimated
98
+ ? [...MARQUEE_COLUMN, ...MARQUEE_COLUMN]
99
+ : IMAGE_COLUMN).map((image, index) => (_jsx(Box, { component: "img", src: image, alt: "", width: "100%", height: `${TILE_HEIGHT}px`, flexShrink: 0, sx: {
100
+ objectFit: 'cover',
101
+ borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)',
102
+ background: AVATAR_TILE_BACKGROUND,
103
+ display: 'block',
104
+ ...(avatarsAnimated && {
105
+ marginBottom: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)',
106
+ }),
107
+ } }, index))) }) }) })] }));
55
108
  };
@@ -4,4 +4,5 @@ declare const meta: Meta<typeof ConversationalAvatarsBanner>;
4
4
  export default meta;
5
5
  type Story = StoryObj<typeof ConversationalAvatarsBanner>;
6
6
  export declare const Default: Story;
7
+ export declare const Animated: Story;
7
8
  export declare const Mobile: Story;
@@ -26,6 +26,14 @@ const meta = {
26
26
  description: 'Action buttons — set primary: true for the brand (violet) button',
27
27
  table: { type: { summary: 'BannerAction[]' } },
28
28
  },
29
+ animateAvatars: {
30
+ control: 'boolean',
31
+ description: 'Continuously scroll (marquee) the avatar collage column. Honors prefers-reduced-motion.',
32
+ table: {
33
+ type: { summary: 'boolean' },
34
+ defaultValue: { summary: 'false' },
35
+ },
36
+ },
29
37
  background: {
30
38
  control: 'text',
31
39
  description: 'Background of the banner',
@@ -46,6 +54,22 @@ export const Default = {
46
54
  actions: [{ text: 'Create New Agent' }],
47
55
  },
48
56
  };
57
+ export const Animated = {
58
+ args: {
59
+ ...Default.args,
60
+ animateAvatars: true,
61
+ },
62
+ parameters: {
63
+ docs: {
64
+ description: {
65
+ story: 'The same banner with animateAvatars on: the collage column scrolls ' +
66
+ 'as a seamless vertical marquee (top and bottom tiles bleed off the ' +
67
+ 'clipped edges, no gap or repeated tile at the loop seam). ' +
68
+ 'Automatically suppressed for users with prefers-reduced-motion.',
69
+ },
70
+ },
71
+ },
72
+ };
49
73
  const FIGMA_MOBILE_VIEWPORT = {
50
74
  viewport: {
51
75
  viewports: {
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { DissolveIconProps } from './types';
3
+ /**
4
+ * An icon that dissolves into dust and reforms whenever its `icon` changes.
5
+ *
6
+ * The effect is a pure SVG filter: `feTurbulence` generates a static noise field
7
+ * that `feDisplacementMap` uses to shove the glyph's pixels around. Driving the
8
+ * displacement `scale` from 0 up to a peak (while fading + blurring + drifting)
9
+ * makes the icon crumble into particles; swapping the glyph at the peak and
10
+ * running the scale back down makes the new icon coalesce from the same dust.
11
+ */
12
+ export declare const DissolveIcon: React.FC<DissolveIconProps>;
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { useMediaQuery } from '@mui/material';
4
+ import { getIconComponent } from '../../utils';
5
+ // Peak displacement (as a fraction of the icon size) that the turbulence pushes
6
+ // the glyph's pixels apart at the midpoint of the transition — strong enough to
7
+ // read as scattering particles, gentle enough that the shape stays recognizable
8
+ // on the way out and in.
9
+ const DISPLACEMENT_RATIO = 0.7;
10
+ // Layered on top of the raw displacement to sell "dust lifting away" rather than
11
+ // just noise: the glyph drifts up slightly and softens as it scatters.
12
+ const DRIFT_PX = 3;
13
+ const MAX_BLUR_PX = 1.2;
14
+ // Smoothstep so the scatter eases in and out instead of moving linearly.
15
+ const easeInOut = (t) => t * t * (3 - 2 * t);
16
+ /**
17
+ * An icon that dissolves into dust and reforms whenever its `icon` changes.
18
+ *
19
+ * The effect is a pure SVG filter: `feTurbulence` generates a static noise field
20
+ * that `feDisplacementMap` uses to shove the glyph's pixels around. Driving the
21
+ * displacement `scale` from 0 up to a peak (while fading + blurring + drifting)
22
+ * makes the icon crumble into particles; swapping the glyph at the peak and
23
+ * running the scale back down makes the new icon coalesce from the same dust.
24
+ */
25
+ export const DissolveIcon = ({ icon, size = 20, weight = 'regular', color = 'currentColor', durationMs = 600, style, testId, }) => {
26
+ const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
27
+ // Colons from useId aren't valid inside a `url(#id)` filter reference, so strip
28
+ // them; the result is still unique per instance (avoids filter collisions when
29
+ // several DissolveIcons render on the same page).
30
+ const filterId = `dissolve-${React.useId().replace(/:/g, '')}`;
31
+ const maxScale = size * DISPLACEMENT_RATIO;
32
+ // `displayedIcon` is what actually renders; `progress` runs 0 (fully formed) ->
33
+ // 1 (fully dust) -> 0 as the transition plays, with the glyph swapped at 1.
34
+ const [displayedIcon, setDisplayedIcon] = React.useState(icon);
35
+ const [progress, setProgress] = React.useState(0);
36
+ // Tracked in a ref (not effect deps) so mid-animation state updates don't tear
37
+ // down and restart the running rAF loop.
38
+ const displayedIconRef = React.useRef(icon);
39
+ const frameRef = React.useRef();
40
+ React.useEffect(() => {
41
+ if (icon === displayedIconRef.current)
42
+ return;
43
+ if (prefersReducedMotion) {
44
+ displayedIconRef.current = icon;
45
+ setDisplayedIcon(icon);
46
+ setProgress(0);
47
+ return;
48
+ }
49
+ const start = performance.now();
50
+ const half = durationMs / 2;
51
+ let swapped = false;
52
+ const tick = (now) => {
53
+ const elapsed = now - start;
54
+ if (elapsed < half) {
55
+ // Dissolve the outgoing glyph out (0 -> 1).
56
+ setProgress(Math.min(1, elapsed / half));
57
+ }
58
+ else if (elapsed < durationMs) {
59
+ // At the peak, swap to the new glyph, then reform it (1 -> 0).
60
+ if (!swapped) {
61
+ swapped = true;
62
+ displayedIconRef.current = icon;
63
+ setDisplayedIcon(icon);
64
+ }
65
+ setProgress(Math.max(0, 1 - (elapsed - half) / half));
66
+ }
67
+ else {
68
+ if (!swapped) {
69
+ displayedIconRef.current = icon;
70
+ setDisplayedIcon(icon);
71
+ }
72
+ setProgress(0);
73
+ return;
74
+ }
75
+ frameRef.current = requestAnimationFrame(tick);
76
+ };
77
+ frameRef.current = requestAnimationFrame(tick);
78
+ return () => {
79
+ if (frameRef.current)
80
+ cancelAnimationFrame(frameRef.current);
81
+ };
82
+ }, [icon, durationMs, prefersReducedMotion]);
83
+ const eased = easeInOut(progress);
84
+ const scale = maxScale * eased;
85
+ const active = eased > 0;
86
+ return (_jsxs("span", { "data-testid": testId, style: {
87
+ display: 'inline-flex',
88
+ alignItems: 'center',
89
+ justifyContent: 'center',
90
+ width: size,
91
+ height: size,
92
+ lineHeight: 0,
93
+ color,
94
+ // Only pay for the filter while the transition is running; a crisp,
95
+ // unfiltered glyph the rest of the time.
96
+ filter: active
97
+ ? `url(#${filterId}) blur(${MAX_BLUR_PX * eased}px)`
98
+ : 'none',
99
+ opacity: 1 - eased,
100
+ transform: `translateY(${-DRIFT_PX * eased}px)`,
101
+ ...style,
102
+ }, children: [_jsx("svg", { "aria-hidden": true, width: 0, height: 0, style: { position: 'absolute' }, children: _jsxs("filter", { id: filterId, x: "-50%", y: "-50%", width: "200%", height: "200%", colorInterpolationFilters: "sRGB", children: [_jsx("feTurbulence", { type: "fractalNoise", baseFrequency: "0.9", numOctaves: 2, seed: 7, result: "noise" }), _jsx("feDisplacementMap", { in: "SourceGraphic", in2: "noise", scale: scale, xChannelSelector: "R", yChannelSelector: "G" })] }) }), getIconComponent(displayedIcon, {
103
+ size,
104
+ weight,
105
+ color: 'currentColor',
106
+ })] }));
107
+ };
@@ -0,0 +1,20 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { DissolveIcon } from './DissolveIcon';
3
+ declare const meta: Meta<typeof DissolveIcon>;
4
+ export default meta;
5
+ type Story = StoryObj<typeof DissolveIcon>;
6
+ /**
7
+ * Change the `icon` control (e.g. from `Copy` to `Check`) to watch the glyph
8
+ * scatter into dust and reform.
9
+ */
10
+ export declare const Playground: Story;
11
+ /**
12
+ * The copy-confirmation use case: tap the button to toggle between the Copy and
13
+ * Check icons and see them dissolve into one another.
14
+ */
15
+ export declare const CopyToCheck: Story;
16
+ /**
17
+ * Auto-cycles through a set of icons to show the effect works for any glyph
18
+ * change, not just a two-state toggle.
19
+ */
20
+ export declare const Cycle: Story;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { DissolveIcon } from './DissolveIcon';
4
+ import { MainButton, ButtonHierarchy, ButtonSize } from '../Buttons';
5
+ const meta = {
6
+ title: 'Design System/Components/UI/DissolveIcon',
7
+ component: DissolveIcon,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ tags: ['autodocs'],
12
+ argTypes: {
13
+ icon: {
14
+ control: 'text',
15
+ description: 'The icon to display. Changing this triggers the dust dissolve/reform transition.',
16
+ table: { type: { summary: 'IconName' } },
17
+ },
18
+ size: {
19
+ control: { type: 'number' },
20
+ description: 'Pixel size of the square icon',
21
+ table: { type: { summary: 'number' }, defaultValue: { summary: '20' } },
22
+ },
23
+ weight: {
24
+ control: 'select',
25
+ options: ['thin', 'light', 'regular', 'bold', 'fill', 'duotone'],
26
+ description: 'Phosphor icon weight',
27
+ table: {
28
+ type: { summary: 'IconWeight' },
29
+ defaultValue: { summary: 'regular' },
30
+ },
31
+ },
32
+ color: {
33
+ control: 'text',
34
+ description: 'Icon color (any CSS color or design token)',
35
+ table: {
36
+ type: { summary: 'string' },
37
+ defaultValue: { summary: 'currentColor' },
38
+ },
39
+ },
40
+ durationMs: {
41
+ control: { type: 'number' },
42
+ description: 'Total duration of the dissolve-out + reform-in transition',
43
+ table: { type: { summary: 'number' }, defaultValue: { summary: '600' } },
44
+ },
45
+ },
46
+ };
47
+ export default meta;
48
+ /**
49
+ * Change the `icon` control (e.g. from `Copy` to `Check`) to watch the glyph
50
+ * scatter into dust and reform.
51
+ */
52
+ export const Playground = {
53
+ args: {
54
+ icon: 'Copy',
55
+ size: 32,
56
+ color: 'var(--color-foreground-fg-primary)',
57
+ durationMs: 600,
58
+ },
59
+ };
60
+ const COPY_STATES = ['Copy', 'Check'];
61
+ /**
62
+ * The copy-confirmation use case: tap the button to toggle between the Copy and
63
+ * Check icons and see them dissolve into one another.
64
+ */
65
+ export const CopyToCheck = {
66
+ render: (args) => {
67
+ const CopyDemo = () => {
68
+ const [index, setIndex] = useState(0);
69
+ const icon = COPY_STATES[index];
70
+ return (_jsxs("div", { style: {
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ alignItems: 'center',
74
+ gap: 24,
75
+ }, children: [_jsx(DissolveIcon, { ...args, icon: icon, size: 40, color: "var(--color-foreground-fg-success)" }), _jsx(MainButton, { text: icon === 'Check' ? 'Copied!' : 'Copy code', size: ButtonSize.md, hierarchy: ButtonHierarchy.SecondaryColor, onClick: () => setIndex((i) => (i + 1) % COPY_STATES.length) })] }));
76
+ };
77
+ return _jsx(CopyDemo, {});
78
+ },
79
+ };
80
+ const CYCLE_ICONS = ['Sun', 'Moon', 'Star', 'Heart', 'Lightning'];
81
+ /**
82
+ * Auto-cycles through a set of icons to show the effect works for any glyph
83
+ * change, not just a two-state toggle.
84
+ */
85
+ export const Cycle = {
86
+ render: (args) => {
87
+ const CycleDemo = () => {
88
+ const [index, setIndex] = useState(0);
89
+ return (_jsxs("div", { style: {
90
+ display: 'flex',
91
+ flexDirection: 'column',
92
+ alignItems: 'center',
93
+ gap: 24,
94
+ }, children: [_jsx(DissolveIcon, { ...args, icon: CYCLE_ICONS[index], size: 48, color: "var(--color-foreground-fg-primary)" }), _jsx(MainButton, { text: "Next icon", size: ButtonSize.md, hierarchy: ButtonHierarchy.Primary, onClick: () => setIndex((i) => (i + 1) % CYCLE_ICONS.length) })] }));
95
+ };
96
+ return _jsx(CycleDemo, {});
97
+ },
98
+ };
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
2
3
  import { Box, useMediaQuery } from '@mui/material';
4
+ import { keyframes } from '@emotion/react';
3
5
  import { Typography, TypographySize, TypographyWeight } from '../Typography';
4
6
  import { MainButton, ButtonHierarchy, ButtonSize } from '../Buttons';
5
7
  import { BANNER_AVATARS as AVATARS, AVATAR_TILE_BACKGROUND, } from './bannerAvatars';
8
+ import { DissolveIcon } from './DissolveIcon';
6
9
  const DEFAULT_BACKGROUND = 'linear-gradient(180deg, rgba(194, 171, 252, 0.08) 0%, rgba(128, 96, 198, 0.2) 100%)';
7
10
  // Below this width the banner switches to the Figma mobile variant: text
8
11
  // centered, features/footer hidden, promo code + CTA stacked, and the mobile*
@@ -17,17 +20,70 @@ const HIDE_COLLAGE = '@media (max-width: 1121px)';
17
20
  // side collages adjacent to the text on wide (e.g. Full HD) viewports instead
18
21
  // of stretching apart; the row stays centered so extra space becomes gutters.
19
22
  const CONTENT_MAX_WIDTH = '458px';
23
+ // One full loop of the avatar marquee. Slow enough to read as ambient motion
24
+ // rather than a distraction.
25
+ const MARQUEE_DURATION = '180s';
26
+ // When the collage animates, the track is top-aligned (so it always overflows
27
+ // both edges of the 220px window and can never expose a gap) and nudged up by
28
+ // this much so the top row of tiles is clipped into a sliver, matching the
29
+ // "bleed" look of the static collage instead of sitting flush against the edge.
30
+ const MARQUEE_TOP_BLEED = 90;
31
+ // How long the copy-confirmation state (Check icon + pulse) lingers before the
32
+ // button reverts to its idle Copy state.
33
+ const COPY_FEEDBACK_MS = 1200;
34
+ // Vertical marquee: the track holds two identical image sets stacked, so
35
+ // translating by exactly one set height (-50% of the doubled track) lands on a
36
+ // pixel-identical frame and loops seamlessly. Even columns scroll up, odd
37
+ // columns scroll down, so adjacent columns drift in opposite directions.
38
+ const marqueeUp = keyframes `
39
+ from { transform: translateY(0); }
40
+ to { transform: translateY(-50%); }
41
+ `;
42
+ const marqueeDown = keyframes `
43
+ from { transform: translateY(-50%); }
44
+ to { transform: translateY(0); }
45
+ `;
46
+ // A restrained pop the promo-code *label* plays as it morphs to "COPIED!" — a
47
+ // small, tasteful scale on the pill only (never the whole button), so the copy
48
+ // confirmation reads as a deliberate success state rather than a bounce.
49
+ const labelPop = keyframes `
50
+ 0% { transform: scale(1); }
51
+ 40% { transform: scale(1.08); }
52
+ 100% { transform: scale(1); }
53
+ `;
20
54
  // The middle tile of each column is the fully-visible "hero"; the first and
21
55
  // last tiles are clipped to slivers above/below by the banner height. Columns
22
56
  // are centered in the banner, then `offset` shifts them vertically (negative =
23
57
  // up) so the two columns of each side interlock, matching the Figma layout.
58
+ //
59
+ // The two columns on a side hold DISJOINT sets of avatars (there are exactly 6
60
+ // avatars, split 3/3), so the same face never shows up in both columns of a
61
+ // side — statically or while the marquee scrolls. Each column's `marqueeImages`
62
+ // is therefore just its 3 avatars; doubling that set for the seamless loop keeps
63
+ // no two adjacent tiles (or seam tiles) identical since the three are distinct.
24
64
  const LEFT_COLUMNS = [
25
- { offset: 0, images: [AVATARS.jerome, AVATARS.micheal, AVATARS.jerome] },
26
- { offset: -40, images: [AVATARS.laura, AVATARS.nelly, AVATARS.fjolla] },
65
+ {
66
+ offset: -40,
67
+ images: [AVATARS.micheal, AVATARS.nelly, AVATARS.fjolla],
68
+ marqueeImages: [AVATARS.nelly, AVATARS.micheal, AVATARS.fjolla],
69
+ },
70
+ {
71
+ offset: 0,
72
+ images: [AVATARS.laura, AVATARS.jerome, AVATARS.yuruo],
73
+ marqueeImages: [AVATARS.laura, AVATARS.jerome, AVATARS.yuruo],
74
+ },
27
75
  ];
28
76
  const RIGHT_COLUMNS = [
29
- { offset: 0, images: [AVATARS.jerome, AVATARS.fjolla, AVATARS.yuruo] },
30
- { offset: -20, images: [AVATARS.micheal, AVATARS.laura, AVATARS.nelly] },
77
+ {
78
+ offset: -20,
79
+ images: [AVATARS.laura, AVATARS.yuruo, AVATARS.jerome],
80
+ marqueeImages: [AVATARS.yuruo, AVATARS.laura, AVATARS.jerome],
81
+ },
82
+ {
83
+ offset: 0,
84
+ images: [AVATARS.fjolla, AVATARS.yuruo, AVATARS.nelly],
85
+ marqueeImages: [AVATARS.fjolla, AVATARS.yuruo, AVATARS.nelly],
86
+ },
31
87
  ];
32
88
  export const PromoBanner = ({ title = 'Real-time AI avatars for every conversation.', description = 'Trusted by developers, startups, educators, and enterprises to build engaging AI experiences at scale:', features = [
33
89
  'Real-Time',
@@ -35,18 +91,38 @@ export const PromoBanner = ({ title = 'Real-time AI avatars for every conversati
35
91
  'API-first',
36
92
  'Enterprise-ready',
37
93
  'GDPR Compliant',
38
- ], promoCode, promoCodeLabel = 'YOUR PROMO CODE', onCopyPromoCode, ctaText, onCtaClick, ctaLoading, ctaDisabled, footerText, mobileTitle, mobileDescription, mobileCtaText, showImages = true, background = DEFAULT_BACKGROUND, style: customStyle, testId, }) => {
94
+ ], promoCode, promoCodeLabel = 'YOUR PROMO CODE', onCopyPromoCode, ctaText, onCtaClick, ctaLoading, ctaDisabled, footerText, mobileTitle, mobileDescription, mobileCtaText, showImages = true, animateAvatars = false, animateActions = false, background = DEFAULT_BACKGROUND, style: customStyle, testId, }) => {
39
95
  // Below 640px the banner renders the Figma mobile variant, swapping to any
40
96
  // provided mobile* copy and falling back to the desktop content otherwise.
41
97
  const isMobile = useMediaQuery(MOBILE_QUERY);
42
98
  const resolvedTitle = isMobile && mobileTitle ? mobileTitle : title;
43
99
  const resolvedDescription = isMobile && mobileDescription ? mobileDescription : description;
44
100
  const resolvedCtaText = isMobile && mobileCtaText ? mobileCtaText : ctaText;
101
+ // Users who ask their OS to minimize motion get a static banner: the marquee
102
+ // and the hover-lift/pulse are suppressed, while the (non-motion) copy->check
103
+ // icon swap still fires as feedback.
104
+ const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
105
+ const avatarsAnimated = animateAvatars && !prefersReducedMotion;
106
+ const actionsMotion = animateActions && !prefersReducedMotion;
107
+ // Tracks the just-copied state so the promo-code button can show a Check icon
108
+ // (and, when motion is allowed, a pulse) for a moment after a copy.
109
+ const [copied, setCopied] = React.useState(false);
110
+ const copyResetTimer = React.useRef();
111
+ React.useEffect(() => () => {
112
+ if (copyResetTimer.current)
113
+ clearTimeout(copyResetTimer.current);
114
+ }, []);
45
115
  const handleCopyPromoCode = () => {
46
116
  if (!promoCode)
47
117
  return;
48
118
  navigator.clipboard?.writeText(promoCode);
49
119
  onCopyPromoCode?.(promoCode);
120
+ if (animateActions) {
121
+ setCopied(true);
122
+ if (copyResetTimer.current)
123
+ clearTimeout(copyResetTimer.current);
124
+ copyResetTimer.current = setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
125
+ }
50
126
  };
51
127
  // Features are part of the desktop-only layout (hidden on the mobile variant).
52
128
  const featuresText = !isMobile && features?.length
@@ -55,11 +131,47 @@ export const PromoBanner = ({ title = 'Real-time AI avatars for every conversati
55
131
  const renderAvatarMatrix = (columns) => {
56
132
  if (!showImages)
57
133
  return null;
58
- return (_jsx(Box, { display: "flex", alignItems: "center", gap: "var(--spacing-tokens-size-in-px-spacing-spacing-lg)", height: "100%", flexShrink: 0, overflow: "hidden", sx: { [HIDE_COLLAGE]: { display: 'none' } }, children: columns.map((column, columnIndex) => (_jsx(Box, { display: "flex", flexDirection: "column", gap: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", flexShrink: 0, sx: { transform: `translateY(${column.offset}px)` }, children: column.images.map((image, imageIndex) => (_jsx(Box, { component: "img", src: image, alt: "", width: "130px", height: "150px", flexShrink: 0, sx: {
59
- objectFit: 'cover',
60
- borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)',
61
- background: AVATAR_TILE_BACKGROUND,
62
- } }, imageIndex))) }, columnIndex))) }));
134
+ return (_jsx(Box, { display: "flex",
135
+ // Static collage is vertically centered (Figma "hero middle tile"), but
136
+ // an animated track must be top-aligned: centering a track only two sets
137
+ // tall lets its bottom edge scroll into the window and expose a gap, so
138
+ // we pin it to the top where it always overflows both edges instead.
139
+ alignItems: avatarsAnimated ? 'flex-start' : 'center', gap: "var(--spacing-tokens-size-in-px-spacing-spacing-lg)", height: "100%", flexShrink: 0, overflow: "hidden", sx: { [HIDE_COLLAGE]: { display: 'none' } }, children: columns.map((column, columnIndex) => {
140
+ // When animating, the track holds two copies of the (de-duplicated,
141
+ // full-length) marquee sequence so it can scroll one set's worth and
142
+ // loop seamlessly with no repeated tile at the seam. Even columns
143
+ // scroll up, odd columns scroll down.
144
+ const trackImages = avatarsAnimated
145
+ ? [...column.marqueeImages, ...column.marqueeImages]
146
+ : column.images;
147
+ const trackAnimation = avatarsAnimated
148
+ ? `${columnIndex % 2 === 0 ? marqueeUp : marqueeDown} ${MARQUEE_DURATION} linear infinite`
149
+ : undefined;
150
+ return (
151
+ // Outer wrapper carries the static positioning; the inner track
152
+ // carries the marquee animation so the two transforms don't clash.
153
+ // When animating we still layer the per-column interlock `offset` on
154
+ // top of the top-bleed nudge, so the two columns stay staggered
155
+ // (never aligned tile-for-tile) instead of starting in lockstep.
156
+ _jsx(Box, { display: "flex", flexDirection: "column", flexShrink: 0, sx: {
157
+ transform: `translateY(${avatarsAnimated
158
+ ? -MARQUEE_TOP_BLEED + column.offset
159
+ : column.offset}px)`,
160
+ }, children: _jsx(Box, { display: "flex", flexDirection: "column",
161
+ // Animated tracks space tiles with marginBottom (applied to every
162
+ // tile, including the last) so the duplicated seam is uniform and
163
+ // the -50% loop is pixel-perfect; the static layout uses gap.
164
+ gap: avatarsAnimated
165
+ ? 0
166
+ : 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', sx: { animation: trackAnimation }, children: trackImages.map((image, imageIndex) => (_jsx(Box, { component: "img", src: image, alt: "", width: "130px", height: "150px", flexShrink: 0, sx: {
167
+ objectFit: 'cover',
168
+ borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)',
169
+ background: AVATAR_TILE_BACKGROUND,
170
+ ...(avatarsAnimated && {
171
+ marginBottom: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)',
172
+ }),
173
+ } }, imageIndex))) }) }, columnIndex));
174
+ }) }));
63
175
  };
64
176
  return (_jsxs(Box, { display: "flex", alignItems: "center", justifyContent: "center", gap: "40px", height: "220px", width: "100%", overflow: "hidden", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-lg)", paddingX: "var(--spacing-tokens-size-in-px-spacing-spacing-2xl)", sx: {
65
177
  background,
@@ -80,11 +192,28 @@ export const PromoBanner = ({ title = 'Real-time AI avatars for every conversati
80
192
  width: 'fit-content',
81
193
  '& button': { width: '100%' },
82
194
  },
83
- }, children: [promoCode && (_jsxs(Box, { position: "relative", display: "flex", children: [_jsx(MainButton, { text: promoCode, size: ButtonSize.md, hierarchy: ButtonHierarchy.SecondaryColor, showTrailingIcon: true, trailingIcon: "Copy", onClick: handleCopyPromoCode, style: {
195
+ }, children: [promoCode && (_jsxs(Box, { position: "relative", display: "flex", children: [_jsx(MainButton, { text: promoCode, size: ButtonSize.md, hierarchy: ButtonHierarchy.SecondaryColor, showTrailingIcon: true, trailingIcon: copied ? 'Check' : 'Copy', trailingIconSlot: actionsMotion ? (_jsx(DissolveIcon, { icon: copied ? 'Check' : 'Copy', size: 20, color: "currentColor" })) : undefined, onClick: handleCopyPromoCode, style: {
84
196
  border: '1px dashed var(--color-border-border-secondary-action)',
85
197
  height: '100%',
86
- }, testId: testId ? `${testId}-promo-code` : undefined }), promoCodeLabel && (_jsx(Box, { position: "absolute", top: "-13px", left: "-8px", bgcolor: "var(--color-background-bg-icon-badge)", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-xs)", padding: "var(--spacing-tokens-size-in-px-spacing-spacing-xxs) var(--spacing-tokens-size-in-px-spacing-spacing-sm)", whiteSpace: "nowrap", zIndex: 1, children: _jsx(Typography, { size: TypographySize.Text2XS, weight: TypographyWeight.SemiBold,
87
- // Invariant violet label (bg-icon-badge) -> pin to the
88
- // invariant dark text so it stays legible in both themes.
89
- color: "var(--color-text-text-dark)", children: promoCodeLabel }) }))] })), resolvedCtaText && (_jsx(MainButton, { text: resolvedCtaText, size: ButtonSize.md, hierarchy: ButtonHierarchy.Primary, onClick: onCtaClick, loading: ctaLoading, disabled: ctaDisabled, testId: testId ? `${testId}-cta` : undefined }))] })), !isMobile && footerText && (_jsx(Typography, { size: TypographySize.TextXS, weight: TypographyWeight.Regular, color: "var(--color-text-text-title)", textStyle: { width: '100%' }, children: footerText }))] }), renderAvatarMatrix(RIGHT_COLUMNS)] }));
198
+ }, testId: testId ? `${testId}-promo-code` : undefined }), promoCodeLabel && (_jsx(Box, { position: "absolute", top: "-13px", left: "-8px", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-xs)", padding: "var(--spacing-tokens-size-in-px-spacing-spacing-xxs) var(--spacing-tokens-size-in-px-spacing-spacing-sm)", whiteSpace: "nowrap", zIndex: 1, sx: {
199
+ // On copy the pill morphs from the violet "promo code"
200
+ // badge into the DS success badge, easing the colour so the
201
+ // state change feels intentional rather than an instant swap.
202
+ backgroundColor: copied
203
+ ? 'var(--color-background-bg-badge-accent-success)'
204
+ : 'var(--color-background-bg-icon-badge)',
205
+ transition: 'background-color 0.25s ease',
206
+ // The pop is motion, so it's gated on actionsMotion; the
207
+ // colour/label morph above is a non-motion state change and
208
+ // still fires for reduced-motion users as feedback.
209
+ ...(actionsMotion &&
210
+ copied && {
211
+ animation: `${labelPop} 0.35s ease`,
212
+ }),
213
+ }, children: _jsx(Typography, { size: TypographySize.Text2XS, weight: TypographyWeight.SemiBold,
214
+ // Both the violet and success badge fills are invariant, so
215
+ // pin the label to the matching on-colour text token.
216
+ color: copied
217
+ ? 'var(--color-text-text-success-on-color)'
218
+ : 'var(--color-text-text-dark)', children: copied ? 'COPIED!' : promoCodeLabel }) }))] })), resolvedCtaText && (_jsx(MainButton, { text: resolvedCtaText, size: ButtonSize.md, hierarchy: ButtonHierarchy.Primary, onClick: onCtaClick, loading: ctaLoading, disabled: ctaDisabled, testId: testId ? `${testId}-cta` : undefined }))] })), !isMobile && footerText && (_jsx(Typography, { size: TypographySize.TextXS, weight: TypographyWeight.Regular, color: "var(--color-text-text-title)", textStyle: { width: '100%' }, children: footerText }))] }), renderAvatarMatrix(RIGHT_COLUMNS)] }));
90
219
  };
@@ -7,4 +7,5 @@ export declare const Default: Story;
7
7
  export declare const WithoutImages: Story;
8
8
  export declare const WithoutPromoCode: Story;
9
9
  export declare const TitleAndCtaOnly: Story;
10
+ export declare const Animated: Story;
10
11
  export declare const Mobile: Story;
@@ -102,6 +102,22 @@ const meta = {
102
102
  type: { summary: 'boolean' },
103
103
  },
104
104
  },
105
+ animateAvatars: {
106
+ control: 'boolean',
107
+ description: 'Continuously scroll (marquee) the side avatar collages. Honors prefers-reduced-motion.',
108
+ table: {
109
+ type: { summary: 'boolean' },
110
+ defaultValue: { summary: 'false' },
111
+ },
112
+ },
113
+ animateActions: {
114
+ control: 'boolean',
115
+ description: 'Enable the premium copy confirmation: the promo-code label morphs into a success "COPIED!" state (Copy->Check icon swap + subtle pop). Honors prefers-reduced-motion.',
116
+ table: {
117
+ type: { summary: 'boolean' },
118
+ defaultValue: { summary: 'false' },
119
+ },
120
+ },
105
121
  background: {
106
122
  control: 'text',
107
123
  description: 'Background of the banner',
@@ -162,6 +178,28 @@ export const TitleAndCtaOnly = {
162
178
  ctaText: 'Get Started',
163
179
  },
164
180
  };
181
+ export const Animated = {
182
+ args: {
183
+ ...Default.args,
184
+ animateAvatars: true,
185
+ animateActions: true,
186
+ },
187
+ parameters: {
188
+ docs: {
189
+ description: {
190
+ story: 'The same PromoBanner as Default with both optional animations on: ' +
191
+ 'the side avatar collages scroll as a seamless vertical marquee ' +
192
+ '(adjacent columns drift in opposite directions), and copying the ' +
193
+ 'promo code morphs its label pill into a success "COPIED!" state with ' +
194
+ 'a Copy->Check icon swap and a subtle pop. Both are gated ' +
195
+ 'independently via animateAvatars / animateActions and are ' +
196
+ 'automatically suppressed for users with prefers-reduced-motion (the ' +
197
+ 'label/colour morph and Copy->Check swap still fire as non-motion ' +
198
+ 'feedback; only the marquee and the pop are dropped).',
199
+ },
200
+ },
201
+ },
202
+ };
165
203
  const FIGMA_MOBILE_VIEWPORT = {
166
204
  viewport: {
167
205
  viewports: {
@@ -3,12 +3,12 @@
3
3
  // the same set of avatars.
4
4
  const AVATAR_BASE = 'https://storage.googleapis.com/bey-dev-public';
5
5
  export const BANNER_AVATARS = {
6
- nelly: `${AVATAR_BASE}/nelly.jpg`,
7
- micheal: `${AVATAR_BASE}/micheal.jpg`,
8
- fjolla: `${AVATAR_BASE}/fjolla.png`,
9
- laura: `${AVATAR_BASE}/laura.png`,
10
- yuruo: `${AVATAR_BASE}/yuruo.png`,
11
- jerome: `${AVATAR_BASE}/jerome.png`,
6
+ nelly: `${AVATAR_BASE}/nelly.webp`,
7
+ micheal: `${AVATAR_BASE}/micheal.webp`,
8
+ fjolla: `${AVATAR_BASE}/fjolla.webp`,
9
+ laura: `${AVATAR_BASE}/laura.webp`,
10
+ yuruo: `${AVATAR_BASE}/yuruo.webp`,
11
+ jerome: `${AVATAR_BASE}/jerome.webp`,
12
12
  };
13
13
  // Placeholder gradient shown behind avatars with transparent areas so every
14
14
  // tile has a consistent backdrop (matches the Figma image tile fill).
@@ -4,6 +4,7 @@ export * from './Avatar';
4
4
  export * from './ConversationalAvatarsBanner';
5
5
  export * from './CheckIcon';
6
6
  export * from './ConversationalVoiceAgentsBanner';
7
+ export * from './DissolveIcon';
7
8
  export * from './HintBubble';
8
9
  export * from './Logo';
9
10
  export * from './Menu';
@@ -4,6 +4,7 @@ export * from './Avatar';
4
4
  export * from './ConversationalAvatarsBanner';
5
5
  export * from './CheckIcon';
6
6
  export * from './ConversationalVoiceAgentsBanner';
7
+ export * from './DissolveIcon';
7
8
  export * from './HintBubble';
8
9
  export * from './Logo';
9
10
  export * from './Menu';
@@ -374,6 +374,10 @@ export interface PromoBannerProps {
374
374
  mobileCtaText?: string;
375
375
  /** Whether to show the avatar collage on both sides of the banner */
376
376
  showImages?: boolean;
377
+ /** Whether to continuously scroll (marquee) the side avatar collages. Honors prefers-reduced-motion. Defaults to false. */
378
+ animateAvatars?: boolean;
379
+ /** Whether to enable the premium copy confirmation: the promo-code label pill morphs into a success "COPIED!" state (with a subtle pop) and the button icon swaps Copy→Check. Honors prefers-reduced-motion. Defaults to false. */
380
+ animateActions?: boolean;
377
381
  /** Background of the banner */
378
382
  background?: string;
379
383
  /** Custom style of the banner */
@@ -405,6 +409,8 @@ export interface ConversationalAvatarsBannerProps {
405
409
  description?: string;
406
410
  /** Action buttons */
407
411
  actions?: BannerAction[];
412
+ /** Whether to continuously scroll (marquee) the avatar collage column. Honors prefers-reduced-motion. Defaults to false. */
413
+ animateAvatars?: boolean;
408
414
  /** Background of the banner */
409
415
  background?: string;
410
416
  /** Custom style of the banner */
@@ -595,3 +601,26 @@ export interface ButtonCardProps {
595
601
  /** Test id */
596
602
  testId?: string;
597
603
  }
604
+ /**
605
+ * {@link DissolveIcon} props. Renders a single icon that, when the `icon` prop
606
+ * changes, animates a "turn to dust and reform" transition: the outgoing glyph
607
+ * scatters into particles (SVG turbulence displacement) and fades out, then the
608
+ * incoming glyph coalesces back from the same dust. Honors prefers-reduced-motion
609
+ * (falls back to an instant swap).
610
+ */
611
+ export interface DissolveIconProps {
612
+ /** The icon to display. Changing this triggers the dust dissolve/reform transition. */
613
+ icon: IconName;
614
+ /** Pixel size of the (square) icon. Defaults to 20. */
615
+ size?: number;
616
+ /** Phosphor icon weight. Defaults to 'regular'. */
617
+ weight?: IconWeight;
618
+ /** Icon color — any CSS color or design token. Defaults to 'currentColor'. */
619
+ color?: string;
620
+ /** Total duration of the dissolve-out + reform-in transition, in ms. Defaults to 600. */
621
+ durationMs?: number;
622
+ /** Custom style applied to the icon wrapper. */
623
+ style?: CSSProperties;
624
+ /** Test ID applied to the icon wrapper. */
625
+ testId?: string;
626
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beydesign/storybook",
3
3
  "private": false,
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",