From ac78796761d3616f5a52ca9b7f342852bbced5e1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 17 Sep 2026 11:21:55 +0300 Subject: [PATCH 1/4] feat(onboarding): extension showcase experiment on the extension step Behind onboarding_extension_showcase (default off) the onboarding funnel's extension step swaps the explainer and demo video for a feature showcase: the funnel headline, a caption that changes per feature, a centred tab carousel and an illustrated stage, mirroring the product tour on the daily.dev homepage. Control keeps the current step untouched. The eight features are only what the extension adds on top of the web app, with the new-tab feed in the middle so the carousel opens on it and plays the same new-tab video the step already uses. The other seven illustrations are served from the webapp's public assets through fromCDN, and Storybook maps that directory so the same paths resolve there. Tabs carry data-funnel-track with a new FunnelTargetId.ExtensionFeature, so the funnel logs which feature was clicked alongside the existing download event for the experiment's analysis. Mockup-to-eng-pass: 1 Co-Authored-By: Claude Opus 5 --- .../ExtensionShowcase.spec.tsx | 40 ++++ .../ExtensionShowcase/ExtensionShowcase.tsx | 141 ++++++++++++++ .../ExtensionShowcaseStage.tsx | 76 ++++++++ .../ExtensionShowcase/defaultFeatures.ts | 172 ++++++++++++++++++ .../onboarding/ExtensionShowcase/types.ts | 21 +++ .../steps/FunnelBrowserExtension.spec.tsx | 103 +++++++++++ .../steps/FunnelBrowserExtension.tsx | 85 ++++++--- .../features/onboarding/types/funnelEvents.ts | 1 + packages/shared/src/lib/featureManagement.ts | 9 + packages/shared/src/styles/utilities.css | 91 +++++++++ packages/storybook/.storybook/main.ts | 6 +- .../onboarding/ExtensionShowcase.stories.tsx | 67 +++++++ ...FunnelBrowserExtensionShowcase.stories.tsx | 125 +++++++++++++ .../app/assets/extension-showcase/brief.webp | Bin 0 -> 101758 bytes .../assets/extension-showcase/companion.webp | Bin 0 -> 72432 bytes .../app/assets/extension-showcase/focus.webp | Bin 0 -> 72406 bytes .../extension-showcase/most-visited.webp | Bin 0 -> 74998 bytes .../assets/extension-showcase/readmode.webp | Bin 0 -> 80678 bytes .../assets/extension-showcase/shortcuts.webp | Bin 0 -> 106362 bytes .../app/assets/extension-showcase/streak.webp | Bin 0 -> 99710 bytes 20 files changed, 913 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx create mode 100644 packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx create mode 100644 packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseStage.tsx create mode 100644 packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.ts create mode 100644 packages/shared/src/components/onboarding/ExtensionShowcase/types.ts create mode 100644 packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.spec.tsx create mode 100644 packages/storybook/stories/components/onboarding/ExtensionShowcase.stories.tsx create mode 100644 packages/storybook/stories/components/onboarding/FunnelBrowserExtensionShowcase.stories.tsx create mode 100644 packages/webapp/public/app/assets/extension-showcase/brief.webp create mode 100644 packages/webapp/public/app/assets/extension-showcase/companion.webp create mode 100644 packages/webapp/public/app/assets/extension-showcase/focus.webp create mode 100644 packages/webapp/public/app/assets/extension-showcase/most-visited.webp create mode 100644 packages/webapp/public/app/assets/extension-showcase/readmode.webp create mode 100644 packages/webapp/public/app/assets/extension-showcase/shortcuts.webp create mode 100644 packages/webapp/public/app/assets/extension-showcase/streak.webp diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx new file mode 100644 index 00000000000..e7b62c82aea --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ExtensionShowcase } from './ExtensionShowcase'; +import { defaultExtensionShowcaseFeatures } from './defaultFeatures'; + +const featureById = (id: string) => + defaultExtensionShowcaseFeatures.find((feature) => feature.id === id)!; + +beforeEach(() => { + Element.prototype.scrollTo = jest.fn(); +}); + +describe('ExtensionShowcase', () => { + it('opens on the new tab feed', () => { + render(); + + expect( + screen.getByRole('button', { name: 'New tab feed' }), + ).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText(featureById('newtab').description)).toBeVisible(); + expect( + screen.getByLabelText(featureById('newtab').media.alt), + ).toBeVisible(); + }); + + it('swaps the caption and illustration on selection', () => { + const onFeatureChange = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Companion' })); + + const companion = featureById('companion'); + expect(onFeatureChange).toHaveBeenCalledWith('companion'); + expect(screen.getByText(companion.description)).toBeVisible(); + expect(screen.getByAltText(companion.media.alt)).toBeVisible(); + expect( + screen.queryByText(featureById('newtab').description), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx new file mode 100644 index 00000000000..37de08ef7e7 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx @@ -0,0 +1,141 @@ +import type { ReactElement } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { FunnelTargetId } from '../../../features/onboarding/types/funnelEvents'; +import { ExtensionShowcaseStage } from './ExtensionShowcaseStage'; +import { + defaultExtensionShowcaseFeatureId, + defaultExtensionShowcaseFeatures, +} from './defaultFeatures'; +import type { ExtensionShowcaseFeature } from './types'; + +export interface ExtensionShowcaseProps { + features?: ExtensionShowcaseFeature[]; + defaultFeatureId?: string; + onFeatureChange?: (featureId: string) => void; + className?: string; + /** Applied to the stage wrapper, e.g. to cap its width. */ + stageClassName?: string; +} + +interface ShowcaseTabProps { + feature: ExtensionShowcaseFeature; + isActive: boolean; + onClick: () => void; +} + +function ShowcaseTab({ + feature, + isActive, + onClick, +}: ShowcaseTabProps): ReactElement { + return ( + + ); +} + +export function ExtensionShowcase({ + features = defaultExtensionShowcaseFeatures, + defaultFeatureId = defaultExtensionShowcaseFeatureId, + onFeatureChange, + className, + stageClassName, +}: ExtensionShowcaseProps): ReactElement { + const [activeId, setActiveId] = useState(defaultFeatureId); + const activeFeature = + features.find((feature) => feature.id === activeId) ?? features[0]; + const scrollerRef = useRef(null); + const hasCentered = useRef(false); + + // The selected tab sits in the middle and the rest fan out to both sides, + // like the product tour on the homepage. The first paint centers instantly; + // later selections glide. + useLayoutEffect(() => { + const scroller = scrollerRef.current; + const tab = scroller?.querySelector('[aria-pressed="true"]'); + if (!scroller || !tab) { + return; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const tabRect = tab.getBoundingClientRect(); + scroller.scrollTo({ + left: + scroller.scrollLeft + + (tabRect.left - scrollerRect.left) - + (scroller.clientWidth - tabRect.width) / 2, + behavior: hasCentered.current ? 'smooth' : 'auto', + }); + hasCentered.current = true; + }, [activeFeature.id]); + + const selectFeature = (featureId: string): void => { + setActiveId(featureId); + onFeatureChange?.(featureId); + }; + + return ( +
+ + {activeFeature.description} + + +
+ +
+
+ ); +} diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseStage.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseStage.tsx new file mode 100644 index 00000000000..66884d34fd2 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseStage.tsx @@ -0,0 +1,76 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ExtensionShowcaseFeature } from './types'; + +interface ExtensionShowcaseStageProps { + feature: ExtensionShowcaseFeature; +} + +const glowSizes = ['size-[22.5rem]', 'size-[26.25rem]']; +const glowDrift = ['animate-showcase-glow-a', 'animate-showcase-glow-b']; + +function StageMedia({ + media, +}: Pick): ReactElement { + if (media.type === 'video') { + return ( +
+
+ ); + } + + return ( + {media.alt} + ); +} + +export function ExtensionShowcaseStage({ + feature, +}: ExtensionShowcaseStageProps): ReactElement { + return ( +
+ {feature.glow.map((glow, index) => ( +
+
+
+ ))} +
+ +
+
+ ); +} diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.ts b/packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.ts new file mode 100644 index 00000000000..a3715246f20 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.ts @@ -0,0 +1,172 @@ +import { cloudinaryOnboardingExtensionVideo } from '../../../lib/image'; +import { fromCDN } from '../../../lib/links'; +import type { ExtensionShowcaseFeature } from './types'; + +const illustration = ( + id: string, + alt: string, +): ExtensionShowcaseFeature['media'] => ({ + type: 'image', + src: fromCDN(`/app/assets/extension-showcase/${id}.webp`), + alt, +}); + +// Only what the extension adds on top of the web app, with the new-tab feed in +// the middle so the carousel opens on it and fans out to both sides, like the +// product tour on daily.dev's homepage (HomeProductTour in +// dailydotdev/recruiter-landing), which also supplies the copy and glows. +export const defaultExtensionShowcaseFeatures: ExtensionShowcaseFeature[] = [ + { + id: 'readmode', + label: 'Read it here', + description: + 'Read any article inside daily.dev. The built-in browser opens it right there, with your actions one click away.', + media: illustration('readmode', 'An article open in the daily.dev reader'), + accent: '#38bdf8', + glow: [ + { + color: 'rgba(56,189,248,.24)', + transform: 'translate(46%,-84%) scale(1.15)', + }, + { + color: 'rgba(59,130,246,.16)', + transform: 'translate(-82%,44%) scale(1.2)', + }, + ], + }, + { + id: 'brief', + label: 'Daily brief', + description: + 'Your first tab of the day opens to an AI brief that compresses everything that matters into a two-minute read.', + media: illustration('brief', 'The presidential briefing'), + accent: '#8b5cf6', + glow: [ + { + color: 'rgba(139,92,246,.26)', + transform: 'translate(-46%,-92%) scale(1.2)', + }, + { + color: 'rgba(99,102,241,.16)', + transform: 'translate(-30%,58%) scale(1.15)', + }, + ], + }, + { + id: 'most-visited', + label: 'Most visited', + description: + 'Your most-visited sites come straight from your browser, so the new tab still knows where you were headed. No setup.', + media: illustration( + 'most-visited', + 'The new tab with your most visited sites', + ), + accent: '#FF9157', + glow: [ + { + color: 'rgba(255,145,87,.22)', + transform: 'translate(-82%,-78%) scale(0.95)', + }, + { + color: 'rgba(255,131,61,.14)', + transform: 'translate(72%,10%) scale(1.3)', + }, + ], + }, + { + id: 'shortcuts', + label: 'Shortcuts', + description: + 'Pin the apps you live in, or import your bookmarks bar in a click, so your essentials stay one click from every tab.', + media: illustration('shortcuts', 'The shortcuts settings'), + accent: '#3b82f6', + glow: [ + { + color: 'rgba(59,130,246,.24)', + transform: 'translate(46%,-84%) scale(1.15)', + }, + { + color: 'rgba(56,189,248,.16)', + transform: 'translate(-82%,44%) scale(1.2)', + }, + ], + }, + { + id: 'newtab', + label: 'New tab feed', + description: + 'Your whole dev world in one ranked feed, on every new tab. Only what is worth reading, none of the noise.', + media: { + type: 'video', + src: cloudinaryOnboardingExtensionVideo, + alt: 'A blank new tab turning into the daily.dev feed', + }, + accent: '#7C6BEA', + glow: [ + { + color: 'rgba(124,107,234,.26)', + transform: 'translate(-82%,-82%) scale(1.25)', + }, + { + color: 'rgba(34,211,238,.16)', + transform: 'translate(48%,42%) scale(1.05)', + }, + ], + }, + { + id: 'companion', + label: 'Companion', + description: + 'The companion rides along on any site you visit, adding an instant TLDR, what the community thinks, and related reads.', + media: illustration('companion', 'The companion docked on an article'), + accent: '#BA56E1', + glow: [ + { + color: 'rgba(186,86,225,.24)', + transform: 'translate(-98%,-36%) scale(1.3)', + }, + { + color: 'rgba(147,51,234,.16)', + transform: 'translate(66%,-64%) scale(0.95)', + }, + ], + }, + { + id: 'streak', + label: 'Reading streak', + description: + 'daily.dev greets you every time you open a new tab, so keeping your reading streak alive takes zero willpower.', + media: illustration('streak', 'The reading streak popup'), + accent: '#F25D82', + glow: [ + { + color: 'rgba(242,93,130,.24)', + transform: 'translate(68%,64%) scale(1.3)', + }, + { + color: 'rgba(255,145,87,.14)', + transform: 'translate(-84%,-72%) scale(1.05)', + }, + ], + }, + { + id: 'focus', + label: 'Focus mode', + description: + 'Need to focus? Pause the new tab for as long as you like and point it anywhere. Full control, in one click.', + media: illustration('focus', 'The pause new tab dialog'), + accent: '#6B56DD', + glow: [ + { + color: 'rgba(107,86,221,.24)', + transform: 'translate(-82%,-82%) scale(1.25)', + }, + { + color: 'rgba(34,211,238,.14)', + transform: 'translate(48%,42%) scale(1.05)', + }, + ], + }, +]; + +export const defaultExtensionShowcaseFeatureId = 'newtab'; diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/types.ts b/packages/shared/src/components/onboarding/ExtensionShowcase/types.ts new file mode 100644 index 00000000000..9cd34f8bdf5 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/types.ts @@ -0,0 +1,21 @@ +export type ExtensionShowcaseMedia = + | { type: 'video'; src: string; alt: string } + | { type: 'image'; src: string; alt: string }; + +export interface ExtensionShowcaseGlow { + color: string; + /** Transform applied to the blob, relative to the stage center. */ + transform: string; +} + +export interface ExtensionShowcaseFeature { + id: string; + label: string; + /** Single-sentence value message shown above the tabs. */ + description: string; + media: ExtensionShowcaseMedia; + /** Tints the selected tab. */ + accent: string; + /** The two ambient stage glows for this feature. */ + glow: [ExtensionShowcaseGlow, ExtensionShowcaseGlow]; +} diff --git a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.spec.tsx b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.spec.tsx new file mode 100644 index 00000000000..0314d7bce4e --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.spec.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FunnelBrowserExtension } from './FunnelBrowserExtension'; +import { FunnelProgressContext } from '../shared/FunnelStepDots'; +import { FunnelStepType } from '../types/funnel'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureOnboardingExtensionShowcase } from '../../../lib/featureManagement'; + +jest.mock('../../../hooks/useConditionalFeature'); + +jest.mock( + '../../../components/onboarding/Extension/useOnboardingExtension', + () => ({ + useOnboardingExtension: () => ({ + browserName: 'chrome', + shouldShowExtensionOnboarding: true, + isReady: true, + }), + }), +); + +jest.mock('../../../contexts/SettingsContext', () => ({ + ThemeMode: { Dark: 'dark' }, + useSettingsContext: () => ({ applyThemeMode: jest.fn() }), +})); + +jest.mock('../../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: jest.fn() }), +})); + +jest.mock('next/router', () => ({ useRouter: () => ({ query: {} }) })); + +const mockUseConditionalFeature = jest.mocked(useConditionalFeature); + +const explainer = 'Unlock the power of every new tab'; + +const renderStep = ({ showcase }: { showcase: boolean }) => { + mockUseConditionalFeature.mockImplementation( + ({ feature }) => + ({ + value: + feature === featureOnboardingExtensionShowcase + ? showcase + : feature.defaultValue, + isLoading: false, + } as never), + ); + + return render( + + + , + ); +}; + +beforeEach(() => { + Element.prototype.scrollTo = jest.fn(); +}); + +describe('FunnelBrowserExtension', () => { + it('plays the demo video with the flag off', () => { + renderStep({ showcase: false }); + + expect(screen.getByText(explainer)).toBeVisible(); + expect( + screen.getByLabelText('daily.dev feed running in a new tab on a laptop'), + ).toBeVisible(); + expect( + screen.queryByRole('navigation', { name: 'Extension features' }), + ).not.toBeInTheDocument(); + }); + + it('swaps the explainer and video for the showcase with the flag on', () => { + renderStep({ showcase: true }); + + expect( + screen.getByRole('heading', { + name: 'Transform every new tab into a learning powerhouse', + }), + ).toBeVisible(); + expect(screen.queryByText(explainer)).not.toBeInTheDocument(); + expect( + screen.getByRole('navigation', { name: 'Extension features' }), + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'New tab feed' }), + ).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('link', { name: 'Add to Chrome' })).toBeVisible(); + }); +}); diff --git a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx index 9192f3885a2..85f651e5c91 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx @@ -36,6 +36,16 @@ import { } from '../../../components/onboarding/common'; import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureOnboardingExtensionShowcase } from '../../../lib/featureManagement'; +import { ExtensionShowcase } from '../../../components/onboarding/ExtensionShowcase/ExtensionShowcase'; + +// The showcase stage grows with the viewport height: never narrower than the +// demo video (40rem), never wider than the homepage tour (64rem), and in +// between sized so the step fits without scrolling. 30rem is everything else +// on the step: top bar, headline, caption, tab carousel and the glass bar. +const showcaseStageClassName = + 'mx-auto max-w-[clamp(40rem,calc((100dvh-30rem)*2.04),64rem)]'; const BROWSER_EXTENSION_DEFAULTS = { headline: 'Transform every new tab into a learning powerhouse', @@ -63,6 +73,10 @@ const BrowserExtension = ({ const isEdge = browserName === BrowserName.Edge; const browserLabel = isEdge ? 'Edge' : 'Chrome'; const isOnboarding = useIsOnboardingFunnel(); + const { value: hasShowcase } = useConditionalFeature({ + feature: featureOnboardingExtensionShowcase, + shouldEvaluate: isOnboarding, + }); // Only swap the default; a Freyja-provided cta wins. const ctaTemplate = isOnboarding && cta === BROWSER_EXTENSION_DEFAULTS.cta @@ -174,29 +188,27 @@ const BrowserExtension = ({ ); } - return ( - - ) : ( - - ) - } - onClick={onDownload} - rel={anchorDefaultRel} - skip={{ - cta: 'Skip', - onClick: () => onTransition?.({ type: FunnelStepTransitionType.Skip }), - }} - tag="a" - target="_blank" - containerClassName="flex flex-col" - > + // The showcase replaces the explainer and the video: the per-feature caption + // does the explaining, right under the headline like the homepage tour. + const body = hasShowcase ? ( + <> +
+ +
+ + + ) : ( + <>
{footage} + + ); + + return ( + + ) : ( + + ) + } + onClick={onDownload} + rel={anchorDefaultRel} + skip={{ + cta: 'Skip', + onClick: () => onTransition?.({ type: FunnelStepTransitionType.Skip }), + }} + tag="a" + target="_blank" + containerClassName="flex flex-col" + > + {body} ); }; diff --git a/packages/shared/src/features/onboarding/types/funnelEvents.ts b/packages/shared/src/features/onboarding/types/funnelEvents.ts index 395c10590f1..27292896eeb 100644 --- a/packages/shared/src/features/onboarding/types/funnelEvents.ts +++ b/packages/shared/src/features/onboarding/types/funnelEvents.ts @@ -24,6 +24,7 @@ export enum FunnelTargetId { SignupProvider = 'signup provider', Logo = 'logo', DownloadExtension = 'download extension', + ExtensionFeature = 'extension feature', FeedTag = 'feed tag', FeedPreview = 'feed preview', FeedContentType = 'feed content type', diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 813e0a0615f..076bd2a1609 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -247,6 +247,15 @@ export const featureOnboardingChrome = new Feature( OnboardingChromeVariant.Control, ); +/** + * Experiment: the onboarding extension step shows the feature showcase (tab + * carousel with a per-feature illustration) instead of the demo video. + */ +export const featureOnboardingExtensionShowcase = new Feature( + 'onboarding_extension_showcase', + false, +); + /** * Experiment: the sponsor strip — a logo wall docked under the main feeds with * a trending ticker under it. The ticker carries the popular half of diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index c16fc2ea664..8312ab46db9 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1239,3 +1239,94 @@ img.agent-media-ring { overflow-wrap: anywhere; hyphens: none; } + +/* + * Extension showcase stage: the two ambient glows drift slowly at rest and + * the illustration and caption slide in on every feature switch, matching the + * product tour on the daily.dev homepage. + */ +@keyframes showcase-glow-a { + from { + transform: translate(-14px, -10px) scale(1); + } + + to { + transform: translate(16px, 12px) scale(1.09); + } +} + +@keyframes showcase-glow-b { + from { + transform: translate(12px, 14px) scale(1.06); + } + + to { + transform: translate(-16px, -12px) scale(1); + } +} + +@keyframes showcase-media-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes showcase-caption-in { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.showcase-carousel-mask { + -webkit-mask-image: linear-gradient( + to right, + transparent, + black 14%, + black 86%, + transparent + ); + mask-image: linear-gradient( + to right, + transparent, + black 14%, + black 86%, + transparent + ); +} + +.animate-showcase-glow-a { + animation: showcase-glow-a 9s ease-in-out infinite alternate; +} + +.animate-showcase-glow-b { + animation: showcase-glow-b 12s ease-in-out infinite alternate; +} + +.animate-showcase-media-in { + animation: showcase-media-in 500ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.animate-showcase-caption-in { + animation: showcase-caption-in 220ms ease both; +} + +@media (prefers-reduced-motion: reduce) { + .animate-showcase-glow-a, + .animate-showcase-glow-b, + .animate-showcase-media-in, + .animate-showcase-caption-in { + animation: none; + } +} diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index 1f6ac08af5c..dae3f9d432e 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -19,7 +19,11 @@ const config: StorybookConfig = { typescript: { reactDocgen: 'react-docgen-typescript', }, - staticDirs: ['../public'], + staticDirs: [ + '../public', + // Webapp-hosted assets that shared components load through `fromCDN`. + { from: '../../webapp/public/app/assets', to: '/app/assets' }, + ], async viteFinal(config, { configType }) { const GrowthBookMockPath = path.resolve( __dirname, diff --git a/packages/storybook/stories/components/onboarding/ExtensionShowcase.stories.tsx b/packages/storybook/stories/components/onboarding/ExtensionShowcase.stories.tsx new file mode 100644 index 00000000000..5297dd52204 --- /dev/null +++ b/packages/storybook/stories/components/onboarding/ExtensionShowcase.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { ExtensionShowcase } from '@dailydotdev/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase'; +import { defaultExtensionShowcaseFeatures } from '@dailydotdev/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures'; + +const featureIds = defaultExtensionShowcaseFeatures.map( + (feature) => feature.id, +); + +type PlaygroundArgs = React.ComponentProps & { + featureIds: string[]; + maxWidth: number; +}; + +const meta: Meta = { + title: 'Components/Onboarding/ExtensionShowcase', + component: ExtensionShowcase, + parameters: { + layout: 'fullscreen', + themes: { themeOverride: 'dark' }, + controls: { expanded: true }, + }, + argTypes: { + featureIds: { + control: { type: 'check' }, + options: featureIds, + description: 'Which features render, in tour order.', + }, + defaultFeatureId: { control: { type: 'select' }, options: featureIds }, + maxWidth: { + control: { type: 'range', min: 640, max: 1280, step: 20 }, + description: 'Width of the container the showcase sits in.', + }, + features: { table: { disable: true } }, + className: { table: { disable: true } }, + stageClassName: { table: { disable: true } }, + }, + args: { + featureIds, + defaultFeatureId: 'newtab', + maxWidth: 1024, + onFeatureChange: fn(), + }, + render: ({ featureIds: selected, maxWidth, ...args }) => ( +
+
+ + selected.includes(feature.id), + )} + /> +
+
+ ), +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +export const TourFeatures: Story = { + name: 'Only the homepage tour features', + args: { featureIds: ['readmode', 'brief', 'newtab', 'streak'] }, +}; diff --git a/packages/storybook/stories/components/onboarding/FunnelBrowserExtensionShowcase.stories.tsx b/packages/storybook/stories/components/onboarding/FunnelBrowserExtensionShowcase.stories.tsx new file mode 100644 index 00000000000..21f1878254d --- /dev/null +++ b/packages/storybook/stories/components/onboarding/FunnelBrowserExtensionShowcase.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { FunnelBrowserExtension } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelBrowserExtension'; +import { FunnelStepType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { + featureOnboardingExtensionShowcase, + OnboardingChromeVariant, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { FunnelStepShell } from './signupFunnel.mocks'; +import { FeatureOverrides } from '../../../mock/GrowthBookProvider'; + +/** + * The extension step of `/onboarding` with the `onboarding_extension_showcase` + * flag pinned, mounted in the same harness as the other signup funnel steps. + */ + +interface StepArgs { + chrome: OnboardingChromeVariant; + showcase: boolean; + headline: string; + cta: string; +} + +const step = { + id: 'browser-extension', + type: FunnelStepType.BrowserExtension as const, + transitions: [], + isActive: true, + onTransition: fn(), +}; + +const meta: Meta = { + title: 'Components/Onboarding/Steps/BrowserExtension showcase', + parameters: { + layout: 'fullscreen', + controls: { expanded: true }, + }, + argTypes: { + chrome: { + control: { type: 'inline-radio' }, + options: Object.values(OnboardingChromeVariant), + }, + showcase: { + description: 'The `onboarding_extension_showcase` flag.', + }, + }, + args: { + chrome: OnboardingChromeVariant.Control, + showcase: true, + headline: 'Transform every new tab into a learning powerhouse', + cta: 'Add to {browser}', + }, + render: ({ chrome, showcase, headline, cta }) => { + const props = { ...step, parameters: { headline, cta } }; + + return ( + + + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Showcase: Story = {}; + +export const Control: Story = { + name: 'Control (video)', + args: { showcase: false }, +}; + +const RESOLUTIONS = [ + { label: 'MacBook Air 13" scaled', width: 1280, height: 720 }, + { label: 'iPad landscape / small laptop', width: 1024, height: 768 }, + { label: 'HD laptop', width: 1366, height: 768 }, + { label: 'MacBook Air default', width: 1440, height: 900 }, +]; + +/** + * Each frame is a real iframe so `laptop:` breakpoints and `100dvh` resolve + * against that frame's own size, exactly as they would on a screen of that + * resolution. Frames are scaled to fit. + */ +export const Resolutions: Story = { + name: 'Small resolutions', + parameters: { controls: { disable: true } }, + render: () => ( +
+ {RESOLUTIONS.map(({ label, width, height }) => { + const scale = Math.min(1, 1200 / width); + + return ( +
+
+ {label} · {width}×{height} +
+
+