From a793452f961ce06e040964a96bae7326ae2eff57 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 23 Aug 2026 15:17:07 +0300 Subject: [PATCH 1/4] feat(onboarding): horizon signup wall behind signup_wall_horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flag-gated fourth signup wall, to run against the served one (`cards`) after the panel lost its test. The marketing homepage's hero artwork is the right half full-bleed — no frame, no overlays, nothing to read on the image — dissolving into the page at the seam, with the tagline and the auth options on the rail. Stacked, it takes the panel's shape: an artwork band on top, the form bottom-anchored underneath for thumb reach. The band keeps more height than the panel's on short viewports, because this column has no divider and no bordered email button. The CTA stack is the other half of the bet. One solid primary (Google), GitHub filled-secondary rather than an outline, and email demoted to a text link — a wall that recommends a default instead of offering three identical doors. `splitSignupStyle` had geometry, copy and hierarchy welded into one boolean; it becomes a `signupStyle` discriminant so the horizon can take the geometry while keeping "Continue with…", which logs returning users straight in rather than building a wrong door. Enrollment mirrors the step's render predicate, not just "is this the onboarding funnel": evaluating the flag is what fires GrowthBook's trackingCallback, so a visit that never paints a wall — already authenticated and confirmed, or onboarding already complete — must not allocate, or it sits in the denominator of both arms unable to convert. The wall holds render until the flag resolves so treatment users never paint a frame of the control, bounded at 200ms because `ready` never flips when boot returns no experiment features. Flag defaults to `false`. Cards, desk and panel are untouched. Co-Authored-By: Claude Opus 5 --- .../src/components/auth/AuthOptionsInner.tsx | 4 +- .../auth/OnboardingRegistrationForm.tsx | 146 ++++++++--- .../shared/src/components/auth/common.tsx | 18 +- .../components/OnboardingSignupHero.spec.tsx | 43 ++- .../components/OnboardingSignupHero.tsx | 84 ++++++ .../signupHero/HeroBackgroundLayer.tsx | 6 +- .../components/signupHero/HorizonArt.tsx | 38 +++ .../components/signupHero/heroStyles.ts | 22 ++ .../steps/FunnelHeroLanding.spec.tsx | 246 ++++++++++++++++++ .../onboarding/steps/FunnelHeroLanding.tsx | 91 ++++++- .../src/features/onboarding/types/funnel.ts | 5 +- packages/shared/src/lib/featureManagement.ts | 11 + packages/shared/src/lib/image.ts | 10 + .../onboarding/FunnelHeroLanding.stories.tsx | 17 +- .../SignupWallComparison.stories.tsx | 5 + 15 files changed, 691 insertions(+), 55 deletions(-) create mode 100644 packages/shared/src/features/onboarding/components/signupHero/HorizonArt.tsx create mode 100644 packages/shared/src/features/onboarding/steps/FunnelHeroLanding.spec.tsx diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index dfa4d8c99aa..579e033600a 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -145,7 +145,7 @@ function AuthOptionsInner({ hideSignupDisclaimer, isOnboardingFunnel, compact, - splitSignupStyle, + signupStyle, preferGithub, autoTriggerProvider, socialProviderScopes, @@ -870,7 +870,7 @@ function AuthOptionsInner({ hideLoginLink={hideLoginLink} hideSignupDisclaimer={hideSignupDisclaimer} compact={compact} - splitSignupStyle={splitSignupStyle} + signupStyle={signupStyle} preferGithub={preferGithub} onAuthOpenLogged={() => setHasLoggedAuthOpen(true)} /> diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx index f64835f6672..ff71a1c6dac 100644 --- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx +++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { cloneElement, useEffect } from 'react'; import classNames from 'classnames'; -import type { AuthFormProps } from './common'; +import type { AuthFormProps, SignupStyle } from './common'; import { providerMap } from './common'; import OrDivider from './OrDivider'; import { useLogContext } from '../../contexts/LogContext'; @@ -39,7 +39,7 @@ interface OnboardingRegistrationFormProps extends AuthFormProps { hideLoginLink?: boolean; hideSignupDisclaimer?: boolean; compact?: boolean; - splitSignupStyle?: boolean; + signupStyle?: SignupStyle; preferGithub?: boolean; onAuthOpenLogged?: () => void; } @@ -118,7 +118,7 @@ export const OnboardingRegistrationForm = ({ hideLoginLink, hideSignupDisclaimer, compact, - splitSignupStyle = false, + signupStyle, preferGithub, onAuthOpenLogged, }: OnboardingRegistrationFormProps): ReactElement => { @@ -127,6 +127,24 @@ export const OnboardingRegistrationForm = ({ const signupProviders = getSignupProviders( preferGithub ?? isOnboardingTrigger, ); + // Read-only views of the one treatment the wall named. + const isSplitLayout = !!signupStyle; + const isCreateAccountCopy = signupStyle === 'splitCreateAccount'; + const isSinglePrimary = signupStyle === 'singlePrimary'; + + // The single-primary treatment sizes the brand marks to the label rather + // than the button, and takes GitHub's filled octocat (the icon's `secondary` + // asset) so it reads at the same weight as Google's mark. Google already + // ships `secondary`, so passing it here is a no-op for that provider. + const getProviderIcon = (icon: ReactElement): ReactElement => { + if (isSinglePrimary) { + return cloneElement(icon, { size: IconSize.XSmall, secondary: true }); + } + if (isSplitLayout) { + return cloneElement(icon, { size: IconSize.Medium }); + } + return icon; + }; const trackOpenSignup = () => { logEvent({ @@ -161,8 +179,10 @@ export const OnboardingRegistrationForm = ({ // This margin, not the login link's own, is most of the gap between the CTA // and "Already have an account". onb-split-cta lets the signup hero close // it further on compact phones. - if (splitSignupStyle) { - return 'onb-split-cta mb-4'; + if (isSplitLayout) { + // The single-primary rail hands the spacing to the link's own padded hit + // area, so it doesn't stack a margin on top of it. + return isSinglePrimary ? 'onb-split-cta' : 'onb-split-cta mb-4'; } if (isOnboardingTrigger) { return 'mb-3'; @@ -170,27 +190,54 @@ export const OnboardingRegistrationForm = ({ return 'mb-8'; }; - const emailButtonLabel = splitSignupStyle + const emailButtonLabel = isCreateAccountCopy ? 'Create account' : 'Continue with email'; + const emailButtonAriaLabel = isCreateAccountCopy + ? 'Create account' + : 'Signup using email'; + const onEmailClick = () => { + trackOpenSignup(); + onContinueWithEmail?.(); + }; - const emailButton = ( + // The single-primary rail demotes email to a text link. A plain button, not + // `Button`: the variant's box, shadow and hover `--button-background` would + // each need overriding to look like a link. `min-h-12` keeps a 48px row under + // 20px of text, so the target clears 44px while the label stays a link — and + // that row is what spaces the login prompt below it (see getEmailButtonClass). + const emailLink = ( + + ); + + const emailButton = isSinglePrimary ? ( + emailLink + ) : ( ))} - + {!isSinglePrimary && ( + + )} {isOnboardingTrigger ? (
{emailButton} diff --git a/packages/shared/src/components/auth/common.tsx b/packages/shared/src/components/auth/common.tsx index 8d660c42ad6..2f9a8a65282 100644 --- a/packages/shared/src/components/auth/common.tsx +++ b/packages/shared/src/components/auth/common.tsx @@ -96,6 +96,20 @@ export const actionToAuthDisplay: Record = { [OnboardingActions.VerifyEmail]: AuthDisplay.EmailVerification, } as const; +/** Which onboarding signup-wall treatment the auth options take. Both values + * imply the split-column geometry (left-aligned login row, tighter CTA spacing + * hooks, smaller provider marks) and then differ in copy and CTA hierarchy: + * + * - `splitCreateAccount` — the panel: "Sign up with…" / "Create account". + * - `singlePrimary` — the horizon: one solid provider and the rest secondary, + * email as a text link, no "or" divider, and "Continue with…" kept so + * returning users are logged straight in. + * + * One name rather than independent booleans, so a caller cannot ask for the + * single-primary hierarchy without the geometry it assumes. + */ +export type SignupStyle = 'splitCreateAccount' | 'singlePrimary'; + export interface AuthProps { isAuthenticating: boolean; isLoginFlow: boolean; @@ -130,8 +144,8 @@ export interface AuthOptionsProps { onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; compact?: boolean; - /** X-style split onboarding: "Sign up with", "Create account", Sign in button */ - splitSignupStyle?: boolean; + /** Which signup-wall treatment the auth options take. See {@link SignupStyle}. */ + signupStyle?: SignupStyle; /** Order GitHub before Google in the OAuth provider list (developer-first). */ preferGithub?: boolean; autoTriggerProvider?: string; diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx index 8263a215c82..9cd6efa3445 100644 --- a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx +++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { OnboardingSignupHero } from './OnboardingSignupHero'; import { FunnelProgressContext } from '../shared/FunnelStepDots'; -import { cloudinaryOnboardingLoginBackground } from '../../../lib/image'; +import { + cloudinaryOnboardingLoginBackground, + signupWallHorizon, +} from '../../../lib/image'; import { useViewSize } from '../../../hooks'; jest.mock('../../../contexts/SettingsContext', () => ({ @@ -123,6 +126,44 @@ describe('OnboardingSignupHero', () => { expect(screen.getByText('Hello devs')).not.toHaveClass('onb-headline'); }); + describe('horizon background', () => { + const renderHorizon = ( + props: Partial> = {}, + ) => renderHero({ background: 'horizon', ...props }); + + it('owns its artwork instead of delegating to the background layer', () => { + renderHorizon(); + expect(screen.queryByTestId('bg-layer')).not.toBeInTheDocument(); + }); + + it('renders the homepage hero artwork full-bleed', () => { + renderHorizon(); + // One for the stacked band, one for the desktop half; each is hidden at + // the breakpoint the other serves. + const art = screen.getAllByTestId('horizon-art'); + expect(art).toHaveLength(2); + art.forEach((image) => + expect(image).toHaveAttribute('src', signupWallHorizon), + ); + }); + + it('sanitizes funnel copy rather than printing markup', () => { + renderHorizon({ + headline: 'Where developers discover', + }); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading.innerHTML).toContain('discover'); + expect(heading.innerHTML).not.toContain('script'); + }); + + it('leaves the artwork free of overlaid copy', () => { + renderHorizon(); + expect( + screen.queryByTestId('landing-app-install'), + ).not.toBeInTheDocument(); + }); + }); + it('renders aurora orbs by default', () => { renderHero(); expect(screen.getByTestId('hero-orbs')).toBeInTheDocument(); diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx index 765506ba18b..22ae82c8194 100644 --- a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx +++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx @@ -26,6 +26,7 @@ import { HERO_STYLES } from './signupHero/heroStyles'; import { HeroBackgroundLayer } from './signupHero/HeroBackgroundLayer'; import { AuroraOrbs } from './signupHero/HeroDecorations'; import { LandingHeroCover } from './signupHero/LandingHeroCover'; +import { HorizonArt } from './signupHero/HorizonArt'; import { LandingAppInstall } from './signupHero/LandingAppInstall'; import { cloudinaryOnboardingLoginBackground } from '../../../lib/image'; import { sanitizeMessage } from '../lib/utils'; @@ -91,6 +92,7 @@ export const OnboardingSignupHero = ({ const isSplitLayout = background === 'split'; const isDeskVariant = background === 'desk'; const isPanelLayout = background === 'panel'; + const isHorizonLayout = background === 'horizon'; const showOrbsLayer = showOrbs; // Once the user moves to the email registration / verification step, drop the @@ -160,6 +162,88 @@ export const OnboardingSignupHero = ({ ); } + // The horizon: the homepage's hero artwork full-bleed as the right half — + // no frame, no overlays — dissolving into the page at the seam, with the + // tagline and the auth options on the rail. Stacked, it takes the panel's + // shape: an artwork band on top, the form bottom-anchored underneath. + if (isHorizonLayout) { + const signupColumn = ( +
+ + + {/* Funnel copy is authored as HTML (b/strong/br), so it takes the + same sanitizer the other walls use. */} + {headline && ( +

+ )} + + {children} +

+ ); + + return ( +
+