feat(onboarding): horizon signup wall — flag-gated full-bleed hero variant - #6491
feat(onboarding): horizon signup wall — flag-gated full-bleed hero variant#6491tsahimatsliah wants to merge 14 commits into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
… 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
left a comment
There was a problem hiding this comment.
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
!importantstack (OnboardingRegistrationForm.tsx:208) - Three interdependent style booleans on a shared auth component (
common.tsx:139) - Duplicated inline artwork markup vs. the existing
LandingHeroCovercomponent (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.mdread; scope matches the stated task - Flag/enrollment path traced through
useConditionalFeature→FeaturesReadyContext→trackingCallback→ allocation POST - Control walls and
heroStylescompact-phone rules checked for inherited-compensation loss (horizon correctly reusesonb-hero-main/onb-split-cta/onb-split-login) - CI inspected: all checks passing; branch is behind
mainand 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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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:
- In practice, does
readyever resolve later thanisAuthReady? 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. swipe_onboardingis cited in the description as the identical precedent, butpackages/webapp/pages/onboarding.tsxdeliberately ignoresisLoadingand 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 thannull) 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.
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
Review follow-ups — all six addressedOne 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.
The blocking one
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 On the holdYou were right to push on it. I kept a hold rather than following New coverage
Suite: 211 passing across onboarding + auth. Verified, not assumed
One thing I did not touch
Ready for another look. |
What
A new signup-wall variant — horizon — behind the
signup_wall_horizonflag. This is the next iteration afterpanellost its test against thecardscontrol.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 theheroLandingstep, reusing the panel's split-column machinerysublinefunnel parameter for the value line, defaulted fromHORIZON_DEFAULT_SUBLINEsignup_wall_horizonflag (defaultfalse)signupWallHorizonimage constant — the homepage hero webp, typically already in the visitor's cacheHorizonstory plus five comparison views (see below)Auth CTA hierarchy
splitSignupStylehad 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:signupStylesplitonb-split-cta/onb-split-logincompact-phone hooksordividersplitCreateAccountsinglePrimarycardsanddeskpass nothing and are untouched.Horizon's stack: Google solid primary (broadest one-click path), GitHub secondary — filled
bg-surface-floatwith a hairline, full-strength label, and the filled octocat (the icon'ssecondaryasset, 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 infeatureManagement.ts, defaults to the control (false), and evaluated throughuseConditionalFeaturewithshouldEvaluate.useConditionalFeature→growthbook.getFeatureValue→ the provider'strackingCallback→ allocation POST (experiment_id/variation_id), the same chain asswipe_onboarding.shouldEvaluatemirrors the step's own render predicate —isAuthReady && isOnboardingFunneland none of the bail-outs that returnnull: 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 byFunnelHeroLanding.spec.tsx.isAuthReadyand GrowthBook'sreadycome out of the same boot payload one commit apart (BootProvidersetsinitialLoadand writesexptogether;GrowthBookProvider's effect callssetReadyon the next commit), so the deadline is a safety net, not a budget. It is kept short deliberately:readynever 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.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 step view,transition funnel,complete funnel,leave funnel) are step-level and background-agnostic — unchanged.data-funnel-track="signup provider"in the sharedOnboardingRegistrationForm, soclick funnel elementfires with identical target ids across arms.Review fixes included
Self-reviewed at high effort; these are the confirmed findings that landed:
subline/oauthOrderderive from the resolved backgroundisLoadingwas discarded → control-wall flash; now held, and boundedheadlineas raw text while other walls pipe funnel copy throughsanitizeMessage— HTML copy would have printed literal tags on this arm onlyHeroBackgroundLayerfell through to the cards mosaic forhorizon; now returns null explicitlyKnown follow-ups (deliberately not in this PR)
w_768404s rather than resizing (re-verified against the live URL). Sized derivatives must be exported and signed upstream before asrcSetcan exist. Until then the stacked band requests atfetchpriority="low"so it does not race the auth options for bandwidth on a cold mobile connection; the desktop column, which is half the screen, keepshigh. Documented at the constant.HorizonArt.)sublineis a silent no-op on the other walls. Rendering it everywhere is a copy/design decision beyond this PR.Test plan
Components/Onboarding/Steps/FunnelHeroLanding → Horizon, andSignup wall comparison:Desktop,Mobile,MobileSmall,Tablet,DesktopControlVsHorizon,MobileControlVsHorizon,MobileCompact(375×667, where the band steps down),DesktopHorizonOnly,MobileHorizonOnlybackground-color: rgba(0,0,0,0)andbox-shadow: nonewhile moving the label to full strengthlint_shared,test_shared(211 passing across onboarding + auth, including the 10 new enrollment/hold specs),typecheck_strict_changedall run locally with the CI commands before pushingbackground: 'horizon'🤖 Generated with Claude Code
Preview domain
https://feat-signup-wall-horizon.preview.app.daily.dev