diff --git a/docs/superpowers/plans/2026-05-15-register-page-improvement.md b/docs/superpowers/plans/2026-05-15-register-page-improvement.md new file mode 100644 index 0000000..5c9a321 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-register-page-improvement.md @@ -0,0 +1,1140 @@ +# Register Page Improvement 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:** Improve the registration page with component decomposition, shared schema, field-level error handling, password UX enhancements, and visual polish. + +**Architecture:** Decompose `register.vue` into 4 focused components (`RegisterForm`, `PasswordInput`, `PasswordStrength`, `CaptchaField`) with a shared Zod schema in `packages/shared/`. Fix `R.error` to pass data through, add `ApiError` class for typed error handling from `useHttpFetch`. + +**Tech Stack:** Nuxt 4.4.2, Nuxt UI 4.6.1, Vue 3.5.32, Zod 4.3.6, Tailwind CSS 4.2.2, Vitest, @nuxt/test-utils + +--- + +### Task 1: Fix R.error utility + +**Files:** +- Modify: `server/utils/response.ts:17-17` + +**Context:** `R.error(message: string, data: T)` accepts a `data` parameter but always returns `data: null`. This blocks field-level error responses. Only one caller exists (`server/api/auth/register.post.ts`) and all callers pass `null` as data, so the fix is safe. + +- [ ] **Step 1: Fix R.error to return the data parameter** + +In `server/utils/response.ts`, change line 17 from `data: null` to `data: data`: + +```typescript +export const R = { + success: (data: T) => { + return { + code: 0, + message: 'success', + data: data, + } as const + }, + error: (message: string, data: T) => { + return { + code: 1, + message: message, + data: data, + } as const + }, + throwError: (code: number, message: string, data: T) => { + throw createError({ + statusCode: code, + statusMessage: message, + data: data, + }) as never + } +} as const +``` + +- [ ] **Step 2: Verify build** + +Run: `bun run build 2>&1 | tail -5` +Expected: Build succeeds, no type errors from existing `R.error(null)` callers. + +- [ ] **Step 3: Commit** + +```bash +git add server/utils/response.ts +git commit -m "fix: return data parameter in R.error response helper + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 2: Create ApiError class for field-level error transport + +**Files:** +- Modify: `app/utils/http/factory.ts` + +**Context:** `useHttpFetch` auto-unwraps API responses via `unwrapApiBody`, which throws `new Error(message)` on non-zero code — losing `data`. We need `data.field` to survive the throw so the form can call `setFieldError`. Create an `ApiError` class that preserves `data`, and update `unwrapApiBody` to throw it. + +- [ ] **Step 1: Add ApiError class and update unwrapApiBody** + +In `app/utils/http/factory.ts`, add the class and update the unwrap function: + +```typescript +import { createUseFetch } from '#imports' + +/** 与 `R.success` / `R.error` 返回结构对齐 */ +export type ApiResponse = { + code: number + message: string + data: T +} + +/** Wraps API error responses, preserving the data field for field-level error handling */ +export class ApiError extends Error { + constructor(message: string, public data: unknown) { + super(message) + this.name = 'ApiError' + } +} + +/** 从 Nitro 推断的响应体上剥离一层 `ApiResponse`,得到 `data` 字段类型 */ +export type UnwrapApiResponse = T extends ApiResponse ? D : T + +export function unwrapApiBody(payload: ApiResponse): T { + if (payload.code !== 0) { + throw new ApiError(payload.message, payload.data) + } + return payload.data +} + +export const request = $fetch.create({}) + +const httpFetchDefaults = { + retry: 0, + $fetch: request, + transform: unwrapApiBody, +} + +export const _useHttpFetch = createUseFetch(httpFetchDefaults) +export const _useLazyHttpFetch = createUseFetch({ + ...httpFetchDefaults, + lazy: true, +}) +``` + +- [ ] **Step 2: Verify build** + +Run: `bun run build 2>&1 | tail -5` +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add app/utils/http/factory.ts +git commit -m "feat: add ApiError class to preserve error data through unwrapApiBody + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 3: Extract shared Zod schema + +**Files:** +- Create: `packages/shared/auth-schema.ts` +- Create: `packages/shared/package.json` +- Modify: `nuxt.config.ts` (add path alias) +- Modify: `app/pages/register.vue` (import from shared, remove inline schema) +- Modify: `server/utils/auth/validation.ts` (re-export from shared) +- Delete: `server/utils/auth/validation.ts` content (replace with re-export) + +**Context:** Both client and server have near-identical Zod schemas. Extract to a shared package. The shared package needs to be importable by both Nitro (server) and the Nuxt app (client). Use a simple TS file + path alias approach. + +- [ ] **Step 1: Create packages/shared/package.json** + +```bash +mkdir -p packages/shared +``` + +```json +{ + "name": "shared", + "type": "module", + "private": true +} +``` + +- [ ] **Step 2: Create the shared schema** + +In `packages/shared/auth-schema.ts`: + +```typescript +import { z } from 'zod' + +export const registerSchema = z + .object({ + username: z + .string() + .trim() + .min(3, '用户名至少需要3个字符') + .max(30, '用户名最多30个字符'), + password: z.string().min(8, '密码至少需要8个字符'), + confirmPassword: z.string(), + captchaToken: z.string().min(1, '验证码令牌不能为空'), + captchaText: z.string().min(1, '验证码不能为空'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: '两次输入的密码不一致', + path: ['confirmPassword'], + }) + +/** Client-side schema: omits captchaToken (managed by page shell) */ +export const clientRegisterSchema = registerSchema.omit({ captchaToken: true }) + +export type RegisterInput = z.infer +``` + +- [ ] **Step 3: Add path alias in nuxt.config.ts** + +In `nuxt.config.ts`, add the `shared` alias to `compilerOptions.paths`: + +```typescript +paths: { + 'drizzle-pkg': ['./packages/drizzle-pkg/lib'], + 'logger': ['./packages/logger/lib'], + 'shared': ['./packages/shared'] +}, +``` + +- [ ] **Step 4: Update server validation to re-export from shared** + +Replace `server/utils/auth/validation.ts`: + +```typescript +export { registerSchema } from 'shared/auth-schema' +``` + +- [ ] **Step 5: Update client-side register.vue to use shared schema** + +In `app/pages/register.vue`, remove the inline `formSchema` (lines 4-14) and replace with: + +```typescript +import { clientRegisterSchema } from 'shared/auth-schema' + +const formSchema = clientRegisterSchema +``` + +- [ ] **Step 6: Verify build** + +Run: `bun run build 2>&1 | tail -10` +Expected: Build succeeds with both client and server resolving the shared schema. + +- [ ] **Step 7: Commit** + +```bash +git add packages/shared/ nuxt.config.ts server/utils/auth/validation.ts app/pages/register.vue +git commit -m "refactor: extract shared Zod registration schema to packages/shared + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4: Update server register endpoint for field-level errors + +**Files:** +- Modify: `server/api/auth/register.post.ts` + +**Context:** Server errors currently use `R.error(message, null)`. Update to pass `{ field: 'fieldName' }` so the client can call `setFieldError` on the correct field. + +- [ ] **Step 1: Update error responses with field metadata** + +```typescript +import { registerSchema } from '../../utils/auth/validation' +import { verifyCaptcha } from '../../utils/auth/captcha' +import { dbGlobal } from 'drizzle-pkg/lib/db' +import { users } from 'drizzle-pkg/lib/schema/auth' +import { hash } from 'bcryptjs' +import log4js from 'logger' + +const logger = log4js.getLogger('AUTH') + +export default defineWrappedResponseHandler(async (event) => { + const body = await readBody(event) + + const parsed = registerSchema.safeParse(body) + if (!parsed.success) { + const field = parsed.error.issues[0]?.path[0]?.toString() ?? undefined + return R.error(parsed.error.issues[0]?.message || '表单验证失败', { field }) + } + + const { username, password, captchaToken, captchaText } = parsed.data + + if (!verifyCaptcha(captchaToken, captchaText)) { + return R.error('验证码错误或已过期', { field: 'captchaText' }) + } + + const hashedPassword = await hash(password, 10) + + try { + const result = await dbGlobal + .insert(users) + .values({ + username, + password: hashedPassword, + role: 'user', + status: 'active', + }) + .returning({ id: users.id }) + + return R.success({ id: result[0].id, username }) + } catch (err: any) { + const msg = String(err?.message ?? '') + if (msg.toLowerCase().includes('unique') || msg.includes('SQLITE_CONSTRAINT')) { + return R.error('用户名已存在', { field: 'username' }) + } + logger.error('Failed to insert user', msg) + return R.error('注册失败,请稍后重试', null) + } +}) +``` + +- [ ] **Step 2: Verify build** + +Run: `bun run build 2>&1 | tail -5` +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add server/api/auth/register.post.ts +git commit -m "feat: add field-level error metadata to register endpoint responses + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 5: Install test dependencies + +**Files:** +- Modify: `package.json` (devDependencies) +- Create: `vitest.config.ts` + +**Context:** Project has no test framework. Add vitest for unit tests and @nuxt/test-utils for component tests. + +- [ ] **Step 1: Install vitest** + +Run: `bun add -d vitest` +Expected: vitest added to devDependencies. + +- [ ] **Step 2: Install @nuxt/test-utils** + +Run: `bun add -d @nuxt/test-utils @vue/test-utils happy-dom` +Expected: Packages added to devDependencies. + +- [ ] **Step 3: Create vitest.config.ts** + +```typescript +import { defineVitestConfig } from '@nuxt/test-utils/config' + +export default defineVitestConfig({ + test: { + environment: 'happy-dom', + include: ['**/*.{test,spec}.{ts,js}'], + }, +}) +``` + +- [ ] **Step 4: Verify vitest runs** + +Run: `bun vitest --version` +Expected: Prints vitest version. + +- [ ] **Step 5: Commit** + +```bash +git add package.json bun.lock vitest.config.ts +git commit -m "chore: add vitest and @nuxt/test-utils for testing + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 6: Unit tests for auth-schema + +**Files:** +- Create: `packages/shared/__tests__/auth-schema.test.ts` + +**Context:** Test the shared Zod schema validation rules for both valid and invalid inputs. Pure unit test, no Vue/Nuxt needed. + +- [ ] **Step 1: Write the test** + +In `packages/shared/__tests__/auth-schema.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { registerSchema, clientRegisterSchema } from '../auth-schema' + +describe('registerSchema', () => { + const validBody = { + username: 'testuser', + password: 'password123', + confirmPassword: 'password123', + captchaToken: 'token-abc', + captchaText: 'abcde', + } + + it('passes with valid input', () => { + expect(registerSchema.safeParse(validBody).success).toBe(true) + }) + + it('fails on short username', () => { + const r = registerSchema.safeParse({ ...validBody, username: 'ab' }) + expect(r.success).toBe(false) + if (!r.success) { + expect(r.error.issues[0].path).toContain('username') + } + }) + + it('fails on short password', () => { + const r = registerSchema.safeParse({ ...validBody, password: '1234567' }) + expect(r.success).toBe(false) + if (!r.success) { + expect(r.error.issues[0].path).toContain('password') + } + }) + + it('fails on password mismatch', () => { + const r = registerSchema.safeParse({ + ...validBody, + confirmPassword: 'different', + }) + expect(r.success).toBe(false) + if (!r.success) { + expect(r.error.issues[0].path).toContain('confirmPassword') + } + }) + + it('fails on empty captchaText', () => { + const r = registerSchema.safeParse({ ...validBody, captchaText: '' }) + expect(r.success).toBe(false) + if (!r.success) { + expect(r.error.issues[0].path).toContain('captchaText') + } + }) + + it('fails on empty captchaToken', () => { + const r = registerSchema.safeParse({ ...validBody, captchaToken: '' }) + expect(r.success).toBe(false) + if (!r.success) { + expect(r.error.issues[0].path).toContain('captchaToken') + } + }) +}) + +describe('clientRegisterSchema', () => { + it('excludes captchaToken from the schema', () => { + const shape = clientRegisterSchema._def.schema.shape() + expect('captchaToken' in shape).toBe(false) + expect('username' in shape).toBe(true) + }) + + it('validates without captchaToken field', () => { + const r = clientRegisterSchema.safeParse({ + username: 'testuser', + password: 'password123', + confirmPassword: 'password123', + captchaText: 'abcde', + }) + expect(r.success).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run the test — verify it fails (file doesn't exist yet)** + +Wait — the test file is new and the schema already exists (created in Task 3), so tests should pass. Run: + +Run: `bun vitest run packages/shared/__tests__/auth-schema.test.ts` +Expected: 7 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add packages/shared/__tests__/auth-schema.test.ts +git commit -m "test: add zod schema validation tests for shared auth-schema + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 7: PasswordStrength component (TDD) + +**Files:** +- Create: `app/components/register/PasswordStrength.vue` +- Create: `app/components/register/__tests__/PasswordStrength.test.ts` + +**Context:** Pure display component — receives a password string, computes a score 0-4 based on composition and length, renders a color-coded bar and label. + +**Scoring algorithm:** +- score 0: empty password, nothing rendered +- score 1: < 8 chars, only digits OR only letters — red, "弱" +- score 2: >= 8 chars, letters + digits — amber, "中" +- score 3: >= 10 chars, upper + lower + digits — green, "强" +- score 4: >= 12 chars, upper + lower + digits + special chars — emerald, "很强" + +- [ ] **Step 1: Write component test** + +In `app/components/register/__tests__/PasswordStrength.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import PasswordStrength from '../PasswordStrength.vue' + +function score(password: string): number { + const comp = mount(PasswordStrength, { props: { password } }) + // The component renders data-testid="strength-bar" with title attr containing score + const bar = comp.find('[data-testid="strength-bar"]') + if (!bar.exists()) return 0 + return Number(bar.attributes('data-score') || 0) +} + +describe('PasswordStrength', () => { + it('renders nothing for empty password', () => { + const wrapper = mount(PasswordStrength, { props: { password: '' } }) + expect(wrapper.find('[data-testid="strength-bar"]').exists()).toBe(false) + }) + + it('score 1 for weak password (only letters, < 8)', () => { + const wrapper = mount(PasswordStrength, { props: { password: 'abc' } }) + expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('1') + expect(wrapper.text()).toContain('弱') + }) + + it('score 1 for only digits', () => { + const wrapper = mount(PasswordStrength, { props: { password: '1234567' } }) + expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('1') + }) + + it('score 2 for letters+digits >= 8 chars', () => { + const wrapper = mount(PasswordStrength, { props: { password: 'abc12345' } }) + expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('2') + expect(wrapper.text()).toContain('中') + }) + + it('score 3 for upper+lower+digits >= 10 chars', () => { + const wrapper = mount(PasswordStrength, { props: { password: 'Abcdef1234' } }) + expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('3') + expect(wrapper.text()).toContain('强') + }) + + it('score 4 for upper+lower+digits+special >= 12 chars', () => { + const wrapper = mount(PasswordStrength, { props: { password: 'Abcdef1234!@' } }) + expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('4') + expect(wrapper.text()).toContain('很强') + }) +}) +``` + +- [ ] **Step 2: Run the test — expect FAIL** + +Run: `bun vitest run app/components/register/__tests__/PasswordStrength.test.ts` +Expected: FAIL — component file does not exist. + +- [ ] **Step 3: Implement the component** + +In `app/components/register/PasswordStrength.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Run the test — verify PASS** + +Run: `bun vitest run app/components/register/__tests__/PasswordStrength.test.ts` +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/components/register/PasswordStrength.vue app/components/register/__tests__/PasswordStrength.test.ts +git commit -m "feat: add PasswordStrength component with real-time strength scoring + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 8: PasswordInput component (TDD) + +**Files:** +- Create: `app/components/register/PasswordInput.vue` +- Create: `app/components/register/__tests__/PasswordInput.test.ts` + +**Context:** Wraps Nuxt UI's `UInput` with a show/hide toggle button. Uses v-model for two-way binding. The toggle switches `type` between "password" and "text". + +- [ ] **Step 1: Write component test** + +In `app/components/register/__tests__/PasswordInput.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import PasswordInput from '../PasswordInput.vue' + +describe('PasswordInput', () => { + it('renders with type=password by default', () => { + const wrapper = mount(PasswordInput, { + props: { modelValue: '' }, + }) + const input = wrapper.find('input') + expect(input.attributes('type')).toBe('password') + }) + + it('toggles to type=text when button clicked', async () => { + const wrapper = mount(PasswordInput, { + props: { modelValue: '' }, + }) + const btn = wrapper.find('button') + await btn.trigger('click') + expect(wrapper.find('input').attributes('type')).toBe('text') + }) + + it('renders label when provided', () => { + const wrapper = mount(PasswordInput, { + props: { modelValue: '', label: 'Test Label' }, + }) + // UFormField renders the label — check for presence + expect(wrapper.text()).toContain('Test Label') + }) + + it('disables input when disabled prop is true', () => { + const wrapper = mount(PasswordInput, { + props: { modelValue: '', disabled: true }, + }) + expect(wrapper.find('input').element.disabled).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run the test — expect FAIL** + +Run: `bun vitest run app/components/register/__tests__/PasswordInput.test.ts` +Expected: FAIL — component file does not exist. + +- [ ] **Step 3: Implement the component** + +In `app/components/register/PasswordInput.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Run the test — verify PASS** + +Run: `bun vitest run app/components/register/__tests__/PasswordInput.test.ts` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/components/register/PasswordInput.vue app/components/register/__tests__/PasswordInput.test.ts +git commit -m "feat: add PasswordInput component with show/hide toggle + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 9: CaptchaField component (TDD) + +**Files:** +- Create: `app/components/register/CaptchaField.vue` +- Create: `app/components/register/__tests__/CaptchaField.test.ts` + +**Context:** Displays captcha SVG (v-html), refresh button with loading spinner, and a text input. Supports v-model for the captcha text value. Emits `refresh` when the button is clicked. + +- [ ] **Step 1: Write component test** + +In `app/components/register/__tests__/CaptchaField.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import CaptchaField from '../CaptchaField.vue' + +const defaultProps = { svg: '', loading: false, modelValue: '' } + +describe('CaptchaField', () => { + it('renders SVG via v-html', () => { + const svg = '' + const wrapper = mount(CaptchaField, { + props: { ...defaultProps, svg }, + }) + expect(wrapper.html()).toContain(' { + const wrapper = mount(CaptchaField, { props: defaultProps }) + const btn = wrapper.find('button') + await btn.trigger('click') + expect(wrapper.emitted('refresh')).toBeTruthy() + }) + + it('disables refresh button when loading', () => { + const wrapper = mount(CaptchaField, { + props: { ...defaultProps, loading: true }, + }) + const btn = wrapper.find('button') + expect(btn.attributes('disabled')).toBeDefined() + }) + + it('renders error state when svg is empty', () => { + const wrapper = mount(CaptchaField, { + props: { ...defaultProps, svg: '' }, + }) + expect(wrapper.text()).toContain('加载失败') + }) + + it('emits update:modelValue when input changes', async () => { + const wrapper = mount(CaptchaField, { props: defaultProps }) + const input = wrapper.find('input[type="text"]') + await input.setValue('abc') + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + expect(wrapper.emitted('update:modelValue')![0]).toEqual(['abc']) + }) +}) +``` + +- [ ] **Step 2: Run the test — expect FAIL** + +Run: `bun vitest run app/components/register/__tests__/CaptchaField.test.ts` +Expected: FAIL — component file does not exist. + +- [ ] **Step 3: Implement the component** + +In `app/components/register/CaptchaField.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Run the test — verify PASS** + +Run: `bun vitest run app/components/register/__tests__/CaptchaField.test.ts` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/components/register/CaptchaField.vue app/components/register/__tests__/CaptchaField.test.ts +git commit -m "feat: add CaptchaField component with v-model and loading/error states + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 10: RegisterForm component + +**Files:** +- Create: `app/components/register/RegisterForm.vue` + +**Context:** The main form component. Uses UForm with the shared `clientRegisterSchema`. Integrates PasswordInput, PasswordStrength, CaptchaField. Handles submit via `useHttpFetch`, catches `ApiError` for field-level errors. + +**Note:** Use raw `$fetch` instead of `useHttpFetch` for the register call. The `useHttpFetch` composable is designed for GET/query patterns and its error path via `ApiError` is less ergonomic for form submission where we need the full response structure. Using `$fetch` directly gives us full control over the `{ code, message, data }` envelope and field-level error handling. + +- [ ] **Step 1: Create the component** + +In `app/components/register/RegisterForm.vue`: + +```vue + + + +``` + +- [ ] **Step 2: Verify build** + +Run: `bun run build 2>&1 | tail -10` +Expected: Build succeeds. The component references shared schema and sub-components. + +- [ ] **Step 3: Commit** + +```bash +git add app/components/register/RegisterForm.vue +git commit -m "feat: add RegisterForm component with field-level error handling + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 11: Refactor register.vue page shell + +**Files:** +- Modify: `app/pages/register.vue` + +**Context:** Rewrite the page shell to use `RegisterForm` and manage only captcha state + toast notifications. Shrink from ~146 lines to ~60 lines. + +- [ ] **Step 1: Rewrite register.vue** + +```vue + + + +``` + +- [ ] **Step 2: Verify build** + +Run: `bun run build 2>&1 | tail -10` +Expected: Build succeeds with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add app/pages/register.vue +git commit -m "refactor: decompose register page into component-driven architecture + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 12: End-to-end verification + +**Files:** +- None (verification only) + +**Context:** Start the dev server, verify the registration page renders and functions correctly. + +- [ ] **Step 1: Start dev server** + +Run in background: `bun run dev` +Expected: Nuxt dev server starts on port 3000. + +- [ ] **Step 2: Check page renders** + +Run: `curl -s http://localhost:3000/register | head -20` +Expected: HTML response with registration form elements. + +- [ ] **Step 3: Run all tests** + +Run: `bun vitest run` +Expected: All tests pass (schema tests + component tests). + +- [ ] **Step 4: Check for TypeScript errors** + +Run: `bun x nuxi typecheck 2>&1 | tail -20` +Expected: No type errors. + +- [ ] **Step 5: Verify captcha API works** + +Run: `curl -s http://localhost:3000/api/auth/captcha | head -5` +Expected: JSON response with `{ code: 0, data: { token: "...", svg: "..." } }`. + +- [ ] **Step 6: Commit any final fixes** + +```bash +git add -A +git commit -m "chore: final verification fixes for registration page improvement + +Co-Authored-By: Claude Opus 4.7 " +```