Compare commits

...
Author SHA1 Message Date
Tanish Manku 8153096a52 feat(webapp): stagger the signup form entrance for parity with login
Mirror the login page's per-element cascade on signup. SignupForm now runs
its own field cascade internally — email leads on a near-zero delay (primary
input lands immediately, typing is never gated), then password, button, the
"or" divider and the Google button follow in a ~75ms staircase. The signup
page no longer wraps the whole form in a single Reveal (which would have
double-animated against the internal cascade); the footer link picks up the
tail of the cascade via a standalone delayed Reveal.

Verified with Playwright on /signup: smooth staircase across the six steps
and typing the instant the field exists retains the full value with zero
focus loss.
2026-06-25 13:45:09 -07:00
Tanish Manku 41615dfab9 feat(webapp): stagger the login form entrance as its own beat
The login form faded in at the same time as the shell/header, so its
entrance read as one undifferentiated fade rather than a deliberate beat.
Two changes fix that:

1. Reveal: stop hardcoding `delay` in the stagger-child transition.
   framer-motion lets a child's explicit `transition.delay` override the
   parent's `staggerChildren`/`delayChildren`, so leaving it in (even as 0)
   collapsed every RevealGroup cascade — all children animated in unison.
   Now the explicit `delay` is only applied to standalone reveals; stagger
   children inherit the parent orchestration. This makes RevealGroup
   actually cascade (affects every RevealGroup in the webapp, as intended).

2. Login: split the single form Reveal into per-element children so email,
   password and the submit button cascade in sequence, with the secondary
   options (divider, Google, footer) continuing the same cascade. The email
   field leads on a near-zero delay so the primary input lands immediately —
   typing is never gated by the animation (inputs stay focusable and accept
   keystrokes mid-cascade).

Verified with Playwright: a smooth ~75ms staircase across the six elements
(email → password → button → divider → Google → footer), and typing the
instant the field exists retains the full value with zero focus loss.
2026-06-25 13:29:47 -07:00
Tanish Manku e6be45f3d4 fix(webapp): stop Reveal motion wrappers from stealing input focus
Reveal/RevealGroup called `motion(as)` inside the render body. Unlike the
cached `motion.div` accessor, calling `motion()` as a function returns a
brand-new component type on every invocation. So each re-render produced a
different element type at that position, React unmounted and remounted the
whole subtree, and any focused <input> inside (the login/signup/forgot-password
forms) lost focus on every keystroke — you could only type one character at a
time.

Cache one motion component per element type via a module-level map so the
component reference stays stable across renders. React now reconciles in place
and focus is preserved.

Verified with a Playwright A/B test on the login form: before the fix typing
"test@example.com" left only "t" with focus on <body> (1 blur mid-type); after
the fix the full value is retained, focus stays on the input, zero blurs.
2026-06-25 13:06:07 -07:00
4 changed files with 234 additions and 173 deletions
@@ -18,6 +18,7 @@ import {
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { Reveal, RevealGroup } from "../motion/reveal";
interface SignupFormProps {
onSuccess: (isNewUser?: boolean) => void;
@@ -115,74 +116,97 @@ export const SignupForm = ({
</div>
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
{/* Fields cascade in as their own beat, leading with the email field on
an almost-zero delay so the primary input lands immediately — the
entrance never gates typing (inputs stay focusable mid-cascade).
Mirrors the login page. */}
<RevealGroup
inView={false}
delayChildren={0.04}
stagger={0.08}
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email
</label>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
</Reveal>
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create account"
)}
</Button>
</div>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</Reveal>
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create account"
)}
</Button>
</Reveal>
</RevealGroup>
</form>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
{/* Secondary sign-up options continue the same cascade just after the
fields (delay continues from the form group's three children above:
0.04 + 3 × 0.08). */}
<RevealGroup
inView={false}
delayChildren={0.28}
stagger={0.08}
className="space-y-4"
>
<Reveal className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</Reveal>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<Reveal>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
</RevealGroup>
</div>
);
};
@@ -16,6 +16,23 @@ import type { ElementType, ReactNode } from "react";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { DUR_SLOW, EASE_OUT } from "../../lib/motion";
// `motion(as)` returns a BRAND-NEW component on every call. Calling it during
// render would hand each render a different component type, so React would
// unmount and remount the whole subtree on every re-render — stealing focus
// from any <input> inside (this is what broke the login form: typing a
// character re-rendered the page and blurred the field). Cache one motion
// component per element type so the reference stays stable across renders.
const motionTagCache = new Map<ElementType, ElementType>();
function motionTag(as: ElementType): ElementType {
let cached = motionTagCache.get(as);
if (!cached) {
cached = motion(as) as ElementType;
motionTagCache.set(as, cached);
}
return cached;
}
interface RevealProps {
children: ReactNode;
/** Render as a different element/component (e.g. "section", "li", Link). */
@@ -49,7 +66,7 @@ export function RevealGroup({
className?: string;
}) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const MotionTag = motionTag(as);
const variants: Variants = {
initial: {},
@@ -83,22 +100,30 @@ export function Reveal({
className,
}: RevealProps) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
animate: {
opacity: 1,
y: 0,
transition: { duration: DUR_SLOW, ease: EASE_OUT, delay },
},
};
const MotionTag = motionTag(as);
// When `inView` is left undefined the element behaves as a stagger child:
// it inherits the parent RevealGroup's orchestration and must NOT drive its
// own initial/animate props (that would override the cascade).
const standalone = inView !== undefined;
// Only a standalone reveal sets its own `delay`. For a stagger child the
// delay MUST be omitted: framer-motion lets a child's explicit transition
// `delay` override the parent's `staggerChildren`/`delayChildren`, so leaving
// it in (even as 0) collapses the cascade and every child animates at once.
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
animate: {
opacity: 1,
y: 0,
transition: {
duration: DUR_SLOW,
ease: EASE_OUT,
...(standalone ? { delay } : {}),
},
},
};
return (
<MotionTag
className={className}
@@ -72,8 +72,6 @@ const Login = () => {
/>
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -87,93 +85,108 @@ const Login = () => {
</div>
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email or Username
</label>
<Input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
{/* The form fields cascade in as their own beat, distinct from the
shell/header settling. We lead with the email field on an almost-zero
delay so the primary input lands immediately — the entrance never
gates typing: inputs stay focusable and accept keystrokes while the
password/button below are still cascading in. */}
<RevealGroup
inView={false}
delayChildren={0.04}
stagger={0.08}
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email or Username
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</Reveal>
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Log in"
)}
</Button>
</div>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Log in"
)}
</Button>
</Reveal>
</RevealGroup>
</form>
</Reveal>
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</Reveal>
{/* Secondary sign-in options pick up the same cascade just after the
fields (delay continues from the form group's three children above:
0.04 + 3 × 0.08). */}
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</Reveal>
<Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
<Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
<Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</Reveal>
<Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</>
);
@@ -1,7 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { Reveal } from "../../components/motion/reveal";
const Signup = () => {
const navigate = useNavigate();
@@ -14,26 +14,25 @@ const Signup = () => {
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
</Reveal>
{/* SignupForm runs its own field cascade internally (see signup-form.tsx).
The footer picks up the tail of that cascade — the form's six staggered
steps land by ~0.36s, so the footer follows at ~0.44s. */}
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<Reveal>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
<Reveal inView={false} delay={0.44}>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</Reveal>
</>
);
};