From 68105e3094c16e00750b239af660f481b573b6f2 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 17:14:37 -0700 Subject: [PATCH 1/2] feat(auth): redesign the signup page as a split-screen brand moment /register was a bare centered form on ivory, shared with /login, with zero brand presence -- the product's actual front door (no marketing route exists) looked identical to every other unauthenticated screen. Adds: - A split-screen layout (form left, brand panel right, stacking below 768px) via a new `split` variant on the existing AuthLayout -- /login is untouched, still the plain centered column. - SignupBrandPanel: a deliberate one-time reversal of the app's normal ink-on-paper (uses --color-text/--color-surface directly, so it still reads as a reversal in dark mode rather than flattening into it), the serif "Trueup" wordmark on its first public-page appearance, real grounded copy (four model portfolios, monthly rebalancing) instead of generic fintech copy, and the genuine three-step sequence -- create account, verify identity, link bank -- numbered because it actually is one, with the current step marked via the system's sanctioned "current selection" use of the accent color. - A password visibility toggle, via a new optional `labelExtra` slot on the shared Input component (purely additive -- every other caller is unaffected, verified by screenshot). - A segmented password-strength meter driven by the exact same describePassword() the checklist already used -- no new fabricated scale -- as the page's one deliberate interactive/animated moment, respecting the app's existing global reduced-motion rule. - A real fix for the duplicate-email case: the backend now raises ValidationError(..., code="email_taken") instead of the shared validation_failed code, so the form can show "This account already exists -- log in instead." with a working link, instead of the generic "check your details" message the dead-code fallback string was silently never reaching. Deliberately not added: a Terms-of-Service checkbox (no such page/legal content exists yet in the app -- would mean inventing legal copy) and any specific regulatory disclosure claims (kept the one trust line to something true by construction). Verified: backend 24/24 auth tests pass (including the updated duplicate-email assertion); npm run lint and build clean; full manual pass in the browser -- real registration through the running local backend, the duplicate-email path end to end with the new message and link, password toggle and strength meter both interactive, light and dark themes (dark mode's inversion checked explicitly, panel correctly flips light-on-dark), mobile width (375px, panel correctly hidden, single column), and a regression screenshot confirming /login (which doesn't use the new labelExtra prop) is pixel-identical to before. Co-Authored-By: Claude Opus 5 --- backend/app/controllers/api/auth.py | 4 +- backend/tests/api/test_auth.py | 4 +- frontend/src/components/Icon/paths.js | 9 ++ frontend/src/components/Input/Input.css | 7 ++ frontend/src/components/Input/Input.jsx | 17 ++- .../src/features/auth/components/AuthForm.css | 46 ++++++++ .../features/auth/components/RegisterForm.jsx | 57 +++++++++- .../auth/components/SignupBrandPanel.css | 107 ++++++++++++++++++ .../auth/components/SignupBrandPanel.jsx | 52 +++++++++ frontend/src/pages/AuthLayout.css | 35 ++++++ frontend/src/pages/AuthLayout.jsx | 15 ++- frontend/src/pages/RegisterPage.jsx | 3 +- frontend/src/utils/apiErrorMessage.js | 1 + 13 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 frontend/src/features/auth/components/SignupBrandPanel.css create mode 100644 frontend/src/features/auth/components/SignupBrandPanel.jsx diff --git a/backend/app/controllers/api/auth.py b/backend/app/controllers/api/auth.py index 962d9d8..581b9d9 100644 --- a/backend/app/controllers/api/auth.py +++ b/backend/app/controllers/api/auth.py @@ -116,7 +116,9 @@ def register() -> Any: with IdentityUnitOfWork(customer_id=None, role=SessionRole.ADMIN) as uow: existing = uow.customers.get_by_email(data.email) if existing is not None: - raise ValidationError("Email already registered") + # Distinct code (not the shared `validation_failed`) so the client can offer a real + # next step -- "log in instead" -- rather than the generic "check your details". + raise ValidationError("Email already registered", code="email_taken") customer = Customer(email=data.email, password_hash=hash_password(data.password)) uow.customers.add(customer) diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index 0face53..46df9a2 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -87,7 +87,9 @@ def test_register_rejects_a_duplicate_email(api_client: FlaskClient) -> None: second = _register(api_client) assert second.status_code == 422 - assert second.get_json()["code"] == "validation_failed" + # Distinct code -- not the shared `validation_failed` -- so the client can offer "log in + # instead" rather than a generic "check your details" message. + assert second.get_json()["code"] == "email_taken" @pytest.mark.parametrize( diff --git a/frontend/src/components/Icon/paths.js b/frontend/src/components/Icon/paths.js index 50327f9..7721dc3 100644 --- a/frontend/src/components/Icon/paths.js +++ b/frontend/src/components/Icon/paths.js @@ -27,4 +27,13 @@ export const ICON_PATHS = { 'M3.8 13v-2.8h2.8', ], menu: ['M2.5 4.5h11', 'M2.5 8h11', 'M2.5 11.5h11'], + eye: [ + 'M2 8c1.6-3.2 4-4.8 6-4.8s4.4 1.6 6 4.8c-1.6 3.2-4 4.8-6 4.8s-4.4-1.6-6-4.8z', + 'M8 8m-1.8 0a1.8 1.8 0 1 0 3.6 0a1.8 1.8 0 1 0 -3.6 0', + ], + 'eye-off': [ + 'M2 8c1.6-3.2 4-4.8 6-4.8s4.4 1.6 6 4.8c-1.6 3.2-4 4.8-6 4.8s-4.4-1.6-6-4.8z', + 'M8 8m-1.8 0a1.8 1.8 0 1 0 3.6 0a1.8 1.8 0 1 0 -3.6 0', + 'M3 3 13 13', + ], } diff --git a/frontend/src/components/Input/Input.css b/frontend/src/components/Input/Input.css index b305f3a..d5a3a56 100644 --- a/frontend/src/components/Input/Input.css +++ b/frontend/src/components/Input/Input.css @@ -4,6 +4,13 @@ gap: var(--space-1); } +.tu-field__label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + .tu-field__label { font-size: var(--font-size-body); font-weight: var(--font-weight-body-medium); diff --git a/frontend/src/components/Input/Input.jsx b/frontend/src/components/Input/Input.jsx index 63bb06f..c9fd85b 100644 --- a/frontend/src/components/Input/Input.jsx +++ b/frontend/src/components/Input/Input.jsx @@ -1,17 +1,24 @@ import { useId } from 'react' import './Input.css' -/** Design system §7.2. Label always visible above the field — never placeholder-as-label. */ -export function Input({ label, id, error, hint, className = '', ...rest }) { +/** + * Design system §7.2. Label always visible above the field — never placeholder-as-label. + * `labelExtra` is an optional node rendered at the label row's trailing edge (e.g. a + * show/hide-password toggle) — additive and unused by every existing caller. + */ +export function Input({ label, id, error, hint, labelExtra, className = '', ...rest }) { const generatedId = useId() const fieldId = id || generatedId const messageId = error || hint ? `${fieldId}-message` : undefined return (
- +
+ + {labelExtra} +
0 ? 'building' : 'empty' // Input's own aria-describedby (wired to its inline error message, id `${id}-message`) would be // clobbered by ours below since {...rest} is spread after it — so reconstruct both ids here // rather than losing the error announcement while the strength checklist is attached. @@ -100,8 +109,8 @@ export function RegisterForm() { } } - const serverErrorMessage = - status === 'error' ? getErrorMessage(error, 'Registration failed — this email may already be registered.') : null + const serverErrorMessage = status === 'error' ? getErrorMessage(error) : null + const isEmailTaken = status === 'error' && error?.code === 'email_taken' return (
@@ -121,7 +130,7 @@ export function RegisterForm() {
setShowPassword((value) => !value)} + controls={PASSWORD_ID} + /> + } required /> +
  • @@ -147,7 +168,7 @@ export function RegisterForm() {
setShowConfirmPassword((value) => !value)} + controls={CONFIRM_PASSWORD_ID} + /> + } required /> {serverErrorMessage && (

{serverErrorMessage} + {isEmailTaken && ( + <> + {' '} + Log in instead. + + )}

)} + ) +} + function RuleIcon({ met }) { return (
+
+

Trueup

+

+ Invest in one of four model portfolios. We rebalance automatically, every month, so you + don't have to. +

+
    + {STEPS.map((step, index) => ( +
  1. + +
    +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
+

Your data is encrypted in transit and at rest.

+
+
+ ) +} diff --git a/frontend/src/pages/AuthLayout.css b/frontend/src/pages/AuthLayout.css index 52e38a0..afe7f27 100644 --- a/frontend/src/pages/AuthLayout.css +++ b/frontend/src/pages/AuthLayout.css @@ -6,3 +6,38 @@ padding: var(--space-6); background: var(--color-paper); } + +/* Full properties re-declared (not relying on cascade to unset the base rule's flex/padding) so + this reads correctly regardless of source order. */ +.tu-auth-layout--split { + display: grid; + grid-template-columns: 1fr; + min-height: 100vh; + padding: 0; + align-items: stretch; + justify-content: stretch; +} + +.tu-auth-layout--split .tu-auth-layout__form { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); + background: var(--color-paper); + min-height: 100vh; +} + +.tu-auth-layout--split .tu-auth-layout__panel { + display: none; +} + +@media (min-width: 768px) { + .tu-auth-layout--split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .tu-auth-layout--split .tu-auth-layout__panel { + display: block; + min-height: 100vh; + } +} diff --git a/frontend/src/pages/AuthLayout.jsx b/frontend/src/pages/AuthLayout.jsx index 61f0be4..2d1e161 100644 --- a/frontend/src/pages/AuthLayout.jsx +++ b/frontend/src/pages/AuthLayout.jsx @@ -1,5 +1,18 @@ import './AuthLayout.css' -export function AuthLayout({ children }) { +/** + * `variant="split"` + `panel` renders a two-column layout (form left, brand panel right, + * stacking to one column below 768px) for the one public page that carries brand/marketing + * content (signup). Default stays the plain centered column every other auth screen uses. + */ +export function AuthLayout({ children, variant = 'centered', panel = null }) { + if (variant === 'split') { + return ( +
+
{children}
+
{panel}
+
+ ) + } return
{children}
} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 3839507..a08e6ab 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,9 +1,10 @@ import { RegisterForm } from '../features/auth/components/RegisterForm.jsx' +import { SignupBrandPanel } from '../features/auth/components/SignupBrandPanel.jsx' import { AuthLayout } from './AuthLayout.jsx' export function RegisterPage() { return ( - + }> ) diff --git a/frontend/src/utils/apiErrorMessage.js b/frontend/src/utils/apiErrorMessage.js index 7923ab4..b2ca79d 100644 --- a/frontend/src/utils/apiErrorMessage.js +++ b/frontend/src/utils/apiErrorMessage.js @@ -2,6 +2,7 @@ const MESSAGES_BY_CODE = { unauthenticated: 'Incorrect email or password.', validation_failed: 'Check your details and try again.', + email_taken: 'An account with this email already exists.', forbidden: "You don't have access to do that.", not_found: 'Not found.', conflict: 'That request has already been submitted.', From dbbbec49d06b6c07df84e03357b1fdafd61a716a Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 17:32:49 -0700 Subject: [PATCH 2/2] feat(onboarding): redesign as a visible two-step sequence The onboarding page swapped its entire contents based on KYC status -- you only ever saw one step at a time, with no sense of the whole journey, and two real functional gaps came with that structure: - A rejected KYC attempt was indistinguishable from never having started. The backend has a real `rejected` status; the frontend's `isKycApproved ? BankLinkSection : KycStep` branch treated it identically to `pending`, showing the same first-time prompt with no explanation of what happened. - Once both steps were done, the page just sat there -- no next action toward the thing onboarding was actually for (choosing a portfolio). Adds OnboardingSteps/StepRow (features/onboarding/components/): both steps render together now, connected by a rail, using the exact numbered-circle idiom already established on the signup page's brand panel (SignupBrandPanel.jsx) -- the same real sequence, continued, not a new decorative device. A step's marker is a numbered circle (current), a muted outline (locked), or a checkmark (completed) -- color is never the only signal. KycStep now takes the customer's real stored `kycStatus` (not just its own local in-progress-attempt state) and shows a distinct rejection message with a "Try again" label instead of the generic first-time prompt. BankLinkStep is locked (no interactive content, no wasted Plaid link-token fetch) until KYC clears. Once both are done, a line below the card offers the real next step: "View portfolio options", linking straight to /portfolio. BankLinkStep's heading is now conditional (`showTitle`, default true) -- OnboardingPage suppresses it since StepRow supplies the title, but FundingPage's own usage (a bare Card with no title of its own) needed it kept, which a first pass at this missed and would have shipped title-less. Caught and fixed via a direct screenshot comparison before committing, not asserted from reading the diff. Verified against the real running app, not the diff: all four reachable states (never started, rejected, KYC-done/bank-pending, both complete) exercised via direct DB manipulation of a real registered test account -- including generating a validly-encrypted placeholder Plaid token through the app's own cipher rather than a raw byte string, since the encrypted column round-trips through decryption even when the field is never serialized. Confirmed light and dark themes (dark mode's completion button correctly inverts), 375px mobile, and the completion link's real navigation to /portfolio. npm run lint and build clean. Co-Authored-By: Claude Opus 5 --- .../onboarding/components/BankLinkStep.jsx | 17 ++-- .../onboarding/components/KycStep.jsx | 22 +++-- .../onboarding/components/OnboardingStep.css | 7 -- .../onboarding/components/OnboardingSteps.css | 87 +++++++++++++++++++ .../onboarding/components/OnboardingSteps.jsx | 50 +++++++++++ frontend/src/pages/OnboardingPage.css | 20 +++++ frontend/src/pages/OnboardingPage.jsx | 65 +++++++++----- 7 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 frontend/src/features/onboarding/components/OnboardingSteps.css create mode 100644 frontend/src/features/onboarding/components/OnboardingSteps.jsx diff --git a/frontend/src/features/onboarding/components/BankLinkStep.jsx b/frontend/src/features/onboarding/components/BankLinkStep.jsx index d30985c..8175869 100644 --- a/frontend/src/features/onboarding/components/BankLinkStep.jsx +++ b/frontend/src/features/onboarding/components/BankLinkStep.jsx @@ -4,7 +4,12 @@ import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { useBankLink } from '../hooks/useBankLink.js' import './OnboardingStep.css' -export function BankLinkStep({ customerId, onCompleted }) { +/** + * `showTitle` defaults to true so FundingPage's existing usage (which relies on this component + * for its own heading, inside a bare Card with no title of its own) is unchanged. OnboardingPage + * passes `showTitle={false}` since its StepRow supplies the title instead. + */ +export function BankLinkStep({ customerId, onCompleted, showTitle = true }) { const { tokenStatus, status, error, ready, open, retryLinkToken } = useBankLink(customerId, { onLinked: onCompleted, }) @@ -15,20 +20,20 @@ export function BankLinkStep({ customerId, onCompleted }) { if (tokenStatus === 'error') { return ( -
+ <>

Couldn't start bank linking.

-
+ ) } return ( -
-

Link your bank

+ <> + {showTitle &&

Link your bank

}

Connect a bank account to fund your investments.

{status === 'error' && (

@@ -42,6 +47,6 @@ export function BankLinkStep({ customerId, onCompleted }) { Link bank account )} -

+ ) } diff --git a/frontend/src/features/onboarding/components/KycStep.jsx b/frontend/src/features/onboarding/components/KycStep.jsx index 8bd5dda..465441a 100644 --- a/frontend/src/features/onboarding/components/KycStep.jsx +++ b/frontend/src/features/onboarding/components/KycStep.jsx @@ -12,13 +12,25 @@ function resolveErrorMessage(error) { return error.message || 'Verification failed. Please try again.' } -export function KycStep({ customerId, onCompleted }) { +/** + * `kycStatus` is the customer's *stored* status (from useIdentityStatus) -- distinct from + * `status`, this hook's own local in-progress-attempt state. A `rejected` stored status must + * never look identical to "hasn't tried yet": a customer who was turned away deserves to be told + * that, not invited to click the same first-time prompt with no memory of what happened. + */ +export function KycStep({ customerId, kycStatus, onCompleted }) { const { status, error, startVerification } = useKycSession() const isBusy = BUSY_STATUSES.has(status) + const wasRejected = kycStatus === 'rejected' && status === 'idle' return ( -
-

Verify your identity

+ <> + {wasRejected && ( +

+ Your identity verification wasn't approved. Try again with a clear photo of a + valid, unexpired government-issued ID. +

+ )}

We use Stripe Identity to confirm who you are — you'll need a government-issued photo ID.

@@ -41,9 +53,9 @@ export function KycStep({ customerId, onCompleted }) { loading={isBusy} disabled={isBusy} > - Start identity verification + {wasRejected ? 'Try again' : 'Start identity verification'} )} -
+ ) } diff --git a/frontend/src/features/onboarding/components/OnboardingStep.css b/frontend/src/features/onboarding/components/OnboardingStep.css index 563736d..4bd2bc2 100644 --- a/frontend/src/features/onboarding/components/OnboardingStep.css +++ b/frontend/src/features/onboarding/components/OnboardingStep.css @@ -1,10 +1,3 @@ -.tu-onboarding-step { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--space-3); -} - .tu-onboarding-step__title { font-size: var(--font-size-heading-sm); line-height: var(--line-height-heading-sm); diff --git a/frontend/src/features/onboarding/components/OnboardingSteps.css b/frontend/src/features/onboarding/components/OnboardingSteps.css new file mode 100644 index 0000000..83e2e76 --- /dev/null +++ b/frontend/src/features/onboarding/components/OnboardingSteps.css @@ -0,0 +1,87 @@ +.tu-onboarding-steps { + display: flex; + flex-direction: column; +} + +/* Padding-bottom (not a parent `gap`) carries the vertical rhythm between rows, so the connector + line below can span exactly that distance and read as one continuous rail rather than two + disconnected segments. */ +.tu-step-row { + display: grid; + grid-template-columns: 24px 1fr; + column-gap: var(--space-3); + padding-bottom: var(--space-6); +} + +.tu-step-row--last { + padding-bottom: 0; +} + +.tu-step-row__rail { + position: relative; + display: flex; + justify-content: center; +} + +.tu-step-row__rail::after { + content: ''; + position: absolute; + top: 24px; + bottom: 0; + width: 1px; + background: var(--color-hairline); +} + +.tu-step-row--last .tu-step-row__rail::after { + display: none; +} + +.tu-step-row__body { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: 1px; +} + +.tu-step-row__title { + font-size: var(--font-size-heading-sm); + line-height: var(--line-height-heading-sm); + font-weight: var(--font-weight-heading-sm); + color: var(--color-text); + margin: 0; +} + +.tu-step-row--locked .tu-step-row__title { + color: var(--color-text-muted); +} + +.tu-step-marker { + flex: none; + width: 24px; + height: 24px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-data-strong); +} + +.tu-step-marker--current { + /* Accent fill for "current step" -- design-system.md §2.4's sanctioned second use beyond + interactive elements. Ink numeral on accent, not white: matches the pairing already proven + on SignupBrandPanel's own current-step marker, with better measured contrast. */ + border: 1px solid var(--color-accent); + background: var(--color-accent); + color: var(--color-text); +} + +.tu-step-marker--locked { + border: 1px solid var(--color-hairline); + background: var(--color-surface); + color: var(--color-text-muted); +} + +.tu-step-marker--completed { + color: var(--color-success); +} diff --git a/frontend/src/features/onboarding/components/OnboardingSteps.jsx b/frontend/src/features/onboarding/components/OnboardingSteps.jsx new file mode 100644 index 0000000..2c7de4e --- /dev/null +++ b/frontend/src/features/onboarding/components/OnboardingSteps.jsx @@ -0,0 +1,50 @@ +import { Card } from '../../../components/Card' +import { Icon } from '../../../components/Icon' +import './OnboardingSteps.css' + +/** + * The onboarding journey's shell: one Card holding an ordered set of `StepRow`s connected by a + * rail, so the whole sequence is visible at once instead of hiding every step but the current + * one. Reuses the same numbered-circle idiom already established on the signup page's brand + * panel (SignupBrandPanel.jsx) -- this is that same real sequence, continued, not a new device. + */ +export function OnboardingSteps({ children }) { + return {children} +} + +/** + * One step in the sequence. `status` drives both the marker and the row's own emphasis: + * - `current` -- the step the customer can act on right now (accent-filled numbered circle). + * - `locked` -- not reachable yet (muted outline, no interactive content underneath). + * - `completed` -- done (a checkmark, never a bare colored dot -- design-system §2.4's own + * never-color-alone rule). + * `last` suppresses the connector rail below the marker for the final step. + */ +export function StepRow({ number, status, title, last = false, children }) { + return ( +
+
+ +
+
+

{title}

+ {children} +
+
+ ) +} + +function StepMarker({ number, status }) { + if (status === 'completed') { + return ( + + + + ) + } + return ( + + ) +} diff --git a/frontend/src/pages/OnboardingPage.css b/frontend/src/pages/OnboardingPage.css index a47d865..83e548d 100644 --- a/frontend/src/pages/OnboardingPage.css +++ b/frontend/src/pages/OnboardingPage.css @@ -20,3 +20,23 @@ font-weight: var(--font-weight-heading-lg); color: var(--color-text); } + +.tu-onboarding-page__complete { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-4); +} + +.tu-onboarding-page__complete-text { + font-size: var(--font-size-body); + color: var(--color-text-secondary); + margin: 0; +} + +/* .tu-button is a native-