Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions packages/shared/src/components/auth/OnboardingRegistrationForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
import React, { useEffect } from 'react';
import React, { cloneElement, useEffect } from 'react';
import classNames from 'classnames';
import type { AuthFormProps } from './common';
import { providerMap } from './common';
Expand All @@ -10,6 +10,7 @@ import { AuthEventNames, AuthTriggers } from '../../lib/auth';
import type { ButtonProps } from '../buttons/Button';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { isIOSNative } from '../../lib/func';
import { IconSize } from '../Icon';

import { MemberAlready } from '../onboarding/MemberAlready';
import SignupDisclaimer from './SignupDisclaimer';
Expand Down Expand Up @@ -146,14 +147,22 @@ export const OnboardingRegistrationForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// text-primary, not white: this button sits on the page background, which is
// light in light mode — white label on it is invisible
const tertiarySignupButtonClass =
'!w-full !border !border-border-subtlest-tertiary !text-white';
'!w-full !border !border-border-subtlest-tertiary !text-text-primary';

const getEmailButtonClass = (): string => {
if (compact) {
return 'mb-4';
}
if (isOnboardingTrigger && !splitSignupStyle) {
// 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 (isOnboardingTrigger) {
return 'mb-3';
}
return 'mb-8';
Expand Down Expand Up @@ -189,13 +198,22 @@ export const OnboardingRegistrationForm = ({
);

const getMemberAlreadyContainerClass = (): string => {
// Stacked, the split layouts have no footer/disclaimer strip under them, so
// this centres like the cards/desk walls. The form is bottom-anchored
// there, so the tight gap is what pushes the buttons down the screen.
// 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) {
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) {
return 'mx-auto mt-5 text-center text-text-secondary typo-callout';
}
return 'mx-auto mt-6 text-center text-text-secondary typo-callout';
};

const memberAlready = !hideLoginLink && !splitSignupStyle && (
const memberAlready = !hideLoginLink && (
<MemberAlready
onLogin={() => onExistingEmail?.('')}
className={{
Expand All @@ -205,23 +223,6 @@ export const OnboardingRegistrationForm = ({
/>
);

const splitSignInSection = splitSignupStyle && !hideLoginLink && (
<div className="mt-2 flex w-full flex-col items-start gap-3">
<p className="text-left text-text-secondary typo-callout">
Already have an account?
</p>
<Button
aria-label="Sign in"
className={tertiarySignupButtonClass}
onClick={() => onExistingEmail?.('')}
size={onboardingSignupButton?.size ?? ButtonSize.Large}
type="button"
variant={ButtonVariant.Tertiary}
>
Sign in
</Button>
</div>
);
const disclaimer = (
<SignupDisclaimer className="!text-text-tertiary tablet:!typo-footnote" />
);
Expand All @@ -240,7 +241,15 @@ export const OnboardingRegistrationForm = ({
className="w-full"
data-funnel-track={FunnelTargetId.SignupProvider}
disabled={!isReady || isSocialAuthLoading}
icon={provider.icon}
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
}
loading={!isReady || isSocialAuthLoading}
onClick={() => onProviderClick?.(provider.value, false)}
size={onboardingSignupButton?.size ?? ButtonSize.Large}
Expand Down Expand Up @@ -268,7 +277,6 @@ export const OnboardingRegistrationForm = ({
)}
>
{emailButton}
{splitSignInSection}
{memberAlready}
</div>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,52 @@ describe('OnboardingSignupHero', () => {
expect(screen.queryByTestId('hero-halo')).not.toBeInTheDocument();
});

it('renders the hero cover and the full element set for the panel background', () => {
renderHero({ background: 'panel', headline: 'Hello devs' });
expect(screen.getAllByTestId('landing-hero-cover').length).toBeGreaterThan(
0,
);
expect(screen.queryByTestId('bg-layer')).not.toBeInTheDocument();
expect(screen.getByTestId('logo')).toBeInTheDocument();
expect(screen.getByText('Hello devs')).toBeInTheDocument();
expect(screen.getByTestId('auth-form')).toBeInTheDocument();
expect(screen.getByTestId('footer')).toBeInTheDocument();
expect(screen.getByTestId('disclaimer')).toBeInTheDocument();
});

describe('legal row on the panel background', () => {
// jsdom does not evaluate media queries, so an element hidden by a `hidden`
// class is still in the DOM and a presence assertion proves nothing. The
// breakpoint it reappears at has to be read off the class list instead.
const revealBreakpoint = (element: HTMLElement): string | undefined => {
let node = element.parentElement;

while (node) {
if (/(^|\s)hidden(\s|$)/.test(node.className)) {
return node.className.match(/(\w+):(?:flex|block)/)?.[1];
}
node = node.parentElement;
}

return undefined;
};

// The cards/desk walls render neither below `tablet` (their `isMobile`
// branch omits both), so the panel matches rather than becoming the only
// wall with a phone-width disclosure.
it('reveals the signup disclosure at the same breakpoint as the other walls', () => {
renderHero({ background: 'panel' });

expect(revealBreakpoint(screen.getByTestId('disclaimer'))).toBe('tablet');
});

it('holds the footer links back to the two-column layout', () => {
renderHero({ background: 'panel' });

expect(revealBreakpoint(screen.getByTestId('footer'))).toBe('laptop');
});
});

it('renders the form and headline', () => {
renderHero({ headline: 'Hello devs' });
expect(screen.getByTestId('auth-form')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
import { HERO_STYLES } from './signupHero/heroStyles';
import { HeroBackgroundLayer } from './signupHero/HeroBackgroundLayer';
import { AuroraOrbs } from './signupHero/HeroDecorations';
import { LandingHeroCover } from './signupHero/LandingHeroCover';
import { LandingAppInstall } from './signupHero/LandingAppInstall';
import { cloudinaryOnboardingLoginBackground } from '../../../lib/image';
import { sanitizeMessage } from '../lib/utils';

Expand Down Expand Up @@ -54,6 +56,9 @@ const SIGNUP_CONTENT_MAX_W = 'max-w-[360px]';
// fields have more room; kept as a named constant to document the deliberate
// difference from SIGNUP_CONTENT_MAX_W.
const SIGNUP_FORM_MAX_W = 'max-w-[440px]';
// The panel's column is as wide as the expanded form, not the marketing wall:
// the footer links and the disclaimer each need that width to stay on one line.
const SIGNUP_SPLIT_COLUMN_MAX_W = SIGNUP_FORM_MAX_W;

export const OnboardingSignupHero = ({
children,
Expand All @@ -80,6 +85,7 @@ export const OnboardingSignupHero = ({

const isSplitLayout = background === 'split';
const isDeskVariant = background === 'desk';
const isPanelLayout = background === 'panel';
const showOrbsLayer = showOrbs;

// Once the user moves to the email registration / verification step, drop the
Expand Down Expand Up @@ -125,6 +131,115 @@ export const OnboardingSignupHero = ({
);
}

// Marketing-landing parity: the signup wall reuses the exact same elements as
// the other backgrounds (logo, headline, auth options, disclaimer, footer
// links); only the shell around them changes. Above `laptop` the form sits in
// a left column with the hero artwork framed in the right one. Below it the
// layout stacks: the artwork takes the top of the screen and dissolves into
// the page background, with the form bottom-anchored underneath.
if (isPanelLayout) {
const signupColumn = (
<div
className={classNames(
'onb-hero-column flex w-full flex-col gap-6 tablet:gap-7 laptop:items-start laptop:gap-8',
SIGNUP_SPLIT_COLUMN_MAX_W,
)}
>
<Logo
position={LogoPosition.Relative}
className="onb-hero-logo !left-0 !top-0 !mt-0 !translate-x-0 self-center laptop:!self-start"
logoClassName={{ container: 'h-7' }}
/>

{headline && (
<h1
// no leading-* here: the typo-* utilities set their own line-height
// and win the cascade, so it would be a dead class. The
// onb-hero-headline hook drives the compact-phone step-down.
className="onb-hero-headline text-balance text-center font-bold tracking-tight text-text-primary typo-large-title tablet:typo-mega3 laptop:text-left"
>
{headline}
</h1>
)}

{children}
</div>
);

return (
<div className="onb-split relative isolate z-3 flex min-h-dvh w-full flex-col overflow-hidden bg-background-default text-text-primary laptop:grid laptop:grid-cols-2 laptop:items-stretch">
<style dangerouslySetInnerHTML={{ __html: HERO_STYLES }} />

<div className="relative z-1 flex min-w-0 flex-1 flex-col laptop:col-start-1 laptop:row-start-1 laptop:min-h-dvh">
{/* Stacked, the artwork owns the top of the screen and dissolves into
the page background over the lower part of it. It is absolute
rather than in flow — at half the viewport there is no room left
for the form otherwise — so the form bottom-anchors underneath and
the two meet inside the gradient. */}
<div
aria-hidden
className="onb-art-half pointer-events-none absolute inset-x-0 top-0 select-none overflow-hidden laptop:hidden"
>
<LandingHeroCover
focus="subjectHigh"
zoom
className="absolute inset-0"
/>
<div className="onb-art-fade absolute inset-x-0 bottom-0 h-3/5" />
</div>

<main
// relative: the artwork layer is absolutely positioned in the same
// stacking context, so static content would paint under it.
// pb-10: below `tablet` the legal row is hidden, so nothing sits
// under the form and the column has to carry its own bottom
// breathing room (and clear the iOS home indicator). From `tablet`
// up the legal row provides it instead.
className="onb-hero-main relative z-1 flex w-full flex-1 flex-col items-center px-5 pb-10 tablet:pb-0 laptop:px-10"
>
{signupColumn}
</main>

{/* The legal row is constrained to the same column width as the form
so its left edge lines up with the buttons above it.

It appears from `tablet` up, which is exactly where the cards and
desk walls put theirs: their `isMobile` branch (below `tablet`)
renders neither FooterLinks nor SignupDisclaimer. Matching that
keeps this variant consistent with what ships today rather than
making it the only wall with a phone-width disclosure. The gap on
phones is real but pre-existing and product-wide — see the PR
description.

Footer links stay `laptop`-only on top of that: seven links wrap
into a block the size of the form on anything narrower. */}
<div className="onb-split-legal pointer-events-auto relative z-1 hidden w-full flex-col items-center gap-3 px-5 pb-6 pt-5 tablet:flex laptop:px-10 laptop:pb-8 laptop:pt-0">
<div
className={classNames(
'flex w-full flex-col items-center gap-3 laptop:items-start',
SIGNUP_SPLIT_COLUMN_MAX_W,
)}
>
{/* the seven links need ~465px at the default gap; nowrap plus a
slightly tighter gap keeps them on one line inside the column */}
<div className="hidden laptop:block [&_footer]:!pb-0 [&_ul]:!mb-0 laptop:[&_ul]:!flex-nowrap laptop:[&_ul]:!justify-start laptop:[&_ul]:!gap-x-2.5">
<FooterLinks />
</div>
<SignupDisclaimer className="!text-text-tertiary typo-caption1 laptop:!text-left" />
</div>
</div>
</div>

{/* The panel deliberately does NOT clip: its ambilight needs to spill
past the column edge rather than being cut off at it. */}
<div className="relative hidden p-10 laptop:col-start-2 laptop:row-start-1 laptop:block laptop:min-h-dvh">
<LandingHeroCover variant="panel" ambilight className="h-full" />
<LandingAppInstall className="absolute bottom-14 right-14" />
</div>
</div>
);
}

if (isMobile) {
return (
<div className="relative z-3 flex min-h-dvh w-full flex-col justify-end overflow-x-hidden bg-background-default pt-40 text-text-primary">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ export const HeroBackgroundLayer = ({
return imageMode === 'colors' ? null : <DeskBackground />;
}

// The panel owns its artwork inside the layout (the split's second column),
// so there is nothing to render here.
if (background === 'panel') {
return null;
}

return <CardsBackground splitMode={background === 'split'} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { APP_URL, LandingAppInstall, VISIBLE_LABEL } from './LandingAppInstall';

describe('LandingAppInstall', () => {
it('exposes the app-store destination as a link, not only as a QR code', () => {
render(<LandingAppInstall />);

const link = screen.getByRole('link');

expect(link).toHaveAttribute('href', APP_URL);
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});

it('keeps the visible label inside the accessible name (WCAG 2.5.3)', () => {
render(<LandingAppInstall />);

expect(screen.getByText(VISIBLE_LABEL)).toBeInTheDocument();
// A voice-control user activates this by speaking the words they can see,
// so the accessible name has to contain the caption verbatim.
expect(
screen.getByRole('link', {
name: new RegExp(VISIBLE_LABEL, 'i'),
}),
).toBeInTheDocument();
});

it('names the destination as well as the gesture', () => {
render(<LandingAppInstall />);

expect(
screen.getByRole('link', { name: /iOS or Android/i }),
).toBeInTheDocument();
});
});
Loading
Loading