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 && ( +
+ {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" +```