Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · zod

Multi-step Form Builder with React and zod Demo

Design a multi-step form and use it straight away. Add or drag fields into steps, set required, length, range and regex rules, and show fields only when another answer matches. The preview validates every step with a zod schema generated from your definition, and you can copy that schema as TypeScript or export and import the form as JSON.

Read the case study →

Interactive demo

samiullah.tech/lab/form-builder

fields · click to add, or drag into a step

steps · drag rows, or Alt+↑/↓ on a row

1.
2.
3.

properties · fullName

Conditional logic

The first field cannot depend on another field.

Acme Dental patient intake

  1. About you (current)
  2. Medical history
  3. Consent

Step 1 of 3 · About you

We send appointment reminders here.

## How it works

A form is a typed definition: steps containing fields, each with a type, a key, rules and an optional showIf condition. Everything else is derived from that one object.

fieldSchema() turns each field into a zod schema at runtime. The preview builds a schema for the current step from the fields that are visible right now, so a hidden conditional field is never validated. The first invalid input gets focus and every message is tied to its input with aria-describedby.

toZodSource() writes the same rules out as TypeScript, with conditional fields marked optional and enforced in a superRefine. Imported JSON is checked by a zod schema for the definition itself, and lintDef() catches what types cannot: duplicate keys, bad regex, and conditions that point forward.

## View source

The code running in the window above, exactly as deployed.

app/lab/form-builder/schema.ts359 lines
/**
 * Form definition model + zod schema generation for the form builder demo. No React here.
 *
 * One definition drives three things: the runtime zod schema that validates the preview, the TypeScript
 * source you can copy, and the JSON export. Because the preview uses the generated schema, what you see
 * fail in the preview is exactly what the exported schema would reject on a server.
 */
import { z, type ZodTypeAny } from "zod";

export const FIELD_TYPES = ["text", "email", "number", "tel", "date", "select", "radio", "checkbox", "textarea"] as const;
export type FieldType = (typeof FIELD_TYPES)[number];

export const FIELD_META: Record<FieldType, { label: string; icon: string; hasOptions?: boolean; hasLength?: boolean; hasRange?: boolean; hasPattern?: boolean }> = {
  text: { label: "Short text", icon: "Aa", hasLength: true, hasPattern: true },
  email: { label: "Email", icon: "@" },
  number: { label: "Number", icon: "#", hasRange: true },
  tel: { label: "Phone", icon: "☎", hasPattern: true },
  date: { label: "Date", icon: "▦" },
  select: { label: "Dropdown", icon: "▾", hasOptions: true },
  radio: { label: "Radio group", icon: "◉", hasOptions: true },
  checkbox: { label: "Checkbox", icon: "☑" },
  textarea: { label: "Long text", icon: "¶", hasLength: true },
};

export const CONDITION_OPS = ["equals", "notEquals", "isChecked", "isFilled"] as const;
export type Condition = { field: string; op: (typeof CONDITION_OPS)[number]; value: string };

export type Field = {
  id: string;
  type: FieldType;
  /** Key in the submitted object. camelCase, unique across the whole form. */
  name: string;
  label: string;
  help: string;
  placeholder: string;
  required: boolean;
  /** Min/max length for text, min/max value for numbers. */
  min: number | null;
  max: number | null;
  pattern: string;
  patternMessage: string;
  options: string[];
  showIf: Condition | null;
};

export type Step = { id: string; title: string; fields: Field[] };
export type FormDef = { version: 1; title: string; steps: Step[] };
export type Values = Record<string, string | boolean>;

const DEFAULT_TEL = "^\\+?[0-9 ()-]{7,20}$";

/* ------------------------------------------------------------------ */
/* Conditional logic                                                   */
/* ------------------------------------------------------------------ */

export function isVisible(field: Field, values: Values): boolean {
  const c = field.showIf;
  if (!c) return true;
  const v = values[c.field];
  switch (c.op) {
    case "equals":
      return String(v ?? "") === c.value;
    case "notEquals":
      return String(v ?? "") !== c.value;
    case "isChecked":
      return v === true;
    case "isFilled":
      return typeof v === "string" ? v.trim() !== "" : v === true;
  }
}

export const allFields = (def: FormDef) => def.steps.flatMap((s) => s.fields);

/* ------------------------------------------------------------------ */
/* Runtime zod schema                                                  */
/* ------------------------------------------------------------------ */

const msg = (message: string) => ({ errorMap: () => ({ message }) });

function safeRegex(src: string): RegExp | null {
  try {
    return new RegExp(src);
  } catch {
    return null;
  }
}

/** The zod schema for one field. `optional` empty strings are allowed through as "". */
export function fieldSchema(f: Field): ZodTypeAny {
  const req = `${f.label} is required.`;
  switch (f.type) {
    case "checkbox":
      return f.required ? z.literal(true, msg(`Please tick “${f.label}”.`)) : z.boolean();
    case "select":
    case "radio": {
      const [first, ...rest] = f.options;
      const s = first === undefined ? z.string() : z.enum([first, ...rest], msg(f.required ? `${f.label}: choose an option.` : "Choose one of the options."));
      return f.required ? s : s.or(z.literal(""));
    }
    case "number": {
      let n = z.coerce.number({ invalid_type_error: `${f.label} must be a number.` });
      if (f.min !== null) n = n.min(f.min, `${f.label} must be at least ${f.min}.`);
      if (f.max !== null) n = n.max(f.max, `${f.label} must be at most ${f.max}.`);
      const s = z.string().trim();
      return f.required ? s.min(1, req).pipe(n) : z.literal("").or(s.pipe(n));
    }
    case "email": {
      const s = z.string().trim().email("Enter a valid email address, like name@example.com.");
      return f.required ? z.string().trim().min(1, req).pipe(s) : z.literal("").or(s);
    }
    case "date": {
      const s = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use the format YYYY-MM-DD.");
      return f.required ? z.string().min(1, req).pipe(s) : z.literal("").or(s);
    }
    default: {
      // text, textarea, tel
      let s = z.string().trim();
      if (f.min !== null) s = s.min(f.min, `${f.label} needs at least ${f.min} characters.`);
      if (f.max !== null) s = s.max(f.max, `${f.label} can be at most ${f.max} characters.`);
      const pattern = f.pattern || (f.type === "tel" ? DEFAULT_TEL : "");
      const re = pattern ? safeRegex(pattern) : null;
      if (re) s = s.regex(re, f.patternMessage || (f.type === "tel" ? "Enter a valid phone number." : "The format is not valid."));
      return f.required ? z.string().trim().min(1, req).pipe(s) : z.literal("").or(s);
    }
  }
}

/** Schema for the fields of one step that are visible right now. Hidden fields are not validated. */
export function stepSchema(step: Step, values: Values) {
  const shape: Record<string, ZodTypeAny> = {};
  for (const f of step.fields) if (isVisible(f, values)) shape[f.name] = fieldSchema(f);
  return z.object(shape);
}

/** Pick the most useful message: union errors from `"".or(rule)` report the rule's message, not "Invalid input". */
function issueMessage(issue: z.ZodIssue): string {
  if (issue.code === "invalid_union") {
    const inner = issue.unionErrors.map((e) => e.issues[0]).find((i) => i && i.code !== "invalid_literal");
    if (inner) return inner.message;
  }
  return issue.message;
}

export type StepResult = { ok: true; data: Record<string, unknown> } | { ok: false; errors: Record<string, string> };

export function validateStep(step: Step, values: Values): StepResult {
  const input: Record<string, unknown> = {};
  for (const f of step.fields) input[f.name] = values[f.name] ?? (f.type === "checkbox" ? false : "");
  const r = stepSchema(step, values).safeParse(input);
  if (r.success) return { ok: true, data: r.data };
  const errors: Record<string, string> = {};
  for (const issue of r.error.issues) {
    const key = String(issue.path[0] ?? "");
    if (key && !errors[key]) errors[key] = issueMessage(issue);
  }
  return { ok: false, errors };
}

/* ------------------------------------------------------------------ */
/* TypeScript source generation (mirrors fieldSchema)                  */
/* ------------------------------------------------------------------ */

const q = (s: string) => JSON.stringify(s);

export function fieldSource(f: Field, forceOptional = false): string {
  const required = f.required && !forceOptional;
  const req = `.min(1, ${q(`${f.label} is required.`)})`;
  let out: string;
  switch (f.type) {
    case "checkbox":
      out = required ? `z.literal(true, { errorMap: () => ({ message: ${q(`Please tick “${f.label}”.`)} }) })` : "z.boolean()";
      break;
    case "select":
    case "radio": {
      const e = f.options.length ? `z.enum([${f.options.map(q).join(", ")}])` : "z.string()";
      out = required ? e : `${e}.or(z.literal(""))`;
      break;
    }
    case "number": {
      let n = "z.coerce.number()";
      if (f.min !== null) n += `.min(${f.min})`;
      if (f.max !== null) n += `.max(${f.max})`;
      out = required ? `z.string().trim()${req}.pipe(${n})` : `z.literal("").or(z.string().trim().pipe(${n}))`;
      break;
    }
    case "email":
      out = required ? `z.string().trim()${req}.pipe(z.string().email())` : `z.literal("").or(z.string().trim().email())`;
      break;
    case "date":
      out = required ? `z.string()${req}.pipe(z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/))` : `z.literal("").or(z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/))`;
      break;
    default: {
      let s = "z.string().trim()";
      if (f.min !== null) s += `.min(${f.min})`;
      if (f.max !== null) s += `.max(${f.max})`;
      const pattern = f.pattern || (f.type === "tel" ? DEFAULT_TEL : "");
      if (pattern && safeRegex(pattern)) s += `.regex(new RegExp(${q(pattern)})${f.patternMessage ? `, ${q(f.patternMessage)}` : ""})`;
      out = required ? `z.string().trim()${req}.pipe(${s})` : `z.literal("").or(${s})`;
    }
  }
  return forceOptional ? `${out}.optional()` : out;
}

const camel = (s: string) =>
  s
    .replace(/[^a-zA-Z0-9]+(.)?/g, (_, c: string | undefined) => (c ? c.toUpperCase() : ""))
    .replace(/^[A-Z]/, (c) => c.toLowerCase())
    .replace(/^[0-9]/, "f$&") || "form";

function conditionSource(c: Condition): string {
  const ref = `data.${c.field}`;
  switch (c.op) {
    case "equals":
      return `${ref} === ${q(c.value)}`;
    case "notEquals":
      return `${ref} !== ${q(c.value)}`;
    case "isChecked":
      return `${ref} === true`;
    case "isFilled":
      return `Boolean(${ref})`;
  }
}

/** The whole definition as copy-pasteable zod code: one object per step, merged, plus rules for conditional fields. */
export function toZodSource(def: FormDef): string {
  const name = camel(def.title);
  const lines = [`import { z } from "zod";`, ""];
  const conditional: Field[] = [];
  def.steps.forEach((step, i) => {
    lines.push(`// Step ${i + 1}: ${step.title}`);
    lines.push(`export const ${name}Step${i + 1} = z.object({`);
    for (const f of step.fields) {
      if (f.showIf) {
        conditional.push(f);
        lines.push(`  // shown when ${f.showIf.field} ${f.showIf.op}${f.showIf.op === "equals" || f.showIf.op === "notEquals" ? ` ${q(f.showIf.value)}` : ""}`);
      }
      lines.push(`  ${f.name}: ${fieldSource(f, !!f.showIf)},`);
    }
    lines.push("});", "");
  });
  const merged = def.steps.map((_, i) => `${name}Step${i + 1}`);
  let schema = merged[0] ?? "z.object({})";
  for (const m of merged.slice(1)) schema += `\n  .merge(${m})`;
  const requiredConditional = conditional.filter((f) => f.required);
  if (requiredConditional.length) {
    schema += `\n  .superRefine((data, ctx) => {`;
    for (const f of requiredConditional) {
      schema += `\n    if (${conditionSource(f.showIf!)} && !data.${f.name}) {\n      ctx.addIssue({ code: "custom", path: [${q(f.name)}], message: ${q(`${f.label} is required.`)} });\n    }`;
    }
    schema += `\n  })`;
  }
  lines.push(`export const ${name}Schema = ${schema};`, "", `export type ${name.replace(/^./, (c) => c.toUpperCase())} = z.infer<typeof ${name}Schema>;`);
  return lines.join("\n");
}

/* ------------------------------------------------------------------ */
/* Import validation (the builder validates its own JSON with zod)     */
/* ------------------------------------------------------------------ */

const conditionSchema = z.object({ field: z.string().min(1), op: z.enum(CONDITION_OPS), value: z.string() });

const fieldDefSchema = z.object({
  id: z.string().min(1),
  type: z.enum(FIELD_TYPES),
  name: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "field names must be valid identifiers"),
  label: z.string().min(1).max(80),
  help: z.string().default(""),
  placeholder: z.string().default(""),
  required: z.boolean(),
  min: z.number().nullable().default(null),
  max: z.number().nullable().default(null),
  pattern: z.string().default(""),
  patternMessage: z.string().default(""),
  options: z.array(z.string().min(1)).default([]),
  showIf: conditionSchema.nullable().default(null),
});

export const formDefSchema = z.object({
  version: z.literal(1),
  title: z.string().min(1).max(80),
  steps: z.array(z.object({ id: z.string().min(1), title: z.string().min(1).max(60), fields: z.array(fieldDefSchema) })).min(1).max(10),
});

/** Problems the type system cannot catch: duplicate names, dangling conditions, empty option lists, bad regex. */
export function lintDef(def: FormDef): string[] {
  const problems: string[] = [];
  const seen = new Map<string, number>();
  const order = allFields(def);
  order.forEach((f, i) => {
    if (seen.has(f.name)) problems.push(`Two fields are named “${f.name}”. Names must be unique.`);
    seen.set(f.name, i);
    if (FIELD_META[f.type].hasOptions && f.options.length === 0) problems.push(`“${f.label}” has no options.`);
    if (f.pattern && !safeRegex(f.pattern)) problems.push(`“${f.label}” has an invalid regex pattern.`);
    if (f.min !== null && f.max !== null && f.min > f.max) problems.push(`“${f.label}”: min is greater than max.`);
    if (f.showIf) {
      const at = order.findIndex((x) => x.name === f.showIf!.field);
      if (at === -1) problems.push(`“${f.label}” depends on a field that does not exist (${f.showIf.field}).`);
      else if (at >= i) problems.push(`“${f.label}” depends on “${order[at]!.label}”, which comes after it.`);
    }
  });
  return problems;
}

export function parseImport(text: string): { ok: true; def: FormDef } | { ok: false; error: string } {
  let raw: unknown;
  try {
    raw = JSON.parse(text);
  } catch (e) {
    return { ok: false, error: `Not valid JSON: ${(e as Error).message}` };
  }
  const r = formDefSchema.safeParse(raw);
  if (!r.success) {
    const i = r.error.issues[0]!;
    return { ok: false, error: `${i.path.join(".") || "root"}: ${i.message}` };
  }
  return { ok: true, def: r.data };
}

/* ------------------------------------------------------------------ */
/* Helpers for the editor                                              */
/* ------------------------------------------------------------------ */

export function uniqueName(label: string, taken: Set<string>): string {
  const base = camel(label) || "field";
  let name = base;
  for (let i = 2; taken.has(name); i++) name = `${base}${i}`;
  return name;
}

export function newField(type: FieldType, id: string, taken: Set<string>): Field {
  const label = FIELD_META[type].label;
  return {
    id,
    type,
    name: uniqueName(label, taken),
    label,
    help: "",
    placeholder: "",
    required: false,
    min: null,
    max: null,
    pattern: "",
    patternMessage: "",
    options: FIELD_META[type].hasOptions ? ["Option A", "Option B"] : [],
    showIf: null,
  };
}

/** Move a field to (step, index), removing it from wherever it is. */
export function moveField(def: FormDef, fieldId: string, toStep: string, toIndex: number): FormDef {
  const field = allFields(def).find((f) => f.id === fieldId);
  if (!field) return def;
  const steps = def.steps.map((s) => ({ ...s, fields: s.fields.filter((f) => f.id !== fieldId) }));
  const target = steps.find((s) => s.id === toStep);
  if (!target) return def;
  target.fields.splice(Math.max(0, Math.min(toIndex, target.fields.length)), 0, field);
  return { ...def, steps };
}
app/lab/form-builder/templates.ts136 lines
/** Starter form definitions. Fictional organisations only. */
import type { Field, FormDef } from "./schema";

const f = (p: Partial<Field> & Pick<Field, "id" | "type" | "name" | "label">): Field => ({
  help: "",
  placeholder: "",
  required: false,
  min: null,
  max: null,
  pattern: "",
  patternMessage: "",
  options: [],
  showIf: null,
  ...p,
});

export const TEMPLATES: { id: string; name: string; def: FormDef }[] = [
  {
    id: "intake",
    name: "Clinic patient intake",
    def: {
      version: 1,
      title: "Acme Dental patient intake",
      steps: [
        {
          id: "s1",
          title: "About you",
          fields: [
            f({ id: "f1", type: "text", name: "fullName", label: "Full name", required: true, min: 2, max: 80, placeholder: "Jamie Doe" }),
            f({ id: "f2", type: "date", name: "dateOfBirth", label: "Date of birth", required: true }),
            f({ id: "f3", type: "email", name: "email", label: "Email", required: true, help: "We send appointment reminders here." }),
            f({ id: "f4", type: "tel", name: "phone", label: "Phone", placeholder: "+44 20 7946 0000" }),
          ],
        },
        {
          id: "s2",
          title: "Medical history",
          fields: [
            f({ id: "f5", type: "radio", name: "allergies", label: "Any allergies?", required: true, options: ["No", "Yes"] }),
            f({
              id: "f6",
              type: "textarea",
              name: "allergyDetails",
              label: "Allergy details",
              required: true,
              min: 3,
              max: 500,
              help: "Medicines, latex, foods…",
              showIf: { field: "allergies", op: "equals", value: "Yes" },
            }),
            f({ id: "f7", type: "checkbox", name: "hasInsurance", label: "I have dental insurance" }),
            f({
              id: "f8",
              type: "select",
              name: "insurer",
              label: "Insurer",
              required: true,
              options: ["Example Health", "Sample Mutual", "Other"],
              showIf: { field: "hasInsurance", op: "isChecked", value: "" },
            }),
            f({
              id: "f9",
              type: "text",
              name: "policyNumber",
              label: "Policy number",
              required: true,
              pattern: "^[A-Z]{2}[0-9]{6}$",
              patternMessage: "Two capital letters then six digits, like AB123456.",
              placeholder: "AB123456",
              showIf: { field: "hasInsurance", op: "isChecked", value: "" },
            }),
          ],
        },
        {
          id: "s3",
          title: "Consent",
          fields: [
            f({ id: "f10", type: "number", name: "painLevel", label: "Current pain level (0–10)", min: 0, max: 10 }),
            f({ id: "f11", type: "checkbox", name: "consent", label: "I agree to the privacy notice", required: true }),
          ],
        },
      ],
    },
  },
  {
    id: "job",
    name: "Job application",
    def: {
      version: 1,
      title: "Frontend developer application",
      steps: [
        {
          id: "s1",
          title: "Contact",
          fields: [
            f({ id: "f1", type: "text", name: "name", label: "Name", required: true, min: 2, max: 80 }),
            f({ id: "f2", type: "email", name: "email", label: "Email", required: true }),
            f({ id: "f3", type: "text", name: "portfolio", label: "Portfolio URL", pattern: "^https://", patternMessage: "Start with https://", placeholder: "https://" }),
          ],
        },
        {
          id: "s2",
          title: "Experience",
          fields: [
            f({ id: "f4", type: "number", name: "years", label: "Years of experience", required: true, min: 0, max: 50 }),
            f({ id: "f5", type: "select", name: "framework", label: "Main framework", required: true, options: ["React", "Vue", "Angular", "Svelte", "Other"] }),
            f({ id: "f6", type: "text", name: "otherFramework", label: "Which framework?", required: true, showIf: { field: "framework", op: "equals", value: "Other" } }),
            f({ id: "f7", type: "textarea", name: "pitch", label: "Why this role?", required: true, min: 50, max: 1000, help: "At least 50 characters." }),
          ],
        },
      ],
    },
  },
  {
    id: "event",
    name: "Event registration",
    def: {
      version: 1,
      title: "Example Meetup registration",
      steps: [
        {
          id: "s1",
          title: "Registration",
          fields: [
            f({ id: "f1", type: "text", name: "name", label: "Name", required: true }),
            f({ id: "f2", type: "email", name: "email", label: "Email", required: true }),
            f({ id: "f3", type: "radio", name: "ticket", label: "Ticket", required: true, options: ["In person", "Online"] }),
            f({ id: "f4", type: "select", name: "diet", label: "Dietary needs", options: ["None", "Vegetarian", "Vegan", "Halal", "Gluten free"], showIf: { field: "ticket", op: "equals", value: "In person" } }),
            f({ id: "f5", type: "number", name: "guests", label: "Extra guests", min: 0, max: 3 }),
          ],
        },
      ],
    },
  },
];
app/lab/form-builder/Demo.tsx637 lines
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { Preview } from "./Preview";
import {
  CONDITION_OPS,
  FIELD_META,
  FIELD_TYPES,
  allFields,
  lintDef,
  moveField,
  newField,
  parseImport,
  toZodSource,
  type Field,
  type FieldType,
  type FormDef,
} from "./schema";
import { TEMPLATES } from "./templates";

const KEY = "lab:form-builder:v1";
const initial = () => structuredClone(TEMPLATES[0]!.def);

type DragItem = { kind: "field"; id: string; label: string } | { kind: "new"; type: FieldType; label: string };
type Drag = { item: DragItem; x: number; y: number; startX: number; startY: number; active: boolean; target: { step: string; index: number } | null };

let idCounter = 0;
const makeId = (p: string) => `${p}${Date.now().toString(36)}${(idCounter++).toString(36)}`;

export default function Demo() {
  const [def, setDef] = useState<FormDef>(initial);
  const [ready, setReady] = useState(false);
  const [selected, setSelected] = useState<string | null>("f1");
  const [tab, setTab] = useState<"preview" | "schema" | "json">("preview");
  const [importText, setImportText] = useState("");
  const [importMsg, setImportMsg] = useState<{ ok: boolean; text: string } | null>(null);
  const [copied, setCopied] = useState<string | null>(null);
  const [status, setStatus] = useState("");
  const [drag, setDrag] = useState<Drag | null>(null);
  const stepEls = useRef(new Map<string, HTMLElement>());
  const justDragged = useRef(false);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) {
        const r = parseImport(raw);
        if (r.ok) setDef(r.def);
      }
    } catch {
      /* ignore */
    }
    setReady(true);
  }, []);
  useEffect(() => {
    if (!ready) return;
    try {
      localStorage.setItem(KEY, JSON.stringify(def));
    } catch {
      /* ignore */
    }
  }, [def, ready]);

  const fields = allFields(def);
  const field = fields.find((f) => f.id === selected) ?? null;
  const problems = useMemo(() => lintDef(def), [def]);
  const source = useMemo(() => toZodSource(def), [def]);
  const json = useMemo(() => JSON.stringify(def, null, 2), [def]);
  const stepOf = (id: string) => def.steps.find((s) => s.fields.some((f) => f.id === id));

  /* ---------- mutations ---------- */
  const updateField = (id: string, patch: Partial<Field>) =>
    setDef((d) => ({ ...d, steps: d.steps.map((s) => ({ ...s, fields: s.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)) })) }));

  function addField(type: FieldType, stepId?: string, index?: number) {
    const target = stepId ?? (selected && stepOf(selected)?.id) ?? def.steps.at(-1)?.id;
    if (!target) return;
    const f = newField(type, makeId("f"), new Set(fields.map((x) => x.name)));
    setDef((d) => {
      const steps = d.steps.map((s) => {
        if (s.id !== target) return s;
        const list = [...s.fields];
        list.splice(index ?? list.length, 0, f);
        return { ...s, fields: list };
      });
      return { ...d, steps };
    });
    setSelected(f.id);
    setStatus(`Added ${FIELD_META[type].label} field.`);
  }

  function removeField(id: string) {
    const f = fields.find((x) => x.id === id);
    setDef((d) => ({
      ...d,
      steps: d.steps.map((s) => ({
        ...s,
        // Removing a field also clears conditions that pointed at it.
        fields: s.fields.filter((x) => x.id !== id).map((x) => (x.showIf?.field === f?.name ? { ...x, showIf: null } : x)),
      })),
    }));
    if (selected === id) setSelected(null);
    setStatus(`Removed “${f?.label}”.`);
  }

  /** Move by one slot, crossing into the neighbouring step at the edges. Used by buttons and Alt+Arrow. */
  function nudge(id: string, dir: -1 | 1) {
    const si = def.steps.findIndex((s) => s.fields.some((f) => f.id === id));
    const step = def.steps[si];
    if (!step) return;
    const i = step.fields.findIndex((f) => f.id === id);
    const j = i + dir;
    if (j >= 0 && j < step.fields.length) {
      setDef((d) => moveField(d, id, step.id, j));
      setStatus(`Moved to position ${j + 1} in ${step.title}.`);
    } else {
      const other = def.steps[si + dir];
      if (!other) return;
      setDef((d) => moveField(d, id, other.id, dir === 1 ? 0 : other.fields.length));
      setStatus(`Moved to step “${other.title}”.`);
    }
  }

  function addStep() {
    const id = makeId("s");
    setDef((d) => ({ ...d, steps: [...d.steps, { id, title: `Step ${d.steps.length + 1}`, fields: [] }] }));
  }
  function moveStep(id: string, dir: -1 | 1) {
    setDef((d) => {
      const i = d.steps.findIndex((s) => s.id === id);
      const j = i + dir;
      if (i < 0 || j < 0 || j >= d.steps.length) return d;
      const steps = [...d.steps];
      [steps[i], steps[j]] = [steps[j]!, steps[i]!];
      return { ...d, steps };
    });
  }
  function removeStep(id: string) {
    if (def.steps.length <= 1) return;
    setDef((d) => ({ ...d, steps: d.steps.filter((s) => s.id !== id) }));
  }

  function loadTemplate(id: string) {
    const t = TEMPLATES.find((x) => x.id === id);
    if (!t) return;
    setDef(structuredClone(t.def));
    setSelected(t.def.steps[0]?.fields[0]?.id ?? null);
    setStatus(`Loaded template “${t.name}”.`);
    try {
      sessionStorage.removeItem(`${KEY}:answers`);
    } catch {
      /* ignore */
    }
  }

  async function copy(text: string, what: string) {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(what);
      setTimeout(() => setCopied(null), 1500);
    } catch {
      setCopied("blocked");
    }
  }

  function download() {
    const blob = new Blob([json], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `${def.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.json`;
    a.click();
    URL.revokeObjectURL(a.href);
  }

  function doImport() {
    const r = parseImport(importText);
    if (!r.ok) return setImportMsg({ ok: false, text: r.error });
    setDef(r.def);
    setSelected(r.def.steps[0]?.fields[0]?.id ?? null);
    const lint = lintDef(r.def);
    setImportMsg({ ok: true, text: `Imported “${r.def.title}”.${lint.length ? ` ${lint.length} warning(s) below.` : ""}` });
  }

  /* ---------- pointer drag (palette → outline, and reordering) ---------- */
  function startDrag(e: React.PointerEvent, item: DragItem) {
    if (e.button !== 0 || (e.pointerType !== "mouse" && !(e.target as HTMLElement).closest("[data-grip]"))) return;
    setDrag({ item, x: e.clientX, y: e.clientY, startX: e.clientX, startY: e.clientY, active: false, target: null });
  }

  useEffect(() => {
    if (!drag) return;
    const hit = (x: number, y: number) => {
      for (const [stepId, el] of stepEls.current) {
        const r = el.getBoundingClientRect();
        if (x < r.left || x > r.right || y < r.top - 12 || y > r.bottom + 12) continue;
        const rows = [...el.querySelectorAll<HTMLElement>("[data-field-row]")].filter((row) => drag.item.kind !== "field" || row.dataset.fieldRow !== drag.item.id);
        let index = 0;
        for (const row of rows) {
          const rr = row.getBoundingClientRect();
          if (y > rr.top + rr.height / 2) index++;
        }
        return { step: stepId, index };
      }
      return null;
    };
    const move = (e: PointerEvent) => {
      const active = drag.active || Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 5;
      if (active) e.preventDefault();
      setDrag({ ...drag, x: e.clientX, y: e.clientY, active, target: active ? hit(e.clientX, e.clientY) : null });
    };
    const up = () => {
      if (drag.active && drag.target) {
        if (drag.item.kind === "new") addField(drag.item.type, drag.target.step, drag.target.index);
        else {
          const { step, index } = drag.target;
          const id = drag.item.id;
          setDef((d) => moveField(d, id, step, index));
          setStatus(`Moved “${drag.item.label}”.`);
        }
      } else if (!drag.active && drag.item.kind === "field") {
        setSelected(drag.item.id);
      }
      if (drag.active) {
        justDragged.current = true;
        setTimeout(() => (justDragged.current = false), 0);
      }
      setDrag(null);
    };
    const cancel = () => setDrag(null);
    window.addEventListener("pointermove", move, { passive: false });
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", cancel);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", cancel);
    };
  });

  const input = "mt-1 w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm outline-none focus:border-accent";
  const small = "rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-fg-muted hover:border-accent hover:text-accent disabled:opacity-30";
  const earlier = field ? fields.slice(0, fields.findIndex((f) => f.id === field.id)) : [];

  return (
    <div className="grid lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
      {/* ================= builder ================= */}
      <div className="space-y-4 border-b border-border p-4 lg:border-r lg:border-b-0">
        <div className="flex flex-wrap items-end gap-2">
          <label className="min-w-[12rem] flex-1">
            <span className="text-xs font-medium text-fg-muted">Form title</span>
            <input value={def.title} onChange={(e) => setDef({ ...def, title: e.target.value })} maxLength={80} className={input} />
          </label>
          <label>
            <span className="text-xs font-medium text-fg-muted">Template</span>
            <select value="" onChange={(e) => loadTemplate(e.target.value)} className={input}>
              <option value="">Load…</option>
              {TEMPLATES.map((t) => (
                <option key={t.id} value={t.id}>
                  {t.name}
                </option>
              ))}
            </select>
          </label>
          <button type="button" onClick={() => loadTemplate("intake")} className="rounded-md border border-border px-2.5 py-1.5 font-mono text-xs hover:border-danger hover:text-danger">
            reset demo
          </button>
        </div>

        <section aria-labelledby="palette-h">
          <h3 id="palette-h" className="font-mono text-xs font-semibold text-fg-muted">
            fields <span className="font-normal">· click to add, or drag into a step</span>
          </h3>
          <div className="mt-2 flex flex-wrap gap-1.5">
            {FIELD_TYPES.map((t) => (
              <button
                key={t}
                type="button"
                onPointerDown={(e) => startDrag(e, { kind: "new", type: t, label: FIELD_META[t].label })}
                onClick={() => !justDragged.current && addField(t)}
                className="flex items-center gap-1.5 rounded-md border border-border bg-bg-elev px-2 py-1 text-xs select-none hover:border-accent"
              >
                <span data-grip className="w-4 touch-none text-center font-mono text-accent" aria-hidden="true">
                  {FIELD_META[t].icon}
                </span>
                {FIELD_META[t].label}
              </button>
            ))}
          </div>
        </section>

        <section aria-labelledby="outline-h" className="space-y-3">
          <h3 id="outline-h" className="font-mono text-xs font-semibold text-fg-muted">
            steps <span className="font-normal">· drag rows, or Alt+↑/↓ on a row</span>
          </h3>
          {def.steps.map((s, si) => {
            const isTarget = drag?.active && drag.target?.step === s.id;
            const rows = drag?.active && drag.item.kind === "field" ? s.fields.filter((f) => f.id !== (drag.item as { id: string }).id) : s.fields;
            return (
              <div key={s.id} className={`rounded-lg border ${isTarget ? "border-accent" : "border-border"} bg-bg-elev`}>
                <div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
                  <span className="font-mono text-[11px] text-fg-muted">{si + 1}.</span>
                  <label className="sr-only" htmlFor={`step-${s.id}`}>
                    Step {si + 1} title
                  </label>
                  <input
                    id={`step-${s.id}`}
                    value={s.title}
                    maxLength={60}
                    onChange={(e) => setDef({ ...def, steps: def.steps.map((x) => (x.id === s.id ? { ...x, title: e.target.value } : x)) })}
                    className="min-w-0 flex-1 rounded bg-transparent px-1 text-sm font-medium outline-none focus:bg-bg"
                  />
                  <button type="button" className={small} disabled={si === 0} onClick={() => moveStep(s.id, -1)} aria-label={`Move step ${s.title} up`}>
                    ↑
                  </button>
                  <button type="button" className={small} disabled={si === def.steps.length - 1} onClick={() => moveStep(s.id, 1)} aria-label={`Move step ${s.title} down`}>
                    ↓
                  </button>
                  <button type="button" className={small} disabled={def.steps.length <= 1} onClick={() => removeStep(s.id)} aria-label={`Delete step ${s.title}`}>
                    ×
                  </button>
                </div>
                <ul
                  ref={(el) => {
                    if (el) stepEls.current.set(s.id, el);
                    else stepEls.current.delete(s.id);
                  }}
                  className="min-h-10 space-y-1 p-1.5"
                >
                  {rows.map((f, i) => (
                    <li key={f.id} data-field-row={f.id}>
                      {isTarget && drag.target?.index === i && <div className="mb-1 h-0.5 rounded bg-accent" aria-hidden="true" />}
                      <div
                        onPointerDown={(e) => startDrag(e, { kind: "field", id: f.id, label: f.label })}
                        className={`flex items-center gap-2 rounded-md border px-2 py-1.5 ${selected === f.id ? "border-accent bg-accent-soft/50" : "border-transparent hover:border-border"}`}
                      >
                        <span data-grip className="cursor-grab touch-none font-mono text-xs text-fg-muted" aria-hidden="true">
                          ⠿
                        </span>
                        <button
                          type="button"
                          onClick={() => setSelected(f.id)}
                          onKeyDown={(e) => {
                            if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
                              e.preventDefault();
                              nudge(f.id, e.key === "ArrowUp" ? -1 : 1);
                              requestAnimationFrame(() => document.querySelector<HTMLElement>(`[data-field-row="${f.id}"] button`)?.focus());
                            } else if (e.key === "Delete") {
                              removeField(f.id);
                            }
                          }}
                          aria-pressed={selected === f.id}
                          className="flex min-w-0 flex-1 items-center gap-2 text-left text-sm select-none"
                        >
                          <span className="w-5 shrink-0 text-center font-mono text-xs text-accent" aria-hidden="true">
                            {FIELD_META[f.type].icon}
                          </span>
                          <span className="truncate">
                            {f.label}
                            {f.required && <span className="text-danger"> *</span>}
                          </span>
                          <span className="ml-auto hidden font-mono text-[11px] text-fg-muted sm:inline">{f.name}</span>
                          {f.showIf && <span className="rounded bg-warn-bg px-1 font-mono text-[10px] text-warn-fg">if</span>}
                        </button>
                        <button type="button" className={small} onClick={() => nudge(f.id, -1)} aria-label={`Move ${f.label} up`}>
                          ↑
                        </button>
                        <button type="button" className={small} onClick={() => nudge(f.id, 1)} aria-label={`Move ${f.label} down`}>
                          ↓
                        </button>
                        <button type="button" className={small} onClick={() => removeField(f.id)} aria-label={`Delete ${f.label}`}>
                          ×
                        </button>
                      </div>
                    </li>
                  ))}
                  {isTarget && (drag.target?.index ?? 0) >= rows.length && <li className="h-0.5 rounded bg-accent" aria-hidden="true" />}
                  {rows.length === 0 && !isTarget && <li className="px-2 py-2 text-xs text-fg-muted">Empty step. Add or drop a field here.</li>}
                </ul>
              </div>
            );
          })}
          <button type="button" onClick={addStep} disabled={def.steps.length >= 10} className="w-full rounded-lg border border-dashed border-border py-1.5 text-sm text-fg-muted hover:border-accent hover:text-accent">
            + Add step
          </button>
        </section>

        {problems.length > 0 && (
          <ul className="space-y-1 rounded-md border border-warn-fg/40 bg-warn-bg p-2 text-xs text-warn-fg" aria-label="Form warnings">
            {problems.map((p) => (
              <li key={p}>⚠ {p}</li>
            ))}
          </ul>
        )}

        {/* ---------- properties ---------- */}
        <section aria-labelledby="props-h" className="rounded-lg border border-border bg-bg-elev p-3">
          <h3 id="props-h" className="font-mono text-xs font-semibold text-fg-muted">
            properties {field && <span className="text-accent">· {field.name}</span>}
          </h3>
          {!field ? (
            <p className="mt-2 text-sm text-fg-muted">Select a field to edit it.</p>
          ) : (
            <div key={field.id} className="mt-2 grid gap-3 sm:grid-cols-2">
              <label className="block">
                <span className="text-xs font-medium">Label</span>
                <input value={field.label} maxLength={80} onChange={(e) => updateField(field.id, { label: e.target.value })} className={input} />
              </label>
              <label className="block">
                <span className="text-xs font-medium">Name (key)</span>
                <input
                  value={field.name}
                  onChange={(e) => updateField(field.id, { name: e.target.value.replace(/[^a-zA-Z0-9_]/g, "") })}
                  className={input + " font-mono"}
                  aria-invalid={fields.some((f) => f.id !== field.id && f.name === field.name) || !field.name}
                />
              </label>
              <label className="block">
                <span className="text-xs font-medium">Type</span>
                <select
                  value={field.type}
                  onChange={(e) => {
                    const type = e.target.value as FieldType;
                    updateField(field.id, { type, options: FIELD_META[type].hasOptions && !field.options.length ? ["Option A", "Option B"] : field.options });
                  }}
                  className={input}
                >
                  {FIELD_TYPES.map((t) => (
                    <option key={t} value={t}>
                      {FIELD_META[t].label}
                    </option>
                  ))}
                </select>
              </label>
              <label className="flex items-center gap-2 self-end pb-2 text-sm">
                <input type="checkbox" checked={field.required} onChange={(e) => updateField(field.id, { required: e.target.checked })} className="size-4 accent-[var(--accent)]" />
                Required
              </label>
              <label className="block sm:col-span-2">
                <span className="text-xs font-medium">Help text</span>
                <input value={field.help} onChange={(e) => updateField(field.id, { help: e.target.value })} className={input} />
              </label>
              {!["checkbox", "radio", "select"].includes(field.type) && (
                <label className="block sm:col-span-2">
                  <span className="text-xs font-medium">Placeholder</span>
                  <input value={field.placeholder} onChange={(e) => updateField(field.id, { placeholder: e.target.value })} className={input} />
                </label>
              )}
              {(FIELD_META[field.type].hasLength || FIELD_META[field.type].hasRange) && (
                <>
                  {(["min", "max"] as const).map((k) => (
                    <label key={k} className="block">
                      <span className="text-xs font-medium">
                        {k === "min" ? "Min" : "Max"} {FIELD_META[field.type].hasRange ? "value" : "length"}
                      </span>
                      <input
                        type="number"
                        value={field[k] ?? ""}
                        onChange={(e) => updateField(field.id, { [k]: e.target.value === "" ? null : Number(e.target.value) })}
                        className={input}
                      />
                    </label>
                  ))}
                </>
              )}
              {FIELD_META[field.type].hasPattern && (
                <>
                  <label className="block">
                    <span className="text-xs font-medium">Regex pattern</span>
                    <input value={field.pattern} onChange={(e) => updateField(field.id, { pattern: e.target.value })} placeholder={field.type === "tel" ? "default phone rule" : "^[A-Z]{2}\\d{6}$"} className={input + " font-mono"} />
                  </label>
                  <label className="block">
                    <span className="text-xs font-medium">Pattern message</span>
                    <input value={field.patternMessage} onChange={(e) => updateField(field.id, { patternMessage: e.target.value })} className={input} />
                  </label>
                </>
              )}
              {FIELD_META[field.type].hasOptions && (
                <label className="block sm:col-span-2">
                  <span className="text-xs font-medium">Options (one per line)</span>
                  <textarea
                    rows={3}
                    value={field.options.join("\n")}
                    onChange={(e) => updateField(field.id, { options: e.target.value.split("\n").map((o) => o.trimStart()).filter((o, i, a) => o !== "" || i === a.length - 1) })}
                    onBlur={() => updateField(field.id, { options: [...new Set(field.options.map((o) => o.trim()).filter(Boolean))] })}
                    className={input}
                  />
                </label>
              )}
              <fieldset className="rounded-md border border-border p-2 sm:col-span-2">
                <legend className="px-1 text-xs font-medium">Conditional logic</legend>
                {earlier.length === 0 ? (
                  <p className="text-xs text-fg-muted">The first field cannot depend on another field.</p>
                ) : (
                  <div className="grid gap-2 sm:grid-cols-3">
                    <label className="block">
                      <span className="text-xs text-fg-muted">Show only when</span>
                      <select
                        value={field.showIf?.field ?? ""}
                        onChange={(e) =>
                          updateField(field.id, { showIf: e.target.value ? { field: e.target.value, op: field.showIf?.op ?? "equals", value: field.showIf?.value ?? "" } : null })
                        }
                        className={input}
                      >
                        <option value="">Always show</option>
                        {earlier.map((f) => (
                          <option key={f.id} value={f.name}>
                            {f.label}
                          </option>
                        ))}
                      </select>
                    </label>
                    {field.showIf && (
                      <>
                        <label className="block">
                          <span className="text-xs text-fg-muted">Condition</span>
                          <select value={field.showIf.op} onChange={(e) => updateField(field.id, { showIf: { ...field.showIf!, op: e.target.value as (typeof CONDITION_OPS)[number] } })} className={input}>
                            {CONDITION_OPS.map((o) => (
                              <option key={o} value={o}>
                                {o}
                              </option>
                            ))}
                          </select>
                        </label>
                        {(field.showIf.op === "equals" || field.showIf.op === "notEquals") && (
                          <label className="block">
                            <span className="text-xs text-fg-muted">Value</span>
                            <input value={field.showIf.value} onChange={(e) => updateField(field.id, { showIf: { ...field.showIf!, value: e.target.value } })} className={input} />
                          </label>
                        )}
                      </>
                    )}
                  </div>
                )}
              </fieldset>
            </div>
          )}
        </section>
      </div>

      {/* ================= output ================= */}
      <div className="min-w-0 bg-bg-elev">
        <div role="tablist" aria-label="Output" className="flex border-b border-border">
          {(
            [
              ["preview", "Live preview"],
              ["schema", "zod schema"],
              ["json", "JSON"],
            ] as const
          ).map(([id, label]) => (
            <button
              key={id}
              role="tab"
              id={`fb-tab-${id}`}
              aria-selected={tab === id}
              aria-controls={`fb-panel-${id}`}
              tabIndex={tab === id ? 0 : -1}
              onClick={() => setTab(id)}
              onKeyDown={(e) => {
                const ids = ["preview", "schema", "json"] as const;
                const i = ids.indexOf(id);
                if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
                  const next = ids[(i + (e.key === "ArrowRight" ? 1 : 2)) % 3]!;
                  setTab(next);
                  document.getElementById(`fb-tab-${next}`)?.focus();
                }
              }}
              className={`border-b-2 px-4 py-2.5 font-mono text-xs ${tab === id ? "border-accent text-fg" : "border-transparent text-fg-muted hover:text-fg"}`}
            >
              {label}
            </button>
          ))}
        </div>
        <div role="tabpanel" id={`fb-panel-${tab}`} aria-labelledby={`fb-tab-${tab}`} className="min-w-0">
          {tab === "preview" && <Preview def={def} />}
          {tab === "schema" && (
            <div className="space-y-2 p-4">
              <div className="flex items-center justify-between">
                <p className="text-xs text-fg-muted">Generated from the definition on every change. The preview validates with the same rules.</p>
                <button type="button" onClick={() => copy(source, "schema")} className={small}>
                  {copied === "schema" ? "copied ✓" : "copy"}
                </button>
              </div>
              <pre className="max-h-[32rem] overflow-auto rounded-md bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed text-[#e4e4e7]">{source}</pre>
            </div>
          )}
          {tab === "json" && (
            <div className="space-y-3 p-4">
              <div className="flex flex-wrap items-center gap-2">
                <button type="button" onClick={() => copy(json, "json")} className={small}>
                  {copied === "json" ? "copied ✓" : "copy JSON"}
                </button>
                <button type="button" onClick={download} className={small}>
                  download .json
                </button>
              </div>
              <pre className="max-h-64 overflow-auto rounded-md bg-[#0b0b0d] p-3 font-mono text-[12px] text-[#a1a1aa]">{json}</pre>
              <label className="block">
                <span className="text-sm font-medium">Import a definition</span>
                <textarea
                  rows={5}
                  value={importText}
                  onChange={(e) => setImportText(e.target.value)}
                  placeholder='{"version":1,"title":"…","steps":[…]}'
                  className={input + " font-mono text-xs"}
                  aria-describedby={importMsg ? "fb-import-msg" : undefined}
                />
              </label>
              <div className="flex flex-wrap items-center gap-2">
                <button type="button" onClick={doImport} className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90">
                  Import
                </button>
                <button type="button" onClick={() => setImportText(json)} className={small}>
                  paste current
                </button>
              </div>
              {importMsg && (
                <p id="fb-import-msg" role="status" className={`text-sm ${importMsg.ok ? "text-accent" : "text-danger"}`}>
                  {importMsg.text}
                </p>
              )}
            </div>
          )}
        </div>
      </div>

      {drag?.active && (
        <div className="pointer-events-none fixed z-50 rounded-md border border-accent bg-bg-elev px-3 py-1.5 text-sm shadow-xl" style={{ left: drag.x + 8, top: drag.y + 8 }} aria-hidden="true">
          {drag.item.label}
        </div>
      )}
      <p className="sr-only" aria-live="polite">
        {status}
      </p>
    </div>
  );
}
app/lab/form-builder/Preview.tsx260 lines
"use client";

import { useEffect, useRef, useState } from "react";
import { isVisible, validateStep, type Field, type FormDef, type Values } from "./schema";

const ANSWERS_KEY = "lab:form-builder:v1:answers";

/** Renders a FormDef as a working multi-step form, validated step by step with the generated zod schema. */
export function Preview({ def }: { def: FormDef }) {
  const [values, setValues] = useState<Values>({});
  const [stepIndex, setStepIndex] = useState(0);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
  const [status, setStatus] = useState("");
  const formRef = useRef<HTMLFormElement>(null);
  const loaded = useRef(false);

  // Draft answers survive a refresh (sessionStorage), but not a new tab.
  useEffect(() => {
    try {
      const raw = sessionStorage.getItem(ANSWERS_KEY);
      if (raw) setValues(JSON.parse(raw) as Values);
    } catch {
      /* ignore */
    }
    loaded.current = true;
  }, []);
  useEffect(() => {
    if (!loaded.current) return;
    try {
      sessionStorage.setItem(ANSWERS_KEY, JSON.stringify(values));
    } catch {
      /* ignore */
    }
  }, [values]);

  const steps = def.steps;
  const index = Math.min(stepIndex, steps.length - 1);
  const step = steps[index];

  function focusFirstError(errs: Record<string, string>) {
    const first = step?.fields.find((f) => errs[f.name]);
    if (first) requestAnimationFrame(() => formRef.current?.querySelector<HTMLElement>(`[name="${first.name}"]`)?.focus());
  }

  function next(e: React.FormEvent) {
    e.preventDefault();
    if (!step) return;
    const r = validateStep(step, values);
    if (!r.ok) {
      setErrors(r.errors);
      const n = Object.keys(r.errors).length;
      setStatus(`${n} field${n === 1 ? " needs" : "s need"} attention.`);
      focusFirstError(r.errors);
      return;
    }
    setErrors({});
    if (index < steps.length - 1) {
      setStepIndex(index + 1);
      setStatus(`Step ${index + 2} of ${steps.length}: ${steps[index + 1]!.title}`);
      return;
    }
    // Final submit: parse every step again so numbers are coerced and hidden fields are dropped.
    const out: Record<string, unknown> = {};
    for (const s of steps) {
      const res = validateStep(s, values);
      if (!res.ok) {
        setStepIndex(steps.indexOf(s));
        setErrors(res.errors);
        return;
      }
      Object.assign(out, res.data);
    }
    setSubmitted(out);
    setStatus("Form submitted.");
  }

  function setValue(name: string, v: string | boolean) {
    setValues((prev) => ({ ...prev, [name]: v }));
    if (errors[name])
      setErrors((prev) => {
        const copy = { ...prev };
        delete copy[name];
        return copy;
      });
  }

  function restart() {
    setValues({});
    setErrors({});
    setSubmitted(null);
    setStepIndex(0);
  }

  if (!step) return <p className="p-4 text-sm text-fg-muted">Add a step to preview the form.</p>;

  if (submitted) {
    return (
      <div className="space-y-3 p-4">
        <p role="status" className="rounded-md border border-accent/40 bg-accent-soft px-3 py-2 text-sm text-accent">
          ✓ Submitted. This is the object your API would receive after zod parsing.
        </p>
        <pre className="max-h-72 overflow-auto rounded-md bg-[#0b0b0d] p-3 font-mono text-[12px] text-[#4ade80]">
          {JSON.stringify(submitted, null, 2)}
        </pre>
        <button type="button" onClick={restart} className="rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent hover:text-accent">
          Fill it in again
        </button>
      </div>
    );
  }

  const visible = step.fields.filter((f) => isVisible(f, values));

  return (
    <form ref={formRef} onSubmit={next} noValidate className="space-y-4 p-4">
      <div>
        <h3 className="text-lg font-semibold">{def.title}</h3>
        {steps.length > 1 && (
          <>
            <ol className="mt-3 flex gap-1" aria-label="Progress">
              {steps.map((s, i) => (
                <li
                  key={s.id}
                  className={`h-1.5 flex-1 rounded-full ${i <= index ? "bg-accent" : "bg-bg-muted"}`}
                  aria-current={i === index ? "step" : undefined}
                >
                  <span className="sr-only">
                    {s.title}
                    {i < index ? " (done)" : i === index ? " (current)" : ""}
                  </span>
                </li>
              ))}
            </ol>
            <p className="mt-2 font-mono text-xs text-fg-muted">
              Step {index + 1} of {steps.length} · {step.title}
            </p>
          </>
        )}
      </div>

      {Object.keys(errors).length > 0 && (
        <div role="alert" className="rounded-md border border-danger/50 px-3 py-2 text-sm text-danger">
          Please fix {Object.keys(errors).length} field{Object.keys(errors).length === 1 ? "" : "s"} before continuing.
        </div>
      )}

      {visible.length === 0 && <p className="text-sm text-fg-muted">This step has no visible fields yet.</p>}
      {visible.map((f) => (
        <FieldInput key={f.id} field={f} value={values[f.name]} error={errors[f.name]} onChange={(v) => setValue(f.name, v)} />
      ))}

      <div className="flex items-center gap-2 border-t border-border pt-4">
        {index > 0 && (
          <button
            type="button"
            onClick={() => {
              setErrors({});
              setStepIndex(index - 1);
            }}
            className="rounded-md border border-border px-4 py-2 text-sm hover:border-fg-muted"
          >
            Back
          </button>
        )}
        <button type="submit" className="ml-auto rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90">
          {index < steps.length - 1 ? "Next" : "Submit"}
        </button>
      </div>
      <p className="sr-only" aria-live="polite">
        {status}
      </p>
    </form>
  );
}

function FieldInput({ field: f, value, error, onChange }: { field: Field; value: string | boolean | undefined; error?: string; onChange: (v: string | boolean) => void }) {
  const id = `pv-${f.id}`;
  const describedBy = [f.help && `${id}-help`, error && `${id}-err`].filter(Boolean).join(" ") || undefined;
  const base = `mt-1 w-full rounded-md border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent ${error ? "border-danger" : "border-border"}`;
  const str = typeof value === "string" ? value : "";
  const common = { id, name: f.name, "aria-invalid": !!error, "aria-describedby": describedBy, "aria-required": f.required || undefined };
  const label = (
    <>
      {f.label}
      {f.required ? <span className="text-danger"> *</span> : <span className="text-xs font-normal text-fg-muted"> (optional)</span>}
    </>
  );
  const help = f.help && (
    <p id={`${id}-help`} className="mt-1 text-xs text-fg-muted">
      {f.help}
    </p>
  );
  const err = error && (
    <p id={`${id}-err`} className="mt-1 text-xs text-danger">
      {error}
    </p>
  );

  if (f.type === "checkbox") {
    return (
      <div>
        <label className="flex items-start gap-2 text-sm">
          <input type="checkbox" {...common} checked={value === true} onChange={(e) => onChange(e.target.checked)} className="mt-0.5 size-4 accent-[var(--accent)]" />
          <span>{label}</span>
        </label>
        {help}
        {err}
      </div>
    );
  }
  if (f.type === "radio") {
    return (
      <fieldset aria-describedby={describedBy} aria-invalid={!!error}>
        <legend className="text-sm font-medium">{label}</legend>
        <div className="mt-1 flex flex-wrap gap-3">
          {f.options.map((o, i) => (
            <label key={o} className="flex items-center gap-1.5 text-sm">
              <input type="radio" name={f.name} id={i === 0 ? id : undefined} value={o} checked={str === o} onChange={() => onChange(o)} className="accent-[var(--accent)]" />
              {o}
            </label>
          ))}
        </div>
        {help}
        {err}
      </fieldset>
    );
  }
  return (
    <div>
      <label htmlFor={id} className="block text-sm font-medium">
        {label}
      </label>
      {f.type === "textarea" ? (
        <textarea {...common} rows={3} value={str} placeholder={f.placeholder} onChange={(e) => onChange(e.target.value)} className={base} />
      ) : f.type === "select" ? (
        <select {...common} value={str} onChange={(e) => onChange(e.target.value)} className={base}>
          <option value="">Select…</option>
          {f.options.map((o) => (
            <option key={o}>{o}</option>
          ))}
        </select>
      ) : (
        <input
          {...common}
          type={f.type === "number" ? "text" : f.type}
          inputMode={f.type === "number" ? "decimal" : undefined}
          value={str}
          placeholder={f.placeholder}
          onChange={(e) => onChange(e.target.value)}
          className={base}
        />
      )}
      {help}
      {f.type === "textarea" && f.max !== null && <p className="mt-1 text-right font-mono text-[11px] text-fg-muted">{str.length}/{f.max}</p>}
      {err}
    </div>
  );
}