# Login Dialog Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add composable `LoginForm` and `LoginDialog` components to the Cameleer3 design system with credential + social login support, client-side validation, and full dark mode compatibility. **Architecture:** `LoginForm` is the core content component with all form logic, validation, and layout. `LoginDialog` is a thin wrapper that renders `LoginForm` inside `Modal size="sm"`. Both live in `src/design-system/composites/LoginForm/` and are exported from the composites barrel. **Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library **Spec:** `docs/superpowers/specs/2026-03-24-login-dialog-design.md` --- ## File Map | File | Action | Responsibility | |------|--------|----------------| | `src/design-system/composites/LoginForm/LoginForm.tsx` | Create | Core form component with validation, social providers, all layout | | `src/design-system/composites/LoginForm/LoginForm.module.css` | Create | All styles using design tokens | | `src/design-system/composites/LoginForm/LoginForm.test.tsx` | Create | 21 test cases for LoginForm | | `src/design-system/composites/LoginForm/LoginDialog.tsx` | Create | Thin Modal wrapper | | `src/design-system/composites/LoginForm/LoginDialog.test.tsx` | Create | 5 test cases for LoginDialog | | `src/design-system/composites/index.ts` | Modify | Add LoginForm, LoginDialog, and type exports | --- ### Task 1: LoginForm — Rendering Tests & Basic Structure **Files:** - Create: `src/design-system/composites/LoginForm/LoginForm.tsx` - Create: `src/design-system/composites/LoginForm/LoginForm.module.css` - Create: `src/design-system/composites/LoginForm/LoginForm.test.tsx` - [ ] **Step 1: Write rendering tests** Create `src/design-system/composites/LoginForm/LoginForm.test.tsx`: ```tsx import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import { LoginForm } from './LoginForm' const socialProviders = [ { label: 'Continue with Google', onClick: vi.fn() }, { label: 'Continue with GitHub', onClick: vi.fn() }, ] const allProps = { logo:
Logo
, title: 'Welcome back', socialProviders, onSubmit: vi.fn(), onForgotPassword: vi.fn(), onSignUp: vi.fn(), } describe('LoginForm', () => { describe('rendering', () => { it('renders all elements when all props provided', () => { render() expect(screen.getByTestId('logo')).toBeInTheDocument() expect(screen.getByText('Welcome back')).toBeInTheDocument() expect(screen.getByText('Continue with Google')).toBeInTheDocument() expect(screen.getByText('Continue with GitHub')).toBeInTheDocument() expect(screen.getByText('or')).toBeInTheDocument() expect(screen.getByLabelText(/email/i)).toBeInTheDocument() expect(screen.getByLabelText(/password/i)).toBeInTheDocument() expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument() expect(screen.getByText(/forgot password/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument() expect(screen.getByText(/sign up/i)).toBeInTheDocument() }) it('renders default title when title prop omitted', () => { render() expect(screen.getByText('Sign in')).toBeInTheDocument() }) it('hides social section when socialProviders is empty', () => { render() expect(screen.queryByText('or')).not.toBeInTheDocument() }) it('hides social section when socialProviders is omitted', () => { render() expect(screen.queryByText('or')).not.toBeInTheDocument() }) it('hides forgot password link when onForgotPassword omitted', () => { render() expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument() }) it('hides sign up link when onSignUp omitted', () => { render() expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument() }) it('hides credentials section when onSubmit omitted (social only)', () => { render() expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument() expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument() expect(screen.queryByText('or')).not.toBeInTheDocument() // Social buttons should still render expect(screen.getByText('Continue with Google')).toBeInTheDocument() }) it('shows server error Alert when error prop set', () => { render() expect(screen.getByText('Invalid credentials')).toBeInTheDocument() }) }) }) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx` Expected: FAIL — module not found - [ ] **Step 3: Create LoginForm component with basic rendering** Create `src/design-system/composites/LoginForm/LoginForm.module.css`: ```css .loginForm { display: flex; flex-direction: column; align-items: center; font-family: var(--font-body); width: 100%; } .logo { margin-bottom: 8px; } .title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0 0 20px; } .error { width: 100%; margin-bottom: 16px; } .socialSection { display: flex; flex-direction: column; gap: 8px; width: 100%; margin-bottom: 20px; } .socialButton { width: 100%; justify-content: center; } .divider { display: flex; align-items: center; gap: 12px; width: 100%; margin-bottom: 20px; } .dividerLine { flex: 1; height: 1px; background: var(--border); } .dividerText { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; } .fields { display: flex; flex-direction: column; gap: 14px; width: 100%; } .rememberRow { display: flex; justify-content: space-between; align-items: center; } .forgotLink { font-size: 11px; color: var(--amber); font-weight: 500; background: none; border: none; cursor: pointer; padding: 0; font-family: var(--font-body); } .forgotLink:hover { text-decoration: underline; } .submitButton { width: 100%; } .signUpText { text-align: center; font-size: 12px; color: var(--text-secondary); } .signUpLink { color: var(--amber); font-weight: 500; background: none; border: none; cursor: pointer; padding: 0; font-family: var(--font-body); font-size: 12px; } .signUpLink:hover { text-decoration: underline; } ``` Create `src/design-system/composites/LoginForm/LoginForm.tsx`: ```tsx import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react' import { Button } from '../../primitives/Button/Button' import { Input } from '../../primitives/Input/Input' import { Checkbox } from '../../primitives/Checkbox/Checkbox' import { FormField } from '../../primitives/FormField/FormField' import { Alert } from '../../primitives/Alert/Alert' import styles from './LoginForm.module.css' export interface SocialProvider { label: string icon?: ReactNode onClick: () => void } export interface LoginFormProps { logo?: ReactNode title?: string socialProviders?: SocialProvider[] onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void onForgotPassword?: () => void onSignUp?: () => void error?: string loading?: boolean className?: string } interface FieldErrors { email?: string password?: string } const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ function validate(email: string, password: string): FieldErrors { const errors: FieldErrors = {} if (!email) { errors.email = 'Email is required' } else if (!EMAIL_REGEX.test(email)) { errors.email = 'Please enter a valid email address' } if (!password) { errors.password = 'Password is required' } else if (password.length < 8) { errors.password = 'Password must be at least 8 characters' } return errors } export function LoginForm({ logo, title = 'Sign in', socialProviders, onSubmit, onForgotPassword, onSignUp, error, loading = false, className, }: LoginFormProps) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [remember, setRemember] = useState(false) const [fieldErrors, setFieldErrors] = useState({}) const [submitted, setSubmitted] = useState(false) const emailRef = useRef(null) // Auto-focus first input on mount useEffect(() => { emailRef.current?.focus() }, []) // Reset submitted flag when error prop changes (new server error from re-attempt) useEffect(() => { if (error) setSubmitted(false) }, [error]) // Server error is shown from prop, hidden after next submit attempt const showServerError = error && !submitted const hasSocial = socialProviders && socialProviders.length > 0 const hasCredentials = !!onSubmit const showDivider = hasSocial && hasCredentials function handleSubmit(e: FormEvent) { e.preventDefault() setSubmitted(true) const errors = validate(email, password) setFieldErrors(errors) if (Object.keys(errors).length === 0) { onSubmit?.({ email, password, remember }) } } return (
{logo &&
{logo}
}

{title}

{showServerError && (
{error}
)} {hasSocial && (
{socialProviders.map((provider) => ( ))}
)} {showDivider && (
or
)} {hasCredentials && (
{ setEmail(e.target.value) if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined })) }} disabled={loading} /> { setPassword(e.target.value) if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined })) }} disabled={loading} />
setRemember(e.target.checked)} disabled={loading} /> {onForgotPassword && ( )}
{onSignUp && (
Don't have an account?{' '}
)}
)} {!hasCredentials && onSignUp && (
Don't have an account?{' '}
)}
) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx` Expected: 8 tests PASS - [ ] **Step 5: Commit** ```bash git add src/design-system/composites/LoginForm/LoginForm.tsx \ src/design-system/composites/LoginForm/LoginForm.module.css \ src/design-system/composites/LoginForm/LoginForm.test.tsx git commit -m "feat: add LoginForm component with rendering tests" ``` --- ### Task 2: LoginForm — Validation Tests & Behavior **Files:** - Modify: `src/design-system/composites/LoginForm/LoginForm.test.tsx` - [ ] **Step 1: Add validation and interaction tests** Append to the `describe('LoginForm')` block in `LoginForm.test.tsx`: ```tsx import userEvent from '@testing-library/user-event' // Add these inside the existing describe('LoginForm') block, after the rendering describe: describe('validation', () => { it('validates required email', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(screen.getByText('Email is required')).toBeInTheDocument() }) it('validates email format', async () => { const user = userEvent.setup() render() await user.type(screen.getByLabelText(/email/i), 'notanemail') await user.type(screen.getByLabelText(/password/i), 'password123') await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument() }) it('validates required password', async () => { const user = userEvent.setup() render() await user.type(screen.getByLabelText(/email/i), 'test@example.com') await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(screen.getByText('Password is required')).toBeInTheDocument() }) it('validates password minimum length', async () => { const user = userEvent.setup() render() await user.type(screen.getByLabelText(/email/i), 'test@example.com') await user.type(screen.getByLabelText(/password/i), 'short') await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument() }) it('clears field errors on typing', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(screen.getByText('Email is required')).toBeInTheDocument() await user.type(screen.getByLabelText(/email/i), 't') expect(screen.queryByText('Email is required')).not.toBeInTheDocument() }) it('calls onSubmit with credentials when valid', async () => { const onSubmit = vi.fn() const user = userEvent.setup() render() await user.type(screen.getByLabelText(/email/i), 'test@example.com') await user.type(screen.getByLabelText(/password/i), 'password123') await user.click(screen.getByLabelText(/remember me/i)) await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', remember: true, }) }) it('does not call onSubmit when validation fails', async () => { const onSubmit = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(onSubmit).not.toHaveBeenCalled() }) }) describe('loading state', () => { it('disables form inputs when loading', () => { render() expect(screen.getByLabelText(/email/i)).toBeDisabled() expect(screen.getByLabelText(/password/i)).toBeDisabled() expect(screen.getByLabelText(/remember me/i)).toBeDisabled() }) it('shows spinner on submit button when loading', () => { render() const submitBtn = screen.getByRole('button', { name: 'Sign in' }) expect(submitBtn).toBeDisabled() // Button component renders Spinner when loading=true expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument() }) it('disables social buttons when loading', () => { render() expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled() }) }) describe('callbacks', () => { it('calls social provider onClick when clicked', async () => { const onClick = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Continue with Google' })) expect(onClick).toHaveBeenCalledOnce() }) it('calls onForgotPassword when link clicked', async () => { const onForgotPassword = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByText(/forgot password/i)) expect(onForgotPassword).toHaveBeenCalledOnce() }) it('calls onSignUp when link clicked', async () => { const onSignUp = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByText(/sign up/i)) expect(onSignUp).toHaveBeenCalledOnce() }) }) ``` - [ ] **Step 2: Run tests to verify they pass** Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx` Expected: 21 tests PASS (8 rendering + 7 validation + 3 loading + 3 callbacks) - [ ] **Step 3: Commit** ```bash git add src/design-system/composites/LoginForm/LoginForm.test.tsx git commit -m "test: add validation and interaction tests for LoginForm" ``` --- ### Task 3: LoginDialog — Component & Tests **Files:** - Create: `src/design-system/composites/LoginForm/LoginDialog.tsx` - Create: `src/design-system/composites/LoginForm/LoginDialog.test.tsx` - [ ] **Step 1: Write LoginDialog tests** Create `src/design-system/composites/LoginForm/LoginDialog.test.tsx`: ```tsx import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LoginDialog } from './LoginDialog' const defaultProps = { open: true, onClose: vi.fn(), onSubmit: vi.fn(), } describe('LoginDialog', () => { it('renders Modal with LoginForm when open', () => { render() expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByText('Sign in')).toBeInTheDocument() expect(screen.getByLabelText(/email/i)).toBeInTheDocument() }) it('does not render when closed', () => { render() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('calls onClose on Esc', async () => { const onClose = vi.fn() const user = userEvent.setup() render() await user.keyboard('{Escape}') expect(onClose).toHaveBeenCalled() }) it('calls onClose on backdrop click', async () => { const onClose = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByTestId('modal-backdrop')) expect(onClose).toHaveBeenCalled() }) it('passes LoginForm props through', () => { render( , ) expect(screen.getByText('Welcome')).toBeInTheDocument() expect(screen.getByText('Continue with Google')).toBeInTheDocument() expect(screen.getByText('Bad credentials')).toBeInTheDocument() }) }) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx` Expected: FAIL — module not found - [ ] **Step 3: Create LoginDialog component** Create `src/design-system/composites/LoginForm/LoginDialog.tsx`: ```tsx import { Modal } from '../Modal/Modal' import { LoginForm, type LoginFormProps } from './LoginForm' export interface LoginDialogProps extends LoginFormProps { open: boolean onClose: () => void } export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) { return ( ) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx` Expected: 5 tests PASS - [ ] **Step 5: Commit** ```bash git add src/design-system/composites/LoginForm/LoginDialog.tsx \ src/design-system/composites/LoginForm/LoginDialog.test.tsx git commit -m "feat: add LoginDialog modal wrapper component" ``` --- ### Task 4: Barrel Exports & Full Test Suite **Files:** - Modify: `src/design-system/composites/index.ts` - [ ] **Step 1: Add exports to barrel** Add these lines to `src/design-system/composites/index.ts` in alphabetical position (after the `LineChart` export, before `MenuItem`): ```ts export { LoginForm } from './LoginForm/LoginForm' export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm' export { LoginDialog } from './LoginForm/LoginDialog' export type { LoginDialogProps } from './LoginForm/LoginDialog' ``` - [ ] **Step 2: Run the full test suite** Run: `npx vitest run src/design-system/composites/LoginForm/` Expected: All tests PASS (21 LoginForm + 5 LoginDialog = 26 tests) - [ ] **Step 3: Run the full project test suite to check for regressions** Run: `npx vitest run` Expected: All tests PASS - [ ] **Step 4: Commit** ```bash git add src/design-system/composites/index.ts git commit -m "feat: export LoginForm and LoginDialog from composites barrel" ```