Skip to content

feat(onboarding): horizon signup wall — flag-gated full-bleed hero variant - #6491

Open
tsahimatsliah wants to merge 14 commits into
mainfrom
feat/signup-wall-horizon
Open

feat(onboarding): horizon signup wall — flag-gated full-bleed hero variant#6491
tsahimatsliah wants to merge 14 commits into
mainfrom
feat/signup-wall-horizon

Conversation

@tsahimatsliah

@tsahimatsliah tsahimatsliah commented Aug 18, 2026

Copy link
Copy Markdown
Member

What

A new signup-wall variant — horizon — behind the signup_wall_horizon flag. This is the next iteration after panel lost its test against the cards control.

The right half is the marketing homepage's own hero artwork, full-bleed: no frame, no QR card, nothing overlaid on the image, dissolving into the page at the seam. The rail carries the tagline, one value line, and a single-primary auth stack. Stacked (mobile), the artwork tops the screen and fades into a bottom-anchored form — the thumb-reach pattern the panel validated.

Why it should beat the panel: that variant replaced the control's live feed evidence with a framed illustration and added a competing QR call-to-action. This keeps the inspiration-led direction but removes the second CTA, un-frames the art so it reads as a world rather than a poster, continues the homepage's exact headline and imagery so the funnel feels like one story, and gives the CTA stack a real hierarchy.

Included

  • background: 'horizon' on the heroLanding step, reusing the panel's split-column machinery
  • New subline funnel parameter for the value line, defaulted from HORIZON_DEFAULT_SUBLINE
  • signup_wall_horizon flag (default false)
  • signupWallHorizon image constant — the homepage hero webp, typically already in the visitor's cache
  • Storybook: a Horizon story plus five comparison views (see below)
  • Specs: the horizon layout owns its artwork, renders the value line, sanitizes funnel copy, and keeps the image free of overlays

Auth CTA hierarchy

splitSignupStyle had three unrelated concerns welded into one boolean. They are now one discriminant, signupStyle, so a wall names its treatment and the illegal combinations — single-primary hierarchy without the geometry it assumes, "Create account" copy on a one-primary rail — are unrepresentable:

signupStyle geometry copy CTA hierarchy used by
split left-aligned rail, smaller provider marks, onb-split-cta/onb-split-login compact-phone hooks "Continue with…" all providers solid, or divider — (the shape a plain split wall would take)
splitCreateAccount same "Sign up with…" / "Create account" same panel
singlePrimary same "Continue with…" one solid primary, rest secondary, email as a text link, no divider horizon

cards and desk pass nothing and are untouched.

Horizon's stack: Google solid primary (broadest one-click path), GitHub secondary — filled bg-surface-float with a hairline, full-strength label, and the filled octocat (the icon's secondary asset, so it matches Google's mark) — and email as a real text link, tertiary grey, hugging its label instead of occupying a button box. Provider marks are sized to the label (20px), not the button. The rail is 360px while the copy above keeps a 440px measure, so the CTA stack reads as one glanceable target.

It keeps the door-agnostic "Continue with…": the funnel logs existing users straight in from these buttons, so the panel's "Sign up with…" built a wrong door for every returning visitor.

Cards and Panel render byte-identically to main — verified in Storybook after every change.

Experiment readiness

Follows the flag rules in AGENTS.md: declared in featureManagement.ts, defaults to the control (false), and evaluated through useConditionalFeature with shouldEvaluate.

  • Enrollment: useConditionalFeaturegrowthbook.getFeatureValue → the provider's trackingCallback → allocation POST (experiment_id/variation_id), the same chain as swipe_onboarding. shouldEvaluate mirrors the step's own render predicate — isAuthReady && isOnboardingFunnel and none of the bail-outs that return null: already authenticated and confirmed, onboarding already complete, or a signed-in user whose actions have not landed yet. Evaluating is what allocates, so a visit that never paints a wall must never evaluate; otherwise those users sit in the denominator of both arms on a signup-completion metric with no way to convert. The paid funnel never enrolls. Pinned by FunnelHeroLanding.spec.tsx.
  • No control-arm contamination: the wall holds render until the flag resolves, so treatment users never paint a frame of the served control — which here means a full-screen background swap plus a wasted hero download, not a widget flicker. The hold is bounded at 200 ms and armed only for visits that are being enrolled. isAuthReady and GrowthBook's ready come out of the same boot payload one commit apart (BootProvider sets initialLoad and writes exp together; GrowthBookProvider's effect calls setReady on the next commit), so the deadline is a safety net, not a budget. It is kept short deliberately: ready never flips when boot returns no experiment features, and this is the funnel's entry screen — a longer deadline would blank the first screen of the acquisition funnel for the control arm too.
  • Two ways to run it: flip the flag in GrowthBook, or serve background: 'horizon' from Freyja exactly like the panel's test was run — the value line and Google-first order key off the resolved background, not the flag, so the Freyja path is complete on its own.
  • Funnel events (funnel step view, transition funnel, complete funnel, leave funnel) are step-level and background-agnostic — unchanged.
  • CTA clicks: every provider and the email link keep data-funnel-track="signup provider" in the shared OnboardingRegistrationForm, so click funnel element fires with identical target ids across arms.

Review fixes included

Self-reviewed at high effort; these are the confirmed findings that landed:

  • Split-column geometry was being withheld from horizon (centered login row against a left-aligned rail, oversized marks, compact-phone spacing hooks never rendered) — fixed by the prop split above
  • Flag/param precedence contradicted its own comment; the flag is now documented as an override, and subline/oauthOrder derive from the resolved background
  • isLoading was discarded → control-wall flash; now held, and bounded
  • Horizon rendered headline as raw text while other walls pipe funnel copy through sanitizeMessage — HTML copy would have printed literal tags on this arm only
  • HeroBackgroundLayer fell through to the cards mosaic for horizon; now returns null explicitly
  • The Horizon story hardcoded a twin of the shipped value line; it imports the constant

Known follow-ups (deliberately not in this PR)

  • Responsive image variants — the one prerequisite for ramping past a small percentage. The hero ships as a single signed Cloudinary URL; the signature covers the transformation, so w_768 404s rather than resizing (re-verified against the live URL). Sized derivatives must be exported and signed upstream before a srcSet can exist. Until then the stacked band requests at fetchpriority="low" so it does not race the auth options for bandwidth on a cold mobile connection; the desktop column, which is half the screen, keeps high. Documented at the constant.
  • The panel/horizon split shell is duplicated (~90 lines). Collapsing it touches the previously-tested arm, which is the wrong risk to take in the same change as the experiment it would destabilise. (The artwork markup inside it is deduplicated — see HorizonArt.)
  • subline is a silent no-op on the other walls. Rendering it everywhere is a copy/design decision beyond this PR.
  • Light-mode horizon is undesigned. Unreachable with current funnel config (the wall forces dark), so it is a known gap rather than speculative work.

Test plan

  • Storybook — Components/Onboarding/Steps/FunnelHeroLanding → Horizon, and Signup wall comparison: Desktop, Mobile, MobileSmall, Tablet, DesktopControlVsHorizon, MobileControlVsHorizon, MobileCompact (375×667, where the band steps down), DesktopHorizonOnly, MobileHorizonOnly
  • Verified at 1440×900, 390×844, 375×812 and 375×667; email link measured at 48px tall (44px minimum cleared) with hover confirmed to keep background-color: rgba(0,0,0,0) and box-shadow: none while moving the label to full strength
  • Regression: Cards and Panel checked for button copy, variants, divider, borders and icon size after each change
  • lint_shared, test_shared (211 passing across onboarding + auth, including the 10 new enrollment/hold specs), typecheck_strict_changed all run locally with the CI commands before pushing
  • Flag off (default): new code is reachable only via background: 'horizon'

🤖 Generated with Claude Code

Preview domain

https://feat-signup-wall-horizon.preview.app.daily.dev

tsahimatsliah and others added 3 commits August 12, 2026 16:01
…e line

Adds a 'horizon' background to the hero-landing step: the marketing
homepage's hero artwork full-bleed as the right half (no frame, no
overlays), a subline parameter for the value line, and door-agnostic
'Continue with…' copy. Storybook: Horizon story + comparison frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stacked, the content column bottom-anchors for thumb reach; at 38dvh the
band left a dead gap above the logo on tall phones. 48dvh lets the art
dissolve into the content instead (compact phones keep 30dvh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enables the horizon wall without a Freyja change, onboarding funnel
only. Funnel-served parameters still win; the flag supplies the value
line and googleFirst order when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
daily-webapp Ready Ready Preview Aug 20, 2026 8:35am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Aug 20, 2026 8:35am

Request Review

The comparison page's 'only' filter now accepts a '+'-joined pair, and
two focused stories show the experiment's actual arms side by side at
desktop and phone sizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Splits three concerns that splitSignupStyle had bundled: geometry
(splitSignupStyle), copy (createAccountCopy), and CTA emphasis
(singlePrimaryStyle). The horizon wall now takes the split-column
geometry it always needed — left-aligned login row, smaller provider
marks, the compact-phone spacing hooks — while keeping 'Continue with…',
and adds one solid primary (Google) with the rest secondary, email as a
text link, no divider. Panel and cards render exactly as before.

Also from review: hold the wall until the flag resolves so treatment
users never see a frame of the control; sanitize headline/subline like
the other walls; stop HeroBackgroundLayer falling through to the cards
mosaic for horizon; document why the hero asset has no sized variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…case fetchpriority

The story hardcoded a twin of the shipped value line, so copy edits
would silently drift apart; it now imports the constant. React 18 warns
on the camelCase fetchPriority prop, and Logo/LazyImage already spell it
lowercase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six deltas against the mock: brand marks were 28px (now 16px, sized to
the label); GitHub rendered the Sketch-exported white mark instead of
the filled octocat; the stepped-down provider was a hairline outline
rather than a filled surface, which reads as disabled beside a solid
primary; the email link was white instead of tertiary grey; the email
and login rows left-aligned instead of centring on the button column;
and the stack ran to 480px, which reads as a form — the rail is 360px
now, with the copy keeping the wider measure above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops 'Hours early.' from the value line and holds the line to the auth
stack's 360px, so it stops outrunning the buttons beneath it. Brand
marks go up a step (16 -> 20px); the stepped-down provider takes a
full-strength label, which its currentColor mark inherits; email becomes
a real text link rather than a full-height button, and the login prompt
drops to tertiary with 'Log in' keeping full strength.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ile crop

The email link inherited the button hover, which swapped
--button-background and popped a floating fill behind the text — the
wrong affordance for something styled as a link. Background is pinned
transparent and hover moves the label to full strength instead.

The mobile band was showing the pair at cover scale, which rendered them
small and near the edge. It now zooms 1.32x around them so the dev and
Dee read clearly at the upper left, with the portal and sunrise filling
the frame to their right.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tsahimatsliah and others added 3 commits August 19, 2026 09:26
… tests

GrowthBook only marks itself ready once boot returns experiment features,
so an unconditional hold could strand the funnel's entry screen on a
blank page when that payload is missing. The wait is now bounded and
falls back to the served wall.

Adds specs for the horizon layout: it owns its artwork, renders the value
line, sanitizes funnel copy, and keeps the image free of overlays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…main

Brings the flag comment up to the convention the other experiment flags
use (what it tests, how enrollment is scoped, the metric, and why the
default must stay false), and tags the horizon artwork with a testid so
the spec asserts through Testing Library rather than container queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@tsahimatsliah tsahimatsliah left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

A new flag-gated horizon signup-wall variant reusing the panel's split-column machinery, plus a three-way split of the old splitSignupStyle boolean so a wall can take the geometry without the copy or the CTA hierarchy. CI is green, the diff is scoped to the variant, control walls (cards, panel, desk) look byte-identical, and the sanitizer/HeroBackgroundLayer/precedence issues the description lists as self-review fixes are genuinely fixed in the branch.

Two things I'd want resolved before this runs as an experiment, plus a handful of non-blocking notes. Details are inline.

Blocking

  • FunnelHeroLanding.tsx:105 — enrollment fires for onboarding-funnel visits that never render a wall (already-authenticated users and completed onboarding), diluting the treatment/control split on a signup-completion metric.

Question (potentially blocking)

  • FunnelHeroLanding.tsx:124 — the bounded flag hold blanks the funnel's entry screen for up to 1s for both arms whenever boot arrives without experiment features. swipe_onboarding, cited as the precedent, does not hold render.

Non-blocking

  • Email CTA tap target and the !important stack (OnboardingRegistrationForm.tsx:208)
  • Three interdependent style booleans on a shared auth component (common.tsx:139)
  • Duplicated inline artwork markup vs. the existing LandingHeroCover component (OnboardingSignupHero.tsx:229)
  • Unsized hero asset at fetchpriority="high" on the funnel entry screen (image.ts:161)
  • No test coverage for the flag override / hold logic itself

Verification

  • Root + package AGENTS.md read; scope matches the stated task
  • Flag/enrollment path traced through useConditionalFeatureFeaturesReadyContexttrackingCallback → allocation POST
  • Control walls and heroStyles compact-phone rules checked for inherited-compensation loss (horizon correctly reuses onb-hero-main / onb-split-cta / onb-split-login)
  • CI inspected: all checks passing; branch is behind main and needs an update before merge
  • Not verified by me: preview at 390×844 / 375×667, and the artwork's real LCP on a cold cache

Verdict

Comment — no code-correctness blocker, but the enrollment scope should be fixed before this is turned on as an experiment.

Reviewed by AI.

const { value: isHorizonWallEnabled, isLoading: isHorizonFlagLoading } =
useConditionalFeature({
feature: featureSignupWallHorizon,
shouldEvaluate: isAuthReady && isOnboarding,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (experiment correctness): shouldEvaluate is isAuthReady && isOnboarding, but the component returns null a few lines down for (isLoggedIn && user.infoConfirmed) and isOnboardingComplete. Evaluation happens during the same render, before that early return, so getFeatureValue runs — and therefore trackingCallback → the /e/x allocation POST fires — for users who never see a wall at all: anyone already authenticated who is immediately transitioned past this step, and anyone re-entering with onboarding already complete.

The description states "exposure fires exactly for wall viewers in the onboarding funnel"; as written it fires for every onboarding-funnel visit once auth is ready. On a signup-completion metric those users sit in the denominator of both arms and cannot convert, which flattens whatever lift the horizon wall produces.

Suggestion: gate shouldEvaluate on the same predicate as the render, e.g. also require !(isLoggedIn && user?.infoConfirmed) and isOnboardingActionsReady && !isOnboardingComplete — the guide's rule is to place enrollment where cohort membership and actual rendering are both known. Worth an accompanying test asserting no allocation for an authenticated confirmed user.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — this was real and it was the right thing to block on.

shouldEvaluate now mirrors the render predicate instead of just asking "is this the onboarding funnel":

const isSkippingWall =
  (isLoggedIn && user?.infoConfirmed) ||
  isOnboardingComplete ||
  (isLoggedIn && !isOnboardingActionsReady);

const shouldEvaluateWallFlag =
  isAuthReady && isOnboarding && !isSkippingWall;

useOnboardingActions() moved above the flag hook so its result is available to gate on. The third clause is the one your comment did not ask for but the same argument implies: a signed-in user's isOnboardingComplete is false until their actions land, so without it we would enroll during the fetch and then transition them past the step. Anonymous visitors — everyone the experiment is actually measured on — have nothing to fetch and are never held there.

New FunnelHeroLanding.spec.tsx asserts shouldEvaluate for each path: anonymous wall viewer (true), authenticated + confirmed (false, and no wall rendered), onboarding complete (false), signed-in with actions still loading (false), auth not ready (false), paid funnel (false). 10 specs, all green.

The description's claim has been corrected too — it now says enrollment mirrors the render predicate rather than asserting the old behaviour.

);
return () => clearTimeout(timeout);
}, []);
const isWallPending =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question (potentially blocking): isHorizonFlagLoading is !(shouldEvaluate && ready), and ready in GrowthBookProvider only flips once experimentation.features arrives with a non-empty payload. So whenever boot returns without experiment features, every onboarding-funnel visitor — control included — gets a blank screen for the full 1000 ms before the served wall paints. That is the first screen of the acquisition funnel, so the cost lands on paid traffic too, and it degrades the control arm the experiment is measured against.

Two things I'd like confirmed:

  1. In practice, does ready ever resolve later than isAuthReady? Both come from the same boot payload, so if they land together this hold is dead complexity; if they don't, it's a real first-paint regression for everyone.
  2. swipe_onboarding is cited in the description as the identical precedent, but packages/webapp/pages/onboarding.tsx deliberately ignores isLoading and renders the control immediately. Why does this wall need the stronger guarantee, and is a shorter deadline (or reusing whatever the funnel already shows while !isAuthReady, rather than null) enough to avoid the control-flash without risking a blank entry screen?

Also minor: the timer starts on mount for every funnel including paid ones, where isOnboarding is false and the state update is never read.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and the answer changed the code. Taking both parts:

1. Does ready resolve later than isAuthReady? No — they land one commit apart. BootProvider's effect calls setInitialLoad(...) (→ isAuthReady) and updateBootData(remoteData) (→ cachedBootData.exp) in the same commit; GrowthBookProvider's own effect then sees experimentation.features and calls setReady(true) on the next one. So on a normal boot the hold is sub-frame, and you are right that 1000 ms was never a budget for anything.

2. Why hold at all, when swipe_onboarding does not? Because the thing that flashes here is a full-screen background plus a hero download, not a widget — rendering cards and swapping to horizon would show treatment users the control arm and burn a wasted image fetch. That is a stronger reason to wait than swipe_onboarding has, but it is not a reason to wait long.

So the deadline drops to 200 ms — roughly 12 frames of slack on top of a one-commit wait — and the timer is now armed only when shouldEvaluateWallFlag is true, which fixes the "starts on mount for paid funnels" nit as a side effect. The pathological case you identified (boot returns no experiment features, ready never flips) now costs a blink rather than a full second, and the constant carries that reasoning in a comment so the next person does not raise it back to a second.

Covered by two specs: the wall is held while isLoading is true and falls back to the served background once the deadline passes, and a visit that is not being enrolled is never held at all.

// --button-background, which pops a floating fill behind the text —
// wrong affordance here, so the background is pinned transparent
// and hover moves the label to full strength instead.
'mx-auto !h-auto !w-auto !bg-transparent !px-0 !py-0 !text-text-tertiary !underline underline-offset-4 !shadow-none hover:!text-text-primary'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (mobile + design system): this is the only email signup path on the horizon arm, and between ButtonSize.Small, !h-auto, !py-0 and a text-sized label the hit area ends up well under the ~44px minimum on a phone. Undersized tap targets in onboarding are a recurring source of post-merge mobile fixes here. Keeping the visual treatment but restoring vertical padding (or wrapping in a larger padded row) would keep both.

Separately, nine !important overrides fighting the Button variant's own --button-background is the kind of compensating CSS reviewers here push back on. ButtonVariant.Tertiary/Option plus a typography class, or a plain link element for a link-shaped affordance, would express "this is a text link" without overriding the primitive. Not blocking, but worth a second look before this scales to other walls.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed both halves, and you were right that they were the same problem: the !important stack existed because Button was the wrong primitive for a link.

It is a plain <button> now — same data-funnel-track, same disabled handling, same click path, zero overrides:

<button
  className={classNames(
    getEmailButtonClass(),
    "mx-auto flex min-h-12 items-center justify-center px-3 text-text-tertiary underline underline-offset-4 typo-callout transition-colors hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
  )}
  ...
>

min-h-12 gives a 48px row under a 20px label, so the target clears 44px while the affordance stays a link. To keep the height the design was signed off at, the row's own padding now supplies the spacing that mb-4 + mt-4 used to: getEmailButtonClass() drops the margin for this treatment and the login prompt moves to mt-1 / laptop:mt-2. Net change to the rail is ~4px.

Verified in Storybook at 1440×900, 390×844 and 375×667: measured height 48px, background-color: rgba(0,0,0,0) at rest and on hover, box-shadow: none, colour moving #a8b3ce#fff on hover. The compact phone still fits without scrolling.

/** "Sign up with…" / "Create account" instead of the door-agnostic
* "Continue with…". Separate from the geometry above: a wall can take the
* split layout and keep "Continue", which logs returning users straight in. */
createAccountCopy?: boolean;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (API shape): splitting the overloaded boolean was the right call, but the result is three independent booleans on a shared auth surface whose valid combinations are implicit and already interdependent downstream (splitSignupStyle && !singlePrimaryStyle, singlePrimaryStyle && index > 0). With two walls today that's manageable; a third will make the matrix hard to reason about, and nothing stops a caller passing singlePrimaryStyle without splitSignupStyle.

A single discriminated signupStyle?: 'split' | 'splitCreateAccount' | 'singlePrimary' (or passing the wall name straight through) would make the legal combinations unrepresentable and keeps the copy/geometry/hierarchy mapping in one place. Fine as a follow-up if the experiment wins.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done rather than deferred — the surface was small enough (4 files) that it was cheaper to fix now than to leave a trap for the third wall.

packages/shared/src/components/auth/common.tsx now exports:

export type SignupStyle = "split" | "splitCreateAccount" | "singlePrimary";

Every value implies the split geometry and then picks copy and hierarchy on top of it, which is exactly the dependency that was implicit before. splitSignupStyle / createAccountCopy / singlePrimaryStyle are gone from common.tsx, AuthOptionsInner, OnboardingRegistrationForm and FunnelHeroLanding; the form derives three read-only views (isSplitLayout, isCreateAccountCopy, isSinglePrimary) from the one discriminant, so singlePrimaryStyle without splitSignupStyle is no longer expressible.

The step picks the name once:

if (isHorizonWall) return "singlePrimary";
if (isPanelWall) return "splitCreateAccount";
return undefined;

Panel and cards verified byte-identical in behaviour after the swap — panel still renders "Sign up with…", GitHub-first, both providers solid, bordered "Create account", or divider; cards still renders "Continue with…", bordered email button, or divider. Two specs assert the mapping so a future wall cannot silently repoint it.

<img
alt=""
className="absolute inset-0 size-full origin-[30%_78%] scale-[1.32] object-cover object-[73%_42%]"
data-testid="horizon-art"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (duplication): the same <img> block — src, decoding, the @ts-expect-error/fetchpriority dance — is inlined twice in this branch, and the file is now 618 lines with the whole variant living inside one if. signupHero/LandingHeroCover.tsx already exists for exactly this job: one artwork component with variant/focus/zoom props, consumed by both the stacked band and the split column. A small HorizonArt (or a new LandingHeroCover variant, since the crop/scale values are the only real difference) would put the two crops next to each other and keep the hero readable.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted. New signupHero/HorizonArt.tsx, one <img>, two crops named side by side:

const VARIANT_CLASS: Record<HorizonArtVariant, string> = {
  band: "origin-[30%_78%] scale-[1.32] object-cover object-[73%_42%]",
  column: "object-cover object-[70%_52%]",
};

Callers are now <HorizonArt variant="band" /> and <HorizonArt variant="column" />, and OnboardingSignupHero drops from 618 to 600 lines with the src / decoding / @ts-expect-error dance appearing once.

I went with a small dedicated component rather than a LandingHeroCover variant, because the two would share only the <img> tag: LandingHeroCover is built around a different asset (signupWallCover), and its whole reason for existing — the panel frame, the ambilight sibling, the concentric radii — is exactly what the horizon deliberately has none of. Folding "full-bleed, no frame, no overlays" in as a third variant would have meant a variant prop where one value opts out of most of the component. Happy to merge them if you would rather have one artwork component; it is a small change either way.

// asset. To size it responsively the way cloudinaryOnboardingHeroDesk does,
// the sized derivatives have to be exported and signed upstream first, then
// consumed here as a srcSet.
export const signupWallHorizon =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (perf), and the follow-up in the description is the right one: a 2988×854 asset at fetchpriority="high", no srcSet, on the funnel's first screen means phones pull the full-width file to fill a 48dvh band. On a cold cache and a slow connection that competes with the auth options for bandwidth on exactly the screen the experiment is measured on — and it lands on the treatment arm only, so it becomes a confound rather than just a perf nit.

Since the signed URL can't be resized client-side, could the sized derivatives be exported and signed upstream before the flag is ramped past a small percentage? Worth also confirming that the homepage-hero cache hit you're relying on actually applies here (same signed URL, same query string) rather than assuming it.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points confirmed rather than assumed, and one is now mitigated in code.

Resize: re-verified against the live URL just now — s--NCILTqRq--/f_auto,q_auto/… returns 200, the same URL with w_768 inserted returns 404. The signature covers the transformation, so there is no client-side path to a smaller file. Exporting and signing the derivatives upstream is the fix, and the description now lists it as the prerequisite for ramping past a small percentage rather than as one follow-up among four.

Cache hit: I checked instead of asserting. The string is byte-identical to what main-site's HomepageExperiment.astro both preloads and renders (same signature, same query), and Chrome partitions the HTTP cache by top-level site, which daily.dev and app.daily.dev share — so the hit is real for visitors arriving from that page. But the homepage variant using this artwork is itself an experiment, so most of this wall's traffic will still pay for the download. That caveat is now in the comment in image.ts; the PR no longer leans on the cache as mitigation.

What actually changed: the stacked band now requests at fetchpriority="low", the desktop column keeps high. The band is scenery behind a bottom-anchored form, not the LCP element — on a cold mobile connection that bandwidth belongs to the auth options, which are the thing the experiment is measured on. Verified in the DOM: band → low, column → high, both still decoding and painting. It does not make the file smaller, but it stops the treatment arm racing its own CTAs for bandwidth while the derivatives are outstanding.

tsahimatsliah and others added 2 commits August 20, 2026 11:31
…g CSS

Review follow-ups on the horizon signup wall.

Enrollment was scoped to "is this the onboarding funnel", but the step
returns null a few lines later for users who are already authenticated
and confirmed, or who re-enter with onboarding complete. Evaluating the
flag is what fires GrowthBook's trackingCallback, so those visits were
allocating without ever seeing a wall — sitting in the denominator of
both arms on a signup-completion metric with no way to convert.
shouldEvaluate now mirrors the render predicate, and a new spec pins
each of the ways a visit can reach this step without seeing a wall.

The flag hold drops from 1000ms to 200ms and is armed only for visits
that evaluate. isAuthReady and GrowthBook's ready come out of the same
boot payload one commit apart, so the deadline was never a budget — but
ready never flips when boot returns no experiment features, and at a
second that blanked the funnel's entry screen for the control arm too.

The email CTA is a plain button styled as a link rather than a Button
with nine !importants unpicking the variant's box, shadow and hover
fill. Its row is 48px, so the tap target clears the 44px minimum while
the label stays a 20px link. Behaviour is unchanged: same tracking
hook, same disabled state, same hover-to-full-strength.

splitSignupStyle/createAccountCopy/singlePrimaryStyle collapse into one
signupStyle discriminant, so the auth options cannot be handed a
combination the layout doesn't support. The duplicated artwork markup
moves into HorizonArt, which also drops the stacked band to
fetchpriority=low: it is scenery behind a bottom-anchored form, and on
a cold mobile connection that bandwidth belongs to the auth options.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tsahimatsliah

Copy link
Copy Markdown
Member Author

Review follow-ups — all six addressed

One commit: 55cee74. All six comments are resolved in code rather than deferred, including the four marked non-blocking — the API-shape one in particular was cheap now and would only get more expensive with a third wall.

# Comment Status What changed
1 Blocking — enrollment fires for visits that never see a wall Fixed shouldEvaluate mirrors the render predicate
2 Question — the 1s hold blanks the entry screen for both arms Answered + fixed 200 ms, armed only when enrolling
3 Email CTA tap target and the !important stack Fixed Plain button, 48px row, zero overrides
4 Three interdependent booleans on a shared auth surface Fixed One signupStyle discriminant
5 Duplicated artwork markup Fixed New HorizonArt component
6 Unsized hero at fetchpriority="high" Mitigated + verified Band demoted to low; both claims checked

The blocking one

shouldEvaluate was isAuthReady && isOnboarding, but the step returns null a few lines later for users who are already authenticated and confirmed, or re-entering with onboarding complete. Since evaluating is what fires trackingCallback, those visits were allocating without seeing a wall — the denominator problem you described. It now mirrors the render:

const isSkippingWall =
  (isLoggedIn && user?.infoConfirmed) ||
  isOnboardingComplete ||
  (isLoggedIn && !isOnboardingActionsReady);

const shouldEvaluateWallFlag = isAuthReady && isOnboarding && !isSkippingWall;

The third clause is the same argument applied one step further: a signed-in user's isOnboardingComplete is false until their actions land, so without it we would enroll during the fetch and then transition them past the step. Anonymous visitors have nothing to fetch and are never held.

On the hold

You were right to push on it. isAuthReady and GrowthBook's ready come out of the same boot payload one commit apart — BootProvider sets initialLoad and writes exp together, and GrowthBookProvider's effect calls setReady on the next commit — so 1000 ms was never a budget for anything. It is 200 ms now, armed only when the visit is being enrolled (which also fixes the paid-funnel timer nit).

I kept a hold rather than following swipe_onboarding exactly, because what flashes here is a full-screen background swap plus a wasted hero download rather than a widget. But that is a reason to wait, not a reason to wait long, and the pathological case you found now costs a blink instead of a blank second for the control arm.

New coverage

FunnelHeroLanding.spec.tsx — 10 specs, all green — asserts shouldEvaluate for anonymous wall viewer (true), authenticated + confirmed (false, no wall), onboarding complete (false), signed-in with actions loading (false), auth not ready (false), paid funnel (false); the hold releasing to the served background after the deadline; a non-enrolled visit never being held; and the background → signupStyle mapping in both directions.

Suite: 211 passing across onboarding + auth. lint_shared and prettier clean.

Verified, not assumed

  • w_768 on the signed hero URL returns 404; the base URL returns 200. Resizing genuinely is blocked upstream.
  • The URL is byte-identical to what main-site's HomepageExperiment.astro preloads, and Chrome partitions the HTTP cache by top-level site (which daily.dev and app.daily.dev share) — so the warm-cache hit is real, but only for traffic arriving from a homepage variant that is itself an experiment. That caveat is now in image.ts; the PR no longer leans on it.
  • Email link measured at 48px with a transparent background and no shadow at rest and on hover.
  • Cards and Panel re-checked in Storybook after the discriminant refactor: same copy, variants, divider, borders, icon sizes.

One thing I did not touch

LandingHeroCover.tsx passes fetchPriority in camelCase, which React 18 warns about on every render (React does not recognize the fetchPriority prop…). It is pre-existing on main and on the panel path, not this branch — flagging it rather than widening the diff into the previously-tested arm.

Ready for another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant