Skip to content
Merged
4 changes: 2 additions & 2 deletions packages/shared/src/components/auth/AuthOptionsInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function AuthOptionsInner({
hideSignupDisclaimer,
isOnboardingFunnel,
compact,
splitSignupStyle,
signupStyle,
preferGithub,
autoTriggerProvider,
socialProviderScopes,
Expand Down Expand Up @@ -870,7 +870,7 @@ function AuthOptionsInner({
hideLoginLink={hideLoginLink}
hideSignupDisclaimer={hideSignupDisclaimer}
compact={compact}
splitSignupStyle={splitSignupStyle}
signupStyle={signupStyle}
preferGithub={preferGithub}
onAuthOpenLogged={() => setHasLoggedAuthOpen(true)}
/>
Expand Down
128 changes: 91 additions & 37 deletions packages/shared/src/components/auth/OnboardingRegistrationForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,7 +39,7 @@ interface OnboardingRegistrationFormProps extends AuthFormProps {
hideLoginLink?: boolean;
hideSignupDisclaimer?: boolean;
compact?: boolean;
splitSignupStyle?: boolean;
signupStyle?: SignupStyle;
preferGithub?: boolean;
onAuthOpenLogged?: () => void;
}
Expand Down Expand Up @@ -118,7 +118,7 @@ export const OnboardingRegistrationForm = ({
hideLoginLink,
hideSignupDisclaimer,
compact,
splitSignupStyle = false,
signupStyle,
preferGithub,
onAuthOpenLogged,
}: OnboardingRegistrationFormProps): ReactElement => {
Expand All @@ -127,6 +127,29 @@ export const OnboardingRegistrationForm = ({
const signupProviders = getSignupProviders(
preferGithub ?? isOnboardingTrigger,
);
const isSplitLayout = !!signupStyle;
const isCreateAccountCopy = signupStyle === 'splitCreateAccount';
const isSinglePrimary = signupStyle === 'singlePrimary';

// `secondary` is GitHub's filled octocat, so it reads at Google's weight.
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;
};

// The rest step down to a fill rather than an outline, which would read as
// disabled beside a solid primary.
const getProviderVariant = (index: number): ButtonVariant => {
if (!isSinglePrimary) {
return onboardingSignupButton?.variant ?? ButtonVariant.Primary;
}
return index === 0 ? ButtonVariant.Primary : ButtonVariant.Float;
};

const trackOpenSignup = () => {
logEvent({
Expand Down Expand Up @@ -161,36 +184,60 @@ 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) {
return isSinglePrimary ? 'onb-split-cta' : 'onb-split-cta mb-4';
}
if (isOnboardingTrigger) {
return 'mb-3';
}
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 = (
// 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 the tap target at 48px under a 20px label.
const emailLink = (
<button
className={classNames(
getEmailButtonClass(),
'mx-auto flex min-h-12 items-center justify-center px-3 text-text-tertiary underline underline-offset-4 transition-colors typo-callout hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-text-tertiary motion-reduce:transition-none',
)}
data-funnel-track={FunnelTargetId.SignupProvider}
disabled={isSocialAuthLoading}
onClick={onEmailClick}
type="button"
>
{emailButtonLabel}
</button>
);

const emailButton = isSinglePrimary ? (
emailLink
) : (
<Button
aria-label={splitSignupStyle ? 'Create account' : 'Signup using email'}
aria-label={emailButtonAriaLabel}
className={classNames(
getEmailButtonClass(),
(isOnboardingTrigger || splitSignupStyle) && tertiarySignupButtonClass,
(isOnboardingTrigger || isSplitLayout) && tertiarySignupButtonClass,
)}
data-funnel-track={FunnelTargetId.SignupProvider}
disabled={isSocialAuthLoading}
onClick={() => {
trackOpenSignup();
onContinueWithEmail?.();
}}
onClick={onEmailClick}
size={onboardingSignupButton?.size ?? ButtonSize.Large}
type="button"
variant={
isOnboardingTrigger || splitSignupStyle
isOnboardingTrigger || isSplitLayout
? ButtonVariant.Tertiary
: ButtonVariant.Float
}
Expand All @@ -206,7 +253,12 @@ export const OnboardingRegistrationForm = ({
// Once the columns appear it follows the left-aligned column edge again.
// onb-split-login is a styling hook for the signup hero: it tightens this
// row on compact phones. Inert anywhere the hero's CSS is not present.
if (splitSignupStyle) {
// Centred on the buttons, not the left edge of the copy. mt-1 because the
// email link's padded row already supplies most of the gap.
if (isSinglePrimary) {
return 'onb-split-login mx-auto mt-1 justify-center text-center text-text-tertiary typo-callout laptop:mt-2';
}
if (isSplitLayout) {
return 'onb-split-login mx-auto mt-4 text-center text-text-secondary typo-callout laptop:mx-0 laptop:mt-5 laptop:text-left';
}
if (isOnboardingTrigger) {
Expand All @@ -220,7 +272,7 @@ export const OnboardingRegistrationForm = ({
onLogin={() => onExistingEmail?.('')}
className={{
container: getMemberAlreadyContainerClass(),
login: '!text-inherit',
login: isSinglePrimary ? '!text-text-primary' : '!text-inherit',
}}
/>
);
Expand All @@ -232,50 +284,52 @@ export const OnboardingRegistrationForm = ({
return (
<div aria-label="Login/Register options" className="flex flex-col gap-4">
<ul aria-label="Social login buttons" className="flex flex-col gap-4">
{signupProviders.map((provider) => (
{signupProviders.map((provider, index) => (
<li key={provider.value}>
<Button
aria-label={
splitSignupStyle
isCreateAccountCopy
? `Sign up with ${provider.label}`
: `Continue with ${provider.label}`
}
className="w-full"
className={classNames(
'w-full',
// Float's label is text-secondary and the brand mark follows
// it through currentColor; the fill is the hierarchy, not the
// label.
isSinglePrimary && index > 0 && '!text-text-primary',
)}
data-funnel-track={FunnelTargetId.SignupProvider}
disabled={!isReady || isSocialAuthLoading}
icon={
// A Large button gives its icon IconSize.Large (32px); the
// split layouts want the brand marks a notch smaller so they
// sit closer to the label's weight. Next step down the scale
// rather than an arbitrary size.
splitSignupStyle
? cloneElement(provider.icon, { size: IconSize.Medium })
: provider.icon
}
icon={getProviderIcon(provider.icon)}
loading={!isReady || isSocialAuthLoading}
onClick={() => onProviderClick?.(provider.value, false)}
size={onboardingSignupButton?.size ?? ButtonSize.Large}
type="button"
variant={onboardingSignupButton?.variant ?? ButtonVariant.Primary}
variant={getProviderVariant(index)}
>
{splitSignupStyle
{isCreateAccountCopy
? `Sign up with ${provider.label}`
: `Continue with ${provider.label}`}
</Button>
</li>
))}
</ul>
<OrDivider
className={{
text: 'text-text-tertiary typo-footnote',
}}
label={isOnboardingTrigger ? 'or' : 'OR'}
/>
{!isSinglePrimary && (
<OrDivider
className={{
text: 'text-text-tertiary typo-footnote',
}}
label={isOnboardingTrigger ? 'or' : 'OR'}
/>
)}
{isOnboardingTrigger ? (
<div
className={classNames(
'flex flex-col',
splitSignupStyle ? 'items-start text-left' : 'text-center',
isSplitLayout && !isSinglePrimary
? 'items-start text-left'
: 'text-center',
)}
>
{emailButton}
Expand Down
8 changes: 6 additions & 2 deletions packages/shared/src/components/auth/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export const actionToAuthDisplay: Record<OnboardingActions, AuthDisplay> = {
[OnboardingActions.VerifyEmail]: AuthDisplay.EmailVerification,
} as const;

/** Signup-wall treatment. Both values imply the split-column geometry and
* then differ in copy and CTA hierarchy. One name rather than independent
* booleans, so a caller cannot ask for a hierarchy without its geometry. */
export type SignupStyle = 'splitCreateAccount' | 'singlePrimary';

export interface AuthProps {
isAuthenticating: boolean;
isLoginFlow: boolean;
Expand Down Expand Up @@ -130,8 +135,7 @@ export interface AuthOptionsProps {
onboardingSignupButton?: ButtonProps<'button'>;
hideLoginLink?: boolean;
compact?: boolean;
/** X-style split onboarding: "Sign up with", "Create account", Sign in button */
splitSignupStyle?: boolean;
signupStyle?: SignupStyle;
/** Order GitHub before Google in the OAuth provider list (developer-first). */
preferGithub?: boolean;
autoTriggerProvider?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -123,6 +126,42 @@ describe('OnboardingSignupHero', () => {
expect(screen.getByText('Hello devs')).not.toHaveClass('onb-headline');
});

describe('horizon background', () => {
const renderHorizon = (
props: Partial<React.ComponentProps<typeof OnboardingSignupHero>> = {},
) => 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();
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 <b>discover</b><script>bad()</script>',
});
const heading = screen.getByRole('heading', { level: 1 });
expect(heading.innerHTML).toContain('<b>discover</b>');
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();
Expand Down
Loading
Loading