# 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: <div data-testid="logo">Logo</div>,
  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(<LoginForm {...allProps} />)
      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(<LoginForm onSubmit={vi.fn()} />)
      expect(screen.getByText('Sign in')).toBeInTheDocument()
    })

    it('hides social section when socialProviders is empty', () => {
      render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
      expect(screen.queryByText('or')).not.toBeInTheDocument()
    })

    it('hides social section when socialProviders is omitted', () => {
      render(<LoginForm onSubmit={vi.fn()} />)
      expect(screen.queryByText('or')).not.toBeInTheDocument()
    })

    it('hides forgot password link when onForgotPassword omitted', () => {
      render(<LoginForm onSubmit={vi.fn()} />)
      expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
    })

    it('hides sign up link when onSignUp omitted', () => {
      render(<LoginForm onSubmit={vi.fn()} />)
      expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
    })

    it('hides credentials section when onSubmit omitted (social only)', () => {
      render(<LoginForm socialProviders={socialProviders} />)
      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(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
      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<FieldErrors>({})
  const [submitted, setSubmitted] = useState(false)
  const emailRef = useRef<HTMLInputElement>(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 (
    <div className={`${styles.loginForm} ${className ?? ''}`}>
      {logo && <div className={styles.logo}>{logo}</div>}
      <h2 className={styles.title}>{title}</h2>

      {showServerError && (
        <div className={styles.error}>
          <Alert variant="error">{error}</Alert>
        </div>
      )}

      {hasSocial && (
        <div className={styles.socialSection}>
          {socialProviders.map((provider) => (
            <Button
              key={provider.label}
              variant="secondary"
              className={styles.socialButton}
              onClick={provider.onClick}
              disabled={loading}
              type="button"
            >
              {provider.icon}
              {provider.label}
            </Button>
          ))}
        </div>
      )}

      {showDivider && (
        <div className={styles.divider}>
          <div className={styles.dividerLine} />
          <span className={styles.dividerText}>or</span>
          <div className={styles.dividerLine} />
        </div>
      )}

      {hasCredentials && (
        <form
          className={styles.fields}
          onSubmit={handleSubmit}
          aria-label="Sign in"
          noValidate
        >
          <FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
            <Input
              ref={emailRef}
              id="login-email"
              type="email"
              placeholder="you@example.com"
              value={email}
              onChange={(e) => {
                setEmail(e.target.value)
                if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
              }}
              disabled={loading}
            />
          </FormField>

          <FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
            <Input
              id="login-password"
              type="password"
              placeholder="••••••••"
              value={password}
              onChange={(e) => {
                setPassword(e.target.value)
                if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
              }}
              disabled={loading}
            />
          </FormField>

          <div className={styles.rememberRow}>
            <Checkbox
              label="Remember me"
              checked={remember}
              onChange={(e) => setRemember(e.target.checked)}
              disabled={loading}
            />
            {onForgotPassword && (
              <button
                type="button"
                className={styles.forgotLink}
                onClick={onForgotPassword}
              >
                Forgot password?
              </button>
            )}
          </div>

          <Button
            variant="primary"
            type="submit"
            loading={loading}
            className={styles.submitButton}
          >
            Sign in
          </Button>

          {onSignUp && (
            <div className={styles.signUpText}>
              Don&apos;t have an account?{' '}
              <button
                type="button"
                className={styles.signUpLink}
                onClick={onSignUp}
              >
                Sign up
              </button>
            </div>
          )}
        </form>
      )}

      {!hasCredentials && onSignUp && (
        <div className={styles.signUpText}>
          Don&apos;t have an account?{' '}
          <button
            type="button"
            className={styles.signUpLink}
            onClick={onSignUp}
          >
            Sign up
          </button>
        </div>
      )}
    </div>
  )
}
```

- [ ] **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(<LoginForm onSubmit={vi.fn()} />)
      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(<LoginForm onSubmit={vi.fn()} />)
      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(<LoginForm onSubmit={vi.fn()} />)
      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(<LoginForm onSubmit={vi.fn()} />)
      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(<LoginForm onSubmit={vi.fn()} />)
      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(<LoginForm onSubmit={onSubmit} />)
      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(<LoginForm onSubmit={onSubmit} />)
      await user.click(screen.getByRole('button', { name: 'Sign in' }))
      expect(onSubmit).not.toHaveBeenCalled()
    })
  })

  describe('loading state', () => {
    it('disables form inputs when loading', () => {
      render(<LoginForm {...allProps} loading />)
      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(<LoginForm {...allProps} loading />)
      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(<LoginForm {...allProps} loading />)
      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(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
      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(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
      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(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
      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(<LoginDialog {...defaultProps} />)
    expect(screen.getByRole('dialog')).toBeInTheDocument()
    expect(screen.getByText('Sign in')).toBeInTheDocument()
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
  })

  it('does not render when closed', () => {
    render(<LoginDialog {...defaultProps} open={false} />)
    expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
  })

  it('calls onClose on Esc', async () => {
    const onClose = vi.fn()
    const user = userEvent.setup()
    render(<LoginDialog {...defaultProps} onClose={onClose} />)
    await user.keyboard('{Escape}')
    expect(onClose).toHaveBeenCalled()
  })

  it('calls onClose on backdrop click', async () => {
    const onClose = vi.fn()
    const user = userEvent.setup()
    render(<LoginDialog {...defaultProps} onClose={onClose} />)
    await user.click(screen.getByTestId('modal-backdrop'))
    expect(onClose).toHaveBeenCalled()
  })

  it('passes LoginForm props through', () => {
    render(
      <LoginDialog
        {...defaultProps}
        title="Welcome"
        socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
        error="Bad credentials"
      />,
    )
    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 (
    <Modal open={open} onClose={onClose} size="sm" className={className}>
      <LoginForm {...formProps} />
    </Modal>
  )
}
```

- [ ] **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"
```
