@beydesign/storybook 0.4.7 → 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/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
|
}
|
|
@@ -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
|
+
}
|