diff --git a/docs/superpowers/plans/2026-03-24-login-dialog.md b/docs/superpowers/plans/2026-03-24-login-dialog.md
new file mode 100644
index 0000000..7d9e20a
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-24-login-dialog.md
@@ -0,0 +1,770 @@
+# 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"
+```