@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.
- package/dist/stories/Buttons/MainButton.js +3 -3
- package/dist/stories/Buttons/types.d.ts +7 -0
- package/dist/stories/EntityCard/AvatarSilhouette.d.ts +8 -0
- package/dist/stories/EntityCard/AvatarSilhouette.js +8 -0
- package/dist/stories/EntityCard/EntityCard.js +13 -5
- package/dist/stories/EntityCard/EntityCard.stories.js +3 -3
- package/dist/stories/EntityCard/types.d.ts +2 -2
- package/dist/stories/UI/ConversationalAvatarsBanner.js +61 -8
- package/dist/stories/UI/ConversationalAvatarsBanner.stories.d.ts +1 -0
- package/dist/stories/UI/ConversationalAvatarsBanner.stories.js +24 -0
- package/dist/stories/UI/DissolveIcon.d.ts +12 -0
- package/dist/stories/UI/DissolveIcon.js +107 -0
- package/dist/stories/UI/DissolveIcon.stories.d.ts +20 -0
- package/dist/stories/UI/DissolveIcon.stories.js +98 -0
- package/dist/stories/UI/PromoBanner.js +144 -15
- package/dist/stories/UI/PromoBanner.stories.d.ts +1 -0
- package/dist/stories/UI/PromoBanner.stories.js +38 -0
- package/dist/stories/UI/bannerAvatars.js +6 -6
- package/dist/stories/UI/index.d.ts +1 -0
- package/dist/stories/UI/index.js +1 -0
- package/dist/stories/UI/types.d.ts +29 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
333
|
-
|
|
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 {
|
|
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: [
|
|
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 })] })),
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
|
32
|
+
/** Show the error state (tinted media area + warning icon) in place of the image */
|
|
33
33
|
error?: boolean;
|
|
34
|
-
/**
|
|
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
|
-
|
|
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",
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
};
|
|
@@ -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
|
-
{
|
|
26
|
-
|
|
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
|
-
{
|
|
30
|
-
|
|
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",
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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",
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
};
|
|
@@ -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.
|
|
7
|
-
micheal: `${AVATAR_BASE}/micheal.
|
|
8
|
-
fjolla: `${AVATAR_BASE}/fjolla.
|
|
9
|
-
laura: `${AVATAR_BASE}/laura.
|
|
10
|
-
yuruo: `${AVATAR_BASE}/yuruo.
|
|
11
|
-
jerome: `${AVATAR_BASE}/jerome.
|
|
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';
|
package/dist/stories/UI/index.js
CHANGED
|
@@ -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
|
+
}
|