# 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 && (
)}
{hasSocial && (
{socialProviders.map((provider) => (
))}
)}
{showDivider && (
)}
{hasCredentials && (
)}
{!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"
```