From 7281072ca781fb7e5b5ab3d2e4ecd027559aa9ed Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 12:25:49 +0300
Subject: [PATCH 1/8] feat(onboarding): panel signup wall variant
Adds a `panel` background to the signup wall (FunnelStepType.HeroLanding):
the form in a left column with the marketing landing page's hero artwork
framed in the right one. Stacks below `laptop`, where the artwork takes the
top of the screen and dissolves into the page background.
Selected by Freyja config via the step's `background` parameter, so the
default (`cards`) is unchanged.
Co-Authored-By: Claude Opus 5
---
.../auth/OnboardingRegistrationForm.tsx | 54 ++++----
.../components/OnboardingSignupHero.spec.tsx | 13 ++
.../components/OnboardingSignupHero.tsx | 104 +++++++++++++++
.../signupHero/HeroBackgroundLayer.tsx | 6 +
.../signupHero/LandingAppInstall.tsx | 66 ++++++++++
.../signupHero/LandingHeroCover.tsx | 109 ++++++++++++++++
.../components/signupHero/heroStyles.ts | 74 +++++++++++
.../onboarding/steps/FunnelHeroLanding.tsx | 24 +++-
.../src/features/onboarding/types/funnel.ts | 8 +-
packages/shared/src/lib/image.ts | 6 +
.../onboarding/FunnelHeroLanding.stories.tsx | 84 ++++++++++++
.../SignupWallComparison.stories.tsx | 122 ++++++++++++++++++
12 files changed, 643 insertions(+), 27 deletions(-)
create mode 100644 packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
create mode 100644 packages/shared/src/features/onboarding/components/signupHero/LandingHeroCover.tsx
create mode 100644 packages/storybook/stories/components/onboarding/FunnelHeroLanding.stories.tsx
create mode 100644 packages/storybook/stories/components/onboarding/SignupWallComparison.stories.tsx
diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx
index 657564e5365..7abc740ac87 100644
--- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx
+++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx
@@ -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';
@@ -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';
@@ -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';
@@ -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 && (
onExistingEmail?.('')}
className={{
@@ -205,23 +223,6 @@ export const OnboardingRegistrationForm = ({
/>
);
- const splitSignInSection = splitSignupStyle && !hideLoginLink && (
-
-
- Already have an account?
-
-
-
- );
const disclaimer = (
);
@@ -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}
@@ -268,7 +277,6 @@ export const OnboardingRegistrationForm = ({
)}
>
{emailButton}
- {splitSignInSection}
{memberAlready}
) : (
diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx
index fab49866764..6ef32db1b22 100644
--- a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx
+++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx
@@ -123,6 +123,19 @@ 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();
+ });
+
it('renders the form and headline', () => {
renderHero({ headline: 'Hello devs' });
expect(screen.getByTestId('auth-form')).toBeInTheDocument();
diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx
index aada28cc335..4852c844f33 100644
--- a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx
+++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx
@@ -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';
@@ -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,
@@ -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
@@ -125,6 +131,104 @@ 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 = (
+
+
+
+ {headline && (
+
+ {headline}
+
+ )}
+
+ {children}
+
+ );
+
+ return (
+
+
+
+
+ {/* 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. */}
+
+
+
+
+
+
+ {signupColumn}
+
+
+ {/* 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 only exists
+ in the two-column layout: once the layout stacks it is dropped
+ entirely, leaving the column ending on the login link. */}
+
+
+ {/* the seven links need ~465px at the default gap; nowrap plus a
+ slightly tighter gap keeps them on one line inside the column */}
+
+
+
+
+
+
+
+
+ {/* The panel deliberately does NOT clip: its ambilight needs to spill
+ past the column edge rather than being cut off at it. */}
+
+
+
+
+
+ );
+ }
+
if (isMobile) {
return (
diff --git a/packages/shared/src/features/onboarding/components/signupHero/HeroBackgroundLayer.tsx b/packages/shared/src/features/onboarding/components/signupHero/HeroBackgroundLayer.tsx
index f27af95403a..d1f45ba0793 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/HeroBackgroundLayer.tsx
+++ b/packages/shared/src/features/onboarding/components/signupHero/HeroBackgroundLayer.tsx
@@ -29,5 +29,11 @@ export const HeroBackgroundLayer = ({
return imageMode === 'colors' ? null : ;
}
+ // 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 ;
};
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
new file mode 100644
index 00000000000..f9b80fc4286
--- /dev/null
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
@@ -0,0 +1,66 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import LogoIcon from '../../../../svg/LogoIcon';
+
+// =============================================================
+// App install prompt — a scannable card floated over the hero
+// artwork on the split layouts.
+//
+// The QR encodes https://r.daily.dev/get — daily.dev's own smart
+// link, which redirects on User-Agent, so the one code sends
+// iPhones to the App Store and Android to Google Play.
+//
+// Because that URL is fixed, the code is a static asset rather than
+// a runtime dependency: the module matrix was generated once at
+// error-correction level H (30% recovery, which is what lets the
+// logo sit on top without breaking the scan) and flattened into a
+// single path of horizontal runs. The viewBox carries the 4-module
+// quiet zone the spec requires. Regenerate if the URL changes.
+// =============================================================
+
+const QR_MODULES = 29;
+// The spec asks for 4 modules of quiet zone. We bake in 2 and let the white
+// box's own padding make up the rest, which buys the code ~12% more area
+// inside the same box. Level H's 30% recovery covers the shortfall.
+const QR_QUIET_ZONE = 2;
+const QR_SIZE = QR_MODULES + QR_QUIET_ZONE * 2;
+const QR_PATH =
+ 'M0 0h7v1h-7zM9 0h1v1h-1zM13 0h1v1h-1zM16 0h1v1h-1zM18 0h1v1h-1zM22 0h7v1h-7zM0 1h1v1h-1zM6 1h1v1h-1zM12 1h2v1h-2zM15 1h1v1h-1zM17 1h2v1h-2zM20 1h1v1h-1zM22 1h1v1h-1zM28 1h1v1h-1zM0 2h1v1h-1zM2 2h3v1h-3zM6 2h1v1h-1zM9 2h2v1h-2zM13 2h1v1h-1zM16 2h1v1h-1zM19 2h2v1h-2zM22 2h1v1h-1zM24 2h3v1h-3zM28 2h1v1h-1zM0 3h1v1h-1zM2 3h3v1h-3zM6 3h1v1h-1zM12 3h3v1h-3zM17 3h1v1h-1zM22 3h1v1h-1zM24 3h3v1h-3zM28 3h1v1h-1zM0 4h1v1h-1zM2 4h3v1h-3zM6 4h1v1h-1zM8 4h1v1h-1zM10 4h1v1h-1zM12 4h1v1h-1zM14 4h2v1h-2zM17 4h1v1h-1zM19 4h1v1h-1zM22 4h1v1h-1zM24 4h3v1h-3zM28 4h1v1h-1zM0 5h1v1h-1zM6 5h1v1h-1zM10 5h2v1h-2zM13 5h1v1h-1zM15 5h1v1h-1zM19 5h1v1h-1zM22 5h1v1h-1zM28 5h1v1h-1zM0 6h7v1h-7zM8 6h1v1h-1zM10 6h1v1h-1zM12 6h1v1h-1zM14 6h1v1h-1zM16 6h1v1h-1zM18 6h1v1h-1zM20 6h1v1h-1zM22 6h7v1h-7zM8 7h2v1h-2zM11 7h1v1h-1zM13 7h1v1h-1zM15 7h1v1h-1zM17 7h1v1h-1zM19 7h1v1h-1zM2 8h2v1h-2zM6 8h3v1h-3zM20 8h3v1h-3zM24 8h1v1h-1zM0 9h2v1h-2zM7 9h5v1h-5zM17 9h4v1h-4zM22 9h5v1h-5zM28 9h1v1h-1zM1 10h4v1h-4zM6 10h1v1h-1zM13 10h1v1h-1zM15 10h4v1h-4zM22 10h1v1h-1zM26 10h2v1h-2zM0 11h3v1h-3zM9 11h1v1h-1zM11 11h2v1h-2zM15 11h6v1h-6zM23 11h1v1h-1zM28 11h1v1h-1zM0 12h1v1h-1zM3 12h2v1h-2zM6 12h1v1h-1zM9 12h3v1h-3zM13 12h2v1h-2zM16 12h2v1h-2zM21 12h1v1h-1zM23 12h1v1h-1zM25 12h4v1h-4zM0 13h2v1h-2zM5 13h1v1h-1zM8 13h7v1h-7zM17 13h4v1h-4zM22 13h2v1h-2zM25 13h2v1h-2zM28 13h1v1h-1zM1 14h2v1h-2zM4 14h1v1h-1zM6 14h4v1h-4zM11 14h1v1h-1zM13 14h2v1h-2zM16 14h1v1h-1zM18 14h2v1h-2zM21 14h5v1h-5zM27 14h2v1h-2zM1 15h1v1h-1zM4 15h2v1h-2zM7 15h1v1h-1zM10 15h6v1h-6zM19 15h1v1h-1zM21 15h2v1h-2zM24 15h2v1h-2zM0 16h2v1h-2zM4 16h1v1h-1zM6 16h6v1h-6zM13 16h1v1h-1zM16 16h2v1h-2zM20 16h1v1h-1zM23 16h3v1h-3zM28 16h1v1h-1zM1 17h1v1h-1zM3 17h2v1h-2zM9 17h4v1h-4zM16 17h1v1h-1zM18 17h1v1h-1zM20 17h1v1h-1zM23 17h1v1h-1zM25 17h2v1h-2zM0 18h1v1h-1zM2 18h6v1h-6zM10 18h1v1h-1zM12 18h2v1h-2zM15 18h1v1h-1zM18 18h1v1h-1zM20 18h1v1h-1zM22 18h1v1h-1zM24 18h2v1h-2zM4 19h2v1h-2zM7 19h2v1h-2zM15 19h2v1h-2zM19 19h1v1h-1zM21 19h2v1h-2zM26 19h3v1h-3zM1 20h3v1h-3zM5 20h2v1h-2zM13 20h1v1h-1zM17 20h1v1h-1zM19 20h10v1h-10zM8 21h1v1h-1zM10 21h1v1h-1zM15 21h1v1h-1zM17 21h4v1h-4zM24 21h2v1h-2zM27 21h2v1h-2zM0 22h7v1h-7zM8 22h1v1h-1zM10 22h3v1h-3zM16 22h2v1h-2zM19 22h2v1h-2zM22 22h1v1h-1zM24 22h1v1h-1zM26 22h2v1h-2zM0 23h1v1h-1zM6 23h1v1h-1zM9 23h1v1h-1zM11 23h1v1h-1zM15 23h4v1h-4zM20 23h1v1h-1zM24 23h1v1h-1zM27 23h2v1h-2zM0 24h1v1h-1zM2 24h3v1h-3zM6 24h1v1h-1zM9 24h5v1h-5zM17 24h2v1h-2zM20 24h8v1h-8zM0 25h1v1h-1zM2 25h3v1h-3zM6 25h1v1h-1zM8 25h2v1h-2zM11 25h2v1h-2zM16 25h4v1h-4zM24 25h2v1h-2zM27 25h1v1h-1zM0 26h1v1h-1zM2 26h3v1h-3zM6 26h1v1h-1zM8 26h1v1h-1zM10 26h2v1h-2zM13 26h1v1h-1zM20 26h1v1h-1zM23 26h1v1h-1zM25 26h2v1h-2zM28 26h1v1h-1zM0 27h1v1h-1zM6 27h1v1h-1zM10 27h3v1h-3zM14 27h2v1h-2zM17 27h1v1h-1zM20 27h1v1h-1zM22 27h1v1h-1zM24 27h2v1h-2zM27 27h1v1h-1zM0 28h7v1h-7zM9 28h1v1h-1zM12 28h2v1h-2zM16 28h2v1h-2zM19 28h5v1h-5zM27 28h1v1h-1z';
+
+export const LandingAppInstall = ({
+ className,
+}: {
+ className?: string;
+}): ReactElement => (
+
+ {/* The card always sits on the dark artwork, so its label is fixed light
+ rather than theme-driven — text-primary would go dark in light mode and
+ disappear into the illustration. */}
+
+ Scan to get the app
+
+
+
+ {/* raw tokens, not theme ones: this badge sits on the QR's own white
+ field, so it must stay dark-on-white in both themes */}
+
+
+
+
+
+);
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingHeroCover.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingHeroCover.tsx
new file mode 100644
index 00000000000..47c340ab6d2
--- /dev/null
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingHeroCover.tsx
@@ -0,0 +1,109 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import { landingHeroCover } from '../../../../lib/image';
+
+type Focus = 'subject' | 'subjectHigh';
+
+// =============================================================
+// Landing hero cover — the artwork from the long variant of the
+// marketing landing page: the dev and their dog at their tent,
+// looking out over a glowing valley.
+//
+// `cover` fills whatever box it is given edge to edge (the stacked
+// layout's top band). `panel` insets the same artwork in a rounded
+// frame with an ambilight behind it (the split's right column).
+// =============================================================
+
+type LandingHeroCoverProps = {
+ className?: string;
+ variant?: 'cover' | 'panel';
+ // renders a blurred, over-saturated copy of the artwork behind the frame, so
+ // the panel throws its own colour onto the page
+ ambilight?: boolean;
+ // 'subject' frames the dev and the dog; 'subjectHigh' shows the same pair but
+ // sat higher in the box, for a band whose lower half is under a fade.
+ focus?: Focus;
+ // Scales the artwork up around the focus point, so the dev and the dog read
+ // at a usable size in a small box. The caller must clip.
+ zoom?: boolean;
+};
+
+// The artwork is square with the pair low and left, so one position serves
+// every subject crop: portrait columns crop horizontally (38% keeps them off
+// the left edge) and taller boxes crop vertically (72% keeps them in frame).
+const FOCUS_CROP: Record = {
+ subject: 'object-[38%_72%]',
+ subjectHigh: 'object-[34%_92%]',
+};
+
+// Anchored on the same point the crop is centred on, so scaling up grows the
+// pair in place rather than sliding them out of frame.
+const ZOOM_CLASS: Record = {
+ subject: 'scale-[1.45] origin-[38%_72%]',
+ subjectHigh: 'scale-[1.5] origin-[34%_92%]',
+};
+
+export const LandingHeroCover = ({
+ className,
+ variant = 'cover',
+ ambilight = false,
+ focus = 'subject',
+ zoom = false,
+}: LandingHeroCoverProps): ReactElement => {
+ const isPanel = variant === 'panel';
+ const cropClass = classNames(FOCUS_CROP[focus], zoom && ZOOM_CLASS[focus]);
+
+ return (
+ // no `relative` in the base: the cover variant is positioned by its caller
+ // (absolute inset-0) and Tailwind's `.relative` would win over it
+
+ {isPanel && ambilight && (
+ // sibling of the frame rather than a child: the frame paints its own
+ // background, which would cover a negatively-stacked child
+
+
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/shared/src/features/onboarding/components/signupHero/heroStyles.ts b/packages/shared/src/features/onboarding/components/signupHero/heroStyles.ts
index 708c1a1badb..f5657017183 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/heroStyles.ts
+++ b/packages/shared/src/features/onboarding/components/signupHero/heroStyles.ts
@@ -139,4 +139,78 @@ export const HERO_STYLES = `.onb-bg {
.onb-split-right-panel {
background: var(--theme-background-default);
}
+
+/* --- signup wall: panel background --- */
+
+/* Stacked, the form is bottom-anchored like the cards/desk walls; the split
+ layout centres it in its column instead. */
+.onb-hero-main { justify-content: flex-end; }
+@media (min-width: 1020px) {
+ .onb-hero-main { justify-content: center; }
+}
+
+/* Compact phones. A 50dvh artwork band leaves too little room for the form on a
+ short viewport, so it gives height back and the type tightens with it. Keyed
+ on height rather than width because that is the axis under pressure — a
+ 375x812 phone gets the roomy treatment, a 375x667 one does not. */
+.onb-art-half { height: 50dvh; }
+@media (max-height: 759px) {
+ .onb-art-half { height: 32dvh; }
+ .onb-hero-logo svg { height: 1.375rem; }
+ .onb-hero-headline { font-size: 1.5rem; line-height: 1.875rem; }
+ .onb-hero-column { gap: 1rem; }
+ .onb-split-cta { margin-bottom: 0.5rem; }
+ .onb-split-login { margin-top: 0.25rem; font-size: 0.8125rem; }
+}
+
+/* Reaches full background by 88% rather than 100%: on short screens the form
+ starts high enough that the last stretch of the ramp sits behind the logo,
+ and a still-visible image there reads as clutter. */
+.onb-art-fade {
+ background: linear-gradient(
+ to bottom,
+ transparent 0%,
+ color-mix(in srgb, var(--theme-background-default) 45%, transparent) 38%,
+ color-mix(in srgb, var(--theme-background-default) 90%, transparent) 68%,
+ var(--theme-background-default) 88%
+ );
+}
+/* Smoked glass: a dark tint rather than the usual white one, so the card reads
+ as a panel resting on the artwork instead of a bright patch cut out of it.
+ The white hairline and inset highlight stay — they are what keep it glassy. */
+.onb-glass-card {
+ background: rgba(10, 12, 18, 0.32);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ backdrop-filter: blur(16px) saturate(140%);
+ -webkit-backdrop-filter: blur(16px) saturate(140%);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.1),
+ 0 8px 24px rgba(0, 0, 0, 0.4);
+}
+.onb-panel-frame {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: color-mix(in srgb, var(--theme-accent-cabbage-default) 6%, transparent);
+ /* small and soft, and on the theme-aware shadow tokens so light mode gets the
+ salt-based shadow instead of a heavy black one. The colour around the panel
+ comes from .onb-ambilight, not from here. */
+ box-shadow:
+ 0 8px 24px -10px var(--theme-shadow-shadow1),
+ 0 2px 6px -2px var(--theme-shadow-shadow1);
+}
+
+/* Ambilight — the artwork itself, blurred and over-saturated behind the panel,
+ so the halo is literally the image's own colours bleeding out of the frame
+ (the TV backlight / YouTube ambient-mode trick). */
+.onb-ambilight {
+ filter: blur(28px) saturate(1.5);
+ opacity: 0.3;
+ animation: onb-ambilight-breathe 14s ease-in-out infinite;
+}
+@keyframes onb-ambilight-breathe {
+ 0%, 100% { opacity: 0.24; transform: scale(1); }
+ 50% { opacity: 0.36; transform: scale(1.02); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .onb-ambilight { animation: none; opacity: 0.3; }
+}
`;
diff --git a/packages/shared/src/features/onboarding/steps/FunnelHeroLanding.tsx b/packages/shared/src/features/onboarding/steps/FunnelHeroLanding.tsx
index c5cd2f35af3..9a4fef327a7 100644
--- a/packages/shared/src/features/onboarding/steps/FunnelHeroLanding.tsx
+++ b/packages/shared/src/features/onboarding/steps/FunnelHeroLanding.tsx
@@ -20,11 +20,17 @@ import { OnboardingSignupHero } from '../components/OnboardingSignupHero';
type FunnelHeroLandingProps = FunnelStepHeroLanding;
+const authContainerClass =
+ 'w-full max-w-full rounded-none tablet:max-w-[30rem]';
+// AuthOptions reserves a 21.25rem minimum so its display swaps don't jolt the
+// page. The split layouts bottom-anchor their form, where that reservation
+// becomes dead space *under* the buttons that holds them off the bottom edge —
+// so they opt out and let the container hug its content instead.
+const splitAuthContainerClass = classNames(authContainerClass, '!min-h-0');
+
const staticAuthProps = {
className: {
- container: classNames(
- 'w-full max-w-full rounded-none tablet:max-w-[30rem]',
- ),
+ container: authContainerClass,
onboardingSignup: '!gap-5 !pb-5 tablet:gap-8 tablet:pb-8',
},
forceDefaultDisplay: true,
@@ -78,6 +84,7 @@ export const FunnelHeroLanding = withIsActiveGuard(
!isEmailSignupActive &&
isSocialSignupUser(user);
const preferGithub = oauthOrder !== 'googleFirst';
+ const isSplitColumnBackground = background === 'panel';
const onAuthStateUpdate = useCallback(
(data: Partial) => {
@@ -184,6 +191,17 @@ export const FunnelHeroLanding = withIsActiveGuard(
>
= {
+ title: 'Components/Onboarding/Steps/FunnelHeroLanding',
+ component: FunnelHeroLanding,
+ parameters: {
+ layout: 'fullscreen',
+ themes: { themeOverride: 'dark' },
+ controls: { expanded: true },
+ },
+ render: (props) => (
+
+
+
+ ),
+ beforeEach: () => {
+ useRouter.mockImplementation(() => ({
+ replace: fn(),
+ push: fn(),
+ pathname: '/onboarding',
+ query: {},
+ }));
+
+ getBootMock.mockReturnValue({
+ ...defaultBootData,
+ user: {
+ id: 'anonymous user',
+ firstVisit: 'first visit',
+ referrer: 'string',
+ },
+ accessToken: { token: '1', expiresIn: '1' },
+ visit: { sessionId: '1', visitId: '1' },
+ feeds: [],
+ });
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const baseArgs: FunnelStepHeroLanding = {
+ id: 'hero-landing-step',
+ type: FunnelStepType.HeroLanding,
+ transitions: [],
+ onTransition: action('onTransition'),
+ parameters: {
+ headline: 'The homepage every developer deserves.',
+ },
+};
+
+export const Cards: Story = {
+ args: { ...baseArgs, parameters: { ...baseArgs.parameters } },
+};
+
+export const Desk: Story = {
+ args: {
+ ...baseArgs,
+ parameters: { ...baseArgs.parameters, background: 'desk' },
+ },
+};
+
+/** Form on the left, hero cover inset as a rounded panel on the right. */
+export const Panel: Story = {
+ args: {
+ ...baseArgs,
+ parameters: {
+ ...baseArgs.parameters,
+ headline: "Where developers discover what's next",
+ background: 'panel',
+ },
+ },
+};
+
+
diff --git a/packages/storybook/stories/components/onboarding/SignupWallComparison.stories.tsx b/packages/storybook/stories/components/onboarding/SignupWallComparison.stories.tsx
new file mode 100644
index 00000000000..61d291d0386
--- /dev/null
+++ b/packages/storybook/stories/components/onboarding/SignupWallComparison.stories.tsx
@@ -0,0 +1,122 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+// =============================================================
+// Side-by-side comparison of the signup wall backgrounds.
+//
+// Each frame is a real
)}
@@ -100,7 +100,7 @@ export const LandingHeroCover = ({
className={classNames('block size-full object-cover', cropClass)}
decoding="async"
fetchPriority="high"
- src={landingHeroCover}
+ src={signupWallCover}
/>
diff --git a/packages/shared/src/lib/image.ts b/packages/shared/src/lib/image.ts
index f110a0c291a..32339d22e4e 100644
--- a/packages/shared/src/lib/image.ts
+++ b/packages/shared/src/lib/image.ts
@@ -146,8 +146,8 @@ export const cloudinaryOnboardingHeroDesk = {
// The hero cover for the signup wall — the dev and their dog at their tent,
// looking out over a glowing valley. Square (1254x1254) with the pair low and
// left in the frame, so wide crops have to bias downwards to keep them.
-export const landingHeroCover =
- 'https://media.daily.dev/image/upload/s--ozLt7EPJ--/f_auto,q_auto/v1785046213/public/ChatGPT%20Image%20Jul%2026%2C%202026%2C%2009_09_52%20AM';
+export const signupWallCover =
+ 'https://media.daily.dev/image/upload/s--a8E1hVet--/f_auto,q_auto/v1785059413/public/daily.dev%20-%20signup%20wall';
export const cloudinaryStreakSplash =
'https://media.daily.dev/image/upload/v1705386465/Splash_v1lxjk.svg';
From bd3b6aa65dccb21df782bf7d8af7b775124a26db Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Wed, 29 Jul 2026 08:46:52 +0300
Subject: [PATCH 4/8] fix(onboarding): make the signup wall app-install card a
real link
The QR was the only route to the app stores, and it is decorative markup
(aria-hidden) that only helps someone holding a phone. The card is now an
anchor to the same smart link, named by its destination, so keyboard and
screen-reader users have the same route.
Co-Authored-By: Claude Opus 5
---
.../signupHero/LandingAppInstall.spec.tsx | 27 +++++++++++++++++
.../signupHero/LandingAppInstall.tsx | 30 ++++++++++++++-----
2 files changed, 49 insertions(+), 8 deletions(-)
create mode 100644 packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
new file mode 100644
index 00000000000..af1cca783e8
--- /dev/null
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { APP_URL, LandingAppInstall } from './LandingAppInstall';
+
+describe('LandingAppInstall', () => {
+ it('exposes the app-store destination as a link, not only as a QR code', () => {
+ render();
+
+ const link = screen.getByRole('link', {
+ name: 'Get the daily.dev app for iOS or Android',
+ });
+
+ expect(link).toHaveAttribute('href', APP_URL);
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('names the link by its destination, not by the scan instruction', () => {
+ render();
+
+ // The caption explains how to use the code, which is no help to anyone who
+ // cannot scan it, so it must not end up as the link's accessible name.
+ expect(screen.getByText('Scan to get the app')).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: /scan/i }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
index f9b80fc4286..e8a972fc18e 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
@@ -7,18 +7,24 @@ import LogoIcon from '../../../../svg/LogoIcon';
// App install prompt — a scannable card floated over the hero
// artwork on the split layouts.
//
-// The QR encodes https://r.daily.dev/get — daily.dev's own smart
-// link, which redirects on User-Agent, so the one code sends
-// iPhones to the App Store and Android to Google Play.
+// The card is a link, not just a picture of one: scanning the code
+// is only available to someone holding a phone, so the same
+// destination has to be reachable by pointer and by keyboard. The
+// QR itself is decorative (aria-hidden) and the link carries the
+// accessible name.
+//
+// APP_URL is daily.dev's own smart link, which redirects on
+// User-Agent, so the one destination sends iPhones to the App
+// Store, Android to Google Play, and desktop to the extension.
//
// Because that URL is fixed, the code is a static asset rather than
// a runtime dependency: the module matrix was generated once at
// error-correction level H (30% recovery, which is what lets the
// logo sit on top without breaking the scan) and flattened into a
-// single path of horizontal runs. The viewBox carries the 4-module
-// quiet zone the spec requires. Regenerate if the URL changes.
+// single path of horizontal runs. Regenerate if APP_URL changes.
// =============================================================
+export const APP_URL = 'https://r.daily.dev/get';
const QR_MODULES = 29;
// The spec asks for 4 modules of quiet zone. We bake in 2 and let the white
// box's own padding make up the rest, which buys the code ~12% more area
@@ -33,12 +39,20 @@ export const LandingAppInstall = ({
}: {
className?: string;
}): ReactElement => (
-
{/* The card always sits on the dark artwork, so its label is fixed light
rather than theme-driven — text-primary would go dark in light mode and
@@ -62,5 +76,5 @@ export const LandingAppInstall = ({
-
+
);
From f4ac07b8facbdef4593078787f4f71b17c894bbf Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Thu, 30 Jul 2026 09:54:56 +0300
Subject: [PATCH 5/8] fix(onboarding): keep the visible label in the
app-install link's name
WCAG 2.5.3 Label in Name: the accessible name replaced the visible "Scan
to get the app" caption instead of containing it, so a voice-control user
could not activate the link by speaking the words on screen.
Co-Authored-By: Claude Opus 5
---
.../signupHero/LandingAppInstall.spec.tsx | 28 ++++++++++++-------
.../signupHero/LandingAppInstall.tsx | 14 ++++++----
2 files changed, 27 insertions(+), 15 deletions(-)
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
index af1cca783e8..111b4392d81 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.spec.tsx
@@ -1,27 +1,35 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
-import { APP_URL, LandingAppInstall } from './LandingAppInstall';
+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();
- const link = screen.getByRole('link', {
- name: 'Get the daily.dev app for iOS or Android',
- });
+ const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', APP_URL);
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
- it('names the link by its destination, not by the scan instruction', () => {
+ it('keeps the visible label inside the accessible name (WCAG 2.5.3)', () => {
render();
- // The caption explains how to use the code, which is no help to anyone who
- // cannot scan it, so it must not end up as the link's accessible name.
- expect(screen.getByText('Scan to get the app')).toBeInTheDocument();
+ 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.queryByRole('link', { name: /scan/i }),
- ).not.toBeInTheDocument();
+ screen.getByRole('link', {
+ name: new RegExp(VISIBLE_LABEL, 'i'),
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('names the destination as well as the gesture', () => {
+ render();
+
+ expect(
+ screen.getByRole('link', { name: /iOS or Android/i }),
+ ).toBeInTheDocument();
});
});
diff --git a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
index e8a972fc18e..d2c48ba5f23 100644
--- a/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
+++ b/packages/shared/src/features/onboarding/components/signupHero/LandingAppInstall.tsx
@@ -25,6 +25,9 @@ import LogoIcon from '../../../../svg/LogoIcon';
// =============================================================
export const APP_URL = 'https://r.daily.dev/get';
+// Rendered as the card's caption AND embedded in its accessible name; exported
+// so the spec asserts on the same string rather than a copy of it.
+export const VISIBLE_LABEL = 'Scan to get the app';
const QR_MODULES = 29;
// The spec asks for 4 modules of quiet zone. We bake in 2 and let the white
// box's own padding make up the rest, which buys the code ~12% more area
@@ -40,10 +43,11 @@ export const LandingAppInstall = ({
className?: string;
}): ReactElement => (
- Scan to get the app
+ {VISIBLE_LABEL}