# OTP Input Redesign Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace the custom OtpInput component with shadcn's InputOTP backed by `input-otp`, keeping the same external API so consumers need zero changes.

**Architecture:** Install `input-otp` → generate shadcn `input-otp.tsx` primitives → rewrite `OtpInput` molecule internals using those primitives. All 3 consumers (LoginOtpStep, OtpStep, VerificationStep) continue using the same `<OtpInput>` props interface unchanged.

**Tech Stack:** `input-otp` library, shadcn/ui primitives, Tailwind v4, React 19

---

### Task 1: Install `input-otp` dependency

**Files:**

- Modify: `apps/portal/package.json`

**Step 1: Install the package**

Run:

```bash
pnpm --filter @customer-portal/portal add input-otp
```

**Step 2: Verify installation**

Run:

```bash
pnpm --filter @customer-portal/portal exec -- node -e "require('input-otp')" 2>&1 || echo "ESM module, checking package exists" && ls node_modules/input-otp/package.json
```

Expected: Package exists in node_modules.

**Step 3: Commit**

```bash
git add apps/portal/package.json pnpm-lock.yaml
git commit -m "chore: add input-otp dependency"
```

---

### Task 2: Create shadcn InputOTP primitives

**Files:**

- Create: `apps/portal/src/components/ui/input-otp.tsx`

The shadcn CLI may not work cleanly because `components.json` aliases `utils` to `@/lib/utils` which doesn't exist. Create the component manually based on shadcn's new-york input-otp source, using the project's `@/shared/utils` import for `cn`.

**Step 1: Create the primitives file**

Create `apps/portal/src/components/ui/input-otp.tsx`:

```tsx
"use client";

import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/shared/utils";

function InputOTP({
  className,
  containerClassName,
  ...props
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
  return (
    <OTPInput
      data-slot="input-otp"
      containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
      className={cn("disabled:cursor-not-allowed", className)}
      {...props}
    />
  );
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
  );
}

function InputOTPSlot({
  index,
  className,
  ...props
}: React.ComponentProps<"div"> & { index: number }) {
  const inputOTPContext = React.useContext(OTPInputContext);
  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];

  return (
    <div
      data-slot="input-otp-slot"
      data-active={isActive}
      className={cn(
        "relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
        "first:rounded-l-lg first:border-l last:rounded-r-lg",
        "bg-card text-foreground",
        isActive && "z-10 ring-2 ring-primary border-primary",
        className
      )}
      {...props}
    >
      {char}
      {hasFakeCaret && (
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
        </div>
      )}
    </div>
  );
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
  return (
    <div data-slot="input-otp-separator" role="separator" {...props}>
      <MinusIcon />
    </div>
  );
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
```

**Step 2: Add caret-blink animation to globals.css**

Add inside the `@theme` block in `apps/portal/src/app/globals.css`, before the closing `}`:

```css
/* OTP caret blink animation */
--animate-caret-blink: caret-blink 1.2s ease-out infinite;

@keyframes caret-blink {
  0%,
  70%,
  100% {
    opacity: 1;
  }
  20%,
  50% {
    opacity: 0;
  }
}
```

**Step 3: Verify type-check**

Run:

```bash
pnpm type-check
```

Expected: PASS

**Step 4: Commit**

```bash
git add apps/portal/src/components/ui/input-otp.tsx apps/portal/src/app/globals.css
git commit -m "feat: add shadcn InputOTP primitives with caret-blink animation"
```

---

### Task 3: Rewrite OtpInput molecule

**Files:**

- Rewrite: `apps/portal/src/components/molecules/OtpInput/OtpInput.tsx`

**Step 1: Replace the entire file**

Replace `apps/portal/src/components/molecules/OtpInput/OtpInput.tsx` with:

```tsx
/**
 * OtpInput - 6-digit OTP code input
 *
 * Wrapper around shadcn InputOTP (backed by input-otp library).
 * Provides mobile SMS autofill, paste support, and accessible slot rendering.
 */

"use client";

import { REGEXP_ONLY_DIGITS } from "input-otp";
import { cn } from "@/shared/utils";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  InputOTPSeparator,
} from "@/components/ui/input-otp";

interface OtpInputProps {
  length?: number | undefined;
  value: string;
  onChange: (value: string) => void;
  onComplete?: ((value: string) => void) | undefined;
  disabled?: boolean | undefined;
  error?: string | undefined;
  autoFocus?: boolean | undefined;
}

export function OtpInput({
  length = 6,
  value,
  onChange,
  onComplete,
  disabled = false,
  error,
  autoFocus = true,
}: OtpInputProps) {
  const firstGroupSize = Math.ceil(length / 2);
  const secondGroupSize = length - firstGroupSize;

  return (
    <div className="space-y-2">
      <div className="flex justify-center">
        <InputOTP
          maxLength={length}
          value={value}
          onChange={onChange}
          onComplete={onComplete}
          disabled={disabled}
          autoFocus={autoFocus}
          pattern={REGEXP_ONLY_DIGITS}
          containerClassName="justify-center"
        >
          <InputOTPGroup className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}>
            {Array.from({ length: firstGroupSize }, (_, i) => (
              <InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
            ))}
          </InputOTPGroup>

          {secondGroupSize > 0 && (
            <>
              <InputOTPSeparator />
              <InputOTPGroup
                className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}
              >
                {Array.from({ length: secondGroupSize }, (_, i) => (
                  <InputOTPSlot
                    key={firstGroupSize + i}
                    index={firstGroupSize + i}
                    className={cn(error && "border-danger")}
                  />
                ))}
              </InputOTPGroup>
            </>
          )}
        </InputOTP>
      </div>
      {error && (
        <p className="text-sm text-danger text-center" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}
```

Note: The `index.ts` barrel file (`export { OtpInput } from "./OtpInput"`) stays unchanged.

**Step 2: Verify type-check**

Run:

```bash
pnpm type-check
```

Expected: PASS

**Step 3: Verify lint**

Run:

```bash
pnpm lint
```

Expected: PASS (no unused imports, no deep path violations)

**Step 4: Commit**

```bash
git add apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
git commit -m "refactor: replace custom OtpInput with shadcn InputOTP"
```

---

### Task 4: Verify all consumers still compile and work

**Files:** (read-only verification, no changes expected)

- `apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx`
- `apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx`
- `apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx`

**Step 1: Full type-check**

Run:

```bash
pnpm type-check
```

Expected: PASS — all 3 consumers use the same `OtpInputProps` API which hasn't changed.

**Step 2: Run portal tests**

Run:

```bash
pnpm --filter @customer-portal/portal test 2>&1 | tail -20
```

Expected: All tests pass.

**Step 3: Final commit if any fixes were needed**

Only commit if adjustments were required. Otherwise, skip.
