{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "record-form",
  "title": "Record Form",
  "author": "Zubeyr <https://zubeyr.dev>",
  "description": "A schema-driven CRM form built on react-hook-form: describe sections and fields as data and get accordion, tab, card or step-by-step layouts, an edit mode and a click-to-edit view mode, 17 field types including people pickers, files, time, month, lists and editable tables with totals, calculated fields, show/hide and required-when conditions, validation rules through one resolver that also takes Zod, Valibot or ArkType, uploads with progress and retry, role permissions, drafts, autosave, a leave warning, translations and dayjs-powered locale dates.",
  "dependencies": [
    "dayjs",
    "react-hook-form",
    "react-dropzone"
  ],
  "registryDependencies": [
    "https://tomei.zubeyr.dev/ui/r/input.json",
    "https://tomei.zubeyr.dev/ui/r/date-picker.json",
    "https://tomei.zubeyr.dev/ui/r/toast.json",
    "https://tomei.zubeyr.dev/ui/r/popover.json",
    "https://tomei.zubeyr.dev/ui/r/dialog.json"
  ],
  "files": [
    {
      "path": "registry/dashboard/components/record-form/record-form.tsx",
      "content": "'use client'\n\nimport { useRef, useState } from 'react'\nimport {\n  Controller,\n  useFormState,\n  useWatch,\n  type SubmitHandler,\n  type UseFormReturn,\n} from 'react-hook-form'\n\nimport { cn } from '@/lib/utils'\nimport { Field } from '@/registry/dashboard/components/input/input'\n\nimport {\n  ConfigProvider,\n  useFormConfig,\n  useResolvedConfig,\n  type RecordFormSettings,\n} from './context'\nimport {\n  DraftBanner,\n  SaveStatus,\n  useAutosave,\n  useDraft,\n  useLeaveWarning,\n  type AutosaveOptions,\n} from './drafts'\nimport { FieldControl, type FieldTypeRenderer } from './field-control'\nimport { flattenErrors } from './resolver'\nimport {\n  applyComputed,\n  formatValue,\n  isEmptyValue,\n  isReadOnly,\n  isRequired,\n  isVisible,\n  canEdit,\n  type FieldDef,\n  type FieldValue,\n  type FormSection,\n  type FormValues,\n} from './schema'\n\nexport {\n  applyComputed,\n  diffValues,\n  evaluate,\n  formatValue,\n  isEmptyValue,\n  isReadOnly,\n  isRequired,\n  isVisible,\n  normalizeOptions,\n  optionLabel,\n  validateField,\n  validateFields,\n  validateRecord,\n  visibleFields,\n} from './schema'\nexport type {\n  BuiltInFieldType,\n  Change,\n  Condition,\n  CustomFieldType,\n  FieldDef,\n  FieldType,\n  FieldTypeMap,\n  FieldValue,\n  FileItem,\n  FormErrors,\n  FormSection,\n  FormValues,\n  Option,\n  OptionObject,\n  Row,\n  Rule,\n} from './schema'\nexport { FieldControl } from './field-control'\nexport { ChangeList, useFormChanges } from './changes'\nexport { FORM_ERROR, flattenErrors, recordFormResolver } from './resolver'\nexport type { RecordFormResolverOptions, StandardSchema } from './resolver'\nexport { RecordFormProvider, useFormConfig } from './context'\nexport type { RecordFormSettings } from './context'\nexport { createT, en } from './i18n'\nexport type { Messages, Translate } from './i18n'\nexport { xhrUploader } from './upload'\nexport type { Uploader, UploadContext, UploadResult } from './upload'\nexport { DraftBanner, SaveStatus, useAutosave, useDraft, useLeaveWarning } from './drafts'\nexport type { AutosaveOptions, AutosaveState, DraftState } from './drafts'\nexport { canEdit, canView } from './schema'\nexport type { Permissions } from './schema'\nexport type { FieldRenderProps, FieldTypeRenderer } from './field-control'\n\nexport type FormLayout = 'accordion' | 'tabs' | 'cards' | 'steps'\n\ntype RecordFormProps = {\n  form: UseFormReturn<FormValues>\n  sections: FormSection[]\n  onSubmit?: SubmitHandler<FormValues>\n  layout?: FormLayout\n  mode?: 'edit' | 'view'\n  readOnly?: boolean\n  sectionColumns?: 1 | 2\n  fieldTypes?: Record<string, FieldTypeRenderer>\n  searchable?: boolean\n  currency?: string\n  review?: boolean\n  submitLabel?: string\n  draftKey?: string\n  autosave?: AutosaveOptions\n  warnOnLeave?: boolean\n  className?: string\n} & RecordFormSettings\n\nconst NO_TYPES: Record<string, FieldTypeRenderer> = {}\n\nconst gridCols = {\n  1: '',\n  2: '@sm:grid-cols-2',\n  3: '@sm:grid-cols-2 @xl:grid-cols-3',\n  4: '@sm:grid-cols-2 @3xl:grid-cols-4',\n}\n\nconst wide = new Set(['rows', 'group', 'file', 'richtext', 'signature'])\n\nfunction spanClass(field: FieldDef, columns: number) {\n  const span = wide.has(field.type) ? (field.span ?? 'full') : field.span\n  if (!span || span === 1 || columns === 1) return undefined\n  if (span === 'full' || span >= columns) return 'col-span-full'\n  if (span === 2) return '@sm:col-span-2'\n  return '@sm:col-span-full @3xl:col-span-3'\n}\n\nconst button =\n  'inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors disabled:opacity-50'\n\nconst touches = (key: string, name: string) => key === name || key.startsWith(`${name}.`)\n\nexport function RecordForm({\n  form,\n  sections,\n  onSubmit,\n  layout = 'accordion',\n  mode = 'edit',\n  readOnly = false,\n  sectionColumns = 1,\n  fieldTypes = NO_TYPES,\n  searchable = layout !== 'steps',\n  currency = '$',\n  review = true,\n  submitLabel,\n  draftKey,\n  autosave: autosaveOptions,\n  warnOnLeave = true,\n  className,\n  locale: localeProp,\n  messages,\n  translate,\n  roles: rolesProp,\n  upload,\n}: RecordFormProps) {\n  const parent = useFormConfig()\n  const config = useResolvedConfig(\n    { locale: localeProp, messages, translate, roles: rolesProp, upload },\n    parent,\n  )\n  const { t, roles, locale } = config\n  const [query, setQuery] = useState('')\n  const [open, setOpen] = useState(\n    () => new Set(sections.filter((s) => s.defaultOpen).map((s) => s.id)),\n  )\n  const [tab, setTab] = useState(sections[0]?.id)\n  const [step, setStep] = useState(0)\n  const [reached, setReached] = useState(0)\n  const [checking, setChecking] = useState(false)\n  const [editing, setEditing] = useState<{ name: string; before: FieldValue | undefined } | null>(\n    null,\n  )\n  const values = useWatch({ control: form.control }) as FormValues\n  const formState = useFormState({ control: form.control })\n  const errors = flattenErrors(formState.errors)\n  const errorKey = Object.keys(errors).join('|')\n  const [seenErrors, setSeenErrors] = useState(errorKey)\n  const focusedFor = useRef<string | null>(null)\n  const draft = useDraft(form, draftKey)\n  const autosave = useAutosave(form, autosaveOptions)\n  const uploading = Object.values(values).some(\n    (v) =>\n      Array.isArray(v) &&\n      v.some(\n        (item) =>\n          item && typeof item === 'object' && 'status' in item && item.status === 'uploading',\n      ),\n  )\n  useLeaveWarning(warnOnLeave && (formState.isDirty || uploading))\n  const submit = () => (onSubmit ? form.handleSubmit(onSubmit)() : Promise.resolve())\n  const q = query.trim().toLowerCase()\n\n  const shownSections = sections\n    .filter((s) => isVisible(s, values, roles))\n    .map((s) => ({\n      ...s,\n      fields: s.fields\n        .filter((f) => isVisible(f, values, roles))\n        .map((f) => (canEdit(s, roles) ? f : { ...f, readOnly: true })),\n    }))\n    .filter((s) => s.fields.length)\n\n  if (seenErrors !== errorKey) {\n    setSeenErrors(errorKey)\n    const failing = shownSections.filter((s) =>\n      s.fields.some((f) => Object.keys(errors).some((k) => touches(k, f.name))),\n    )\n    if (failing.length) {\n      if (layout === 'tabs' && !failing.some((s) => s.id === tab)) setTab(failing[0].id)\n      if (layout === 'accordion') setOpen(new Set([...open, ...failing.map((s) => s.id)]))\n      if (layout === 'steps') {\n        const first = shownSections.indexOf(failing[0])\n        if (first < step) setStep(first)\n      }\n    }\n  }\n\n  const searched = shownSections\n    .map((s) => ({ ...s, fields: s.fields.filter((f) => f.label.toLowerCase().includes(q)) }))\n    .filter((s) => s.fields.length)\n\n  const valueOf = (field: FieldDef) => (field.compute ? field.compute(values) : values[field.name])\n\n  const errorIn = (field: FieldDef) =>\n    Object.keys(errors).some((k) => errors[k] && touches(k, field.name))\n\n  const stats = (section: FormSection) => {\n    const all = shownSections.find((s) => s.id === section.id)?.fields ?? []\n    const empty = (f: FieldDef) =>\n      fieldTypes[f.type]?.isEmpty?.(valueOf(f)) ?? isEmptyValue(valueOf(f))\n    return {\n      filled: all.filter((f) => !empty(f)).length,\n      total: all.length,\n      hasError: all.some(errorIn),\n    }\n  }\n\n  const syncComputed = () => {\n    const current = form.getValues()\n    const next = applyComputed(sections, current)\n    for (const [name, value] of Object.entries(next))\n      if (next[name] !== current[name]) form.setValue(name, value, { shouldDirty: true })\n  }\n\n  const hint = (field: FieldDef) => {\n    const base = field.hint ?? (field.compute ? t('calculated') : undefined)\n    const counted =\n      field.maxLength !== undefined && (field.type === 'text' || field.type === 'textarea')\n    if (!counted) return base\n    const length = String(values[field.name] ?? '').length\n    return (\n      <span className=\"flex justify-between gap-3\">\n        <span>{base}</span>\n        <span className={cn('tabular-nums', length > field.maxLength! && 'text-destructive')}>\n          {length}/{field.maxLength}\n        </span>\n      </span>\n    )\n  }\n\n  const control = (field: FieldDef) => (\n    <Controller\n      name={field.name}\n      control={form.control}\n      render={({ field: rhf, fieldState }) => {\n        const id = `field-${field.name}`\n        const invalid = fieldState.invalid\n        return (\n          <div\n            className=\"contents\"\n            onBlur={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) rhf.onBlur()\n            }}\n          >\n            <FieldControl\n              id={id}\n              field={field}\n              value={field.compute ? valueOf(field) : rhf.value}\n              values={values}\n              invalid={invalid}\n              disabled={readOnly || isReadOnly(field, values, roles)}\n              describedBy={invalid ? `${id}-error` : field.hint ? `${id}-hint` : undefined}\n              currency={currency}\n              errors={errors}\n              fieldTypes={fieldTypes}\n              onChange={(v) => {\n                rhf.onChange(v)\n                syncComputed()\n              }}\n            />\n          </div>\n        )\n      }}\n    />\n  )\n\n  const viewField = (field: FieldDef, columns: number) => {\n    const locked = readOnly || isReadOnly(field, values, roles)\n    const isEditing = editing?.name === field.name\n    const rich = wide.has(field.type)\n    const value = valueOf(field)\n    const formatted = formatValue(field, value, fieldTypes, { t, locale })\n    const text =\n      field.type === 'currency' && formatted !== '—' ? `${currency}${formatted}` : formatted\n    const error = errors[field.name]\n\n    const finish = (cancel: boolean) => {\n      if (cancel && editing) {\n        form.setValue(field.name, editing.before, { shouldDirty: true })\n        syncComputed()\n      }\n      focusedFor.current = null\n      setEditing(null)\n    }\n\n    return (\n      <div\n        key={field.name}\n        className={cn('flex min-w-0 flex-col gap-1', spanClass(field, columns))}\n      >\n        <span id={`field-${field.name}-label`} className=\"text-xs text-muted-foreground\">\n          {field.label}\n          {isEditing && isRequired(field, values) && <span> *</span>}\n        </span>\n        {isEditing ? (\n          <div\n            className=\"flex flex-col gap-2\"\n            ref={(el) => {\n              if (!el || focusedFor.current === field.name) return\n              focusedFor.current = field.name\n              const target = el.querySelector<HTMLElement>(\n                'input:not([type=file]), textarea, button',\n              )\n              target?.focus()\n              if (target instanceof HTMLInputElement) target.select()\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'Escape') {\n                e.stopPropagation()\n                finish(true)\n              }\n              if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') finish(false)\n            }}\n          >\n            {control(field)}\n            {error && <span className=\"text-xs text-destructive\">{error}</span>}\n            <div className=\"flex gap-1.5\">\n              <button\n                type=\"button\"\n                onClick={() => finish(false)}\n                className={cn(button, 'h-7 bg-primary px-2.5 text-xs text-primary-foreground')}\n              >\n                {t('done')}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => finish(true)}\n                className={cn(button, 'h-7 px-2.5 text-xs text-muted-foreground hover:bg-muted')}\n              >\n                {t('cancel')}\n              </button>\n            </div>\n          </div>\n        ) : rich ? (\n          <div className=\"flex flex-col items-start gap-1.5\">\n            {isEmptyValue(value) ? (\n              <span className=\"text-sm text-muted-foreground\">—</span>\n            ) : (\n              <div className=\"w-full\">\n                <FieldControl\n                  id={`field-${field.name}`}\n                  field={field}\n                  value={value}\n                  values={values}\n                  invalid={false}\n                  disabled\n                  currency={currency}\n                  fieldTypes={fieldTypes}\n                  onChange={() => {}}\n                />\n              </div>\n            )}\n            {!locked && (\n              <button\n                type=\"button\"\n                onClick={() => setEditing({ name: field.name, before: values[field.name] })}\n                className=\"text-xs font-medium text-muted-foreground hover:text-foreground\"\n              >\n                {t('editField', { label: field.label.toLowerCase() })}\n              </button>\n            )}\n          </div>\n        ) : (\n          <button\n            type=\"button\"\n            disabled={locked}\n            aria-labelledby={`field-${field.name}-label`}\n            title={locked ? undefined : t('clickToEdit')}\n            onClick={() => setEditing({ name: field.name, before: values[field.name] })}\n            className={cn(\n              'group -mx-1.5 flex min-h-7 items-center justify-between gap-2 rounded-md px-1.5 text-left text-sm transition-colors',\n              !locked && 'hover:bg-muted',\n              error && 'ring-1 ring-destructive',\n              text === '—' && 'text-muted-foreground',\n            )}\n          >\n            <span className=\"min-w-0 break-words whitespace-pre-wrap\">{text}</span>\n            {!locked && (\n              <span\n                aria-hidden\n                className=\"text-xs text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100\"\n              >\n                ✎\n              </span>\n            )}\n          </button>\n        )}\n        {!isEditing && error && <span className=\"text-xs text-destructive\">{error}</span>}\n      </div>\n    )\n  }\n\n  const fields = (section: FormSection) => {\n    const columns = section.columns ?? 1\n    return (\n      <div className=\"@container rounded-xl bg-card p-3 shadow-(--shadow-card)\">\n        <div className={cn('grid gap-4 [&>*]:min-w-0', gridCols[columns])}>\n          {section.fields.map((field) =>\n            mode === 'view' ? (\n              viewField(field, columns)\n            ) : (\n              <Field\n                key={field.name}\n                label={field.label}\n                htmlFor={`field-${field.name}`}\n                hint={hint(field)}\n                error={errors[field.name]}\n                required={!readOnly && isRequired(field, values)}\n                className={spanClass(field, columns)}\n              >\n                {control(field)}\n              </Field>\n            ),\n          )}\n        </div>\n      </div>\n    )\n  }\n\n  const count = (section: FormSection) => {\n    const s = stats(section)\n    return (\n      <span\n        className={cn(\n          'text-xs font-normal tabular-nums',\n          s.hasError ? 'text-destructive' : 'text-muted-foreground',\n        )}\n      >\n        {s.hasError ? t('needsAttention') : `${s.filled}/${s.total}`}\n      </span>\n    )\n  }\n\n  const heading = (section: FormSection, extra?: React.ReactNode) => (\n    <div className=\"flex items-start gap-2.5 text-[15px] font-medium [&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:text-muted-foreground\">\n      {section.icon}\n      <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <h3>{section.title}</h3>\n        {section.description && (\n          <p className=\"text-sm font-normal text-muted-foreground\">{section.description}</p>\n        )}\n      </div>\n      {extra ?? count(section)}\n    </div>\n  )\n\n  const cards = (list: FormSection[]) => (\n    <div className={cn('grid gap-6 [&>*]:min-w-0', sectionColumns === 2 && '@3xl:grid-cols-2')}>\n      {list.map((section) => (\n        <section\n          key={section.id}\n          className={cn(\n            'flex flex-col gap-3',\n            sectionColumns === 2 && section.span === 'full' && 'col-span-full',\n          )}\n        >\n          {heading(section)}\n          {fields(section)}\n        </section>\n      ))}\n    </div>\n  )\n\n  const toggle = (id: string) => {\n    const next = new Set(open)\n    if (next.has(id)) next.delete(id)\n    else next.add(id)\n    setOpen(next)\n  }\n\n  const accordion = (list: FormSection[]) =>\n    list.map((section) => {\n      const expanded = q ? true : open.has(section.id)\n      return (\n        <section key={section.id} className=\"border-b py-1 last:border-0\">\n          <button\n            type=\"button\"\n            aria-expanded={expanded}\n            onClick={() => toggle(section.id)}\n            className=\"flex h-11 w-full items-center gap-2.5 text-left text-[15px] font-medium text-muted-foreground hover:text-foreground aria-expanded:text-foreground [&_svg]:size-4\"\n          >\n            {section.icon}\n            <span className=\"flex-1\">{section.title}</span>\n            {count(section)}\n            <span\n              aria-hidden\n              className={cn('text-[10px] transition-transform', expanded && 'rotate-180')}\n            >\n              ▾\n            </span>\n          </button>\n          {expanded && (\n            <div className=\"mb-3 flex flex-col gap-3\">\n              {section.description && (\n                <p className=\"text-sm text-muted-foreground\">{section.description}</p>\n              )}\n              {fields(section)}\n            </div>\n          )}\n        </section>\n      )\n    })\n\n  const tabs = () => {\n    const current = shownSections.find((s) => s.id === tab) ?? shownSections[0]\n    if (!current) return null\n    return (\n      <div className=\"flex flex-col gap-4\">\n        <div\n          role=\"tablist\"\n          aria-label=\"Sections\"\n          className=\"flex gap-1 overflow-x-auto rounded-xl bg-muted p-1\"\n        >\n          {shownSections.map((section) => (\n            <button\n              key={section.id}\n              type=\"button\"\n              role=\"tab\"\n              id={`tab-${section.id}`}\n              aria-selected={section.id === current.id}\n              aria-controls={`panel-${section.id}`}\n              onClick={() => setTab(section.id)}\n              className={cn(\n                'flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-sm font-medium whitespace-nowrap transition-colors [&_svg]:size-3.5',\n                section.id === current.id\n                  ? 'bg-card text-foreground shadow-(--shadow-button)'\n                  : 'text-muted-foreground hover:text-foreground',\n              )}\n            >\n              {section.icon}\n              {section.title}\n              {stats(section).hasError && (\n                <span\n                  aria-label=\"Needs attention\"\n                  className=\"size-1.5 rounded-full bg-destructive\"\n                />\n              )}\n            </button>\n          ))}\n        </div>\n        <div role=\"tabpanel\" id={`panel-${current.id}`} aria-labelledby={`tab-${current.id}`}>\n          {current.description && (\n            <p className=\"mb-3 text-sm text-muted-foreground\">{current.description}</p>\n          )}\n          {fields(current)}\n        </div>\n      </div>\n    )\n  }\n\n  const steps = () => {\n    const last = shownSections.length - 1\n    const reviewing = review && step > last\n    const current = shownSections[Math.min(step, last)]\n    if (!current) return null\n    const labels = [...shownSections.map((s) => s.title), ...(review ? [t('review')] : [])]\n\n    const go = (to: number) => {\n      setStep(to)\n      setReached(Math.max(reached, to))\n    }\n\n    const check = (section: FormSection) => form.trigger(section.fields.map((f) => f.name))\n\n    const next = async () => {\n      setChecking(true)\n      const ok = await check(current).finally(() => setChecking(false))\n      if (!ok) return\n      if (step < last || review) go(step + 1)\n      else await submit()\n    }\n\n    const submitAll = async () => {\n      setChecking(true)\n      try {\n        for (let i = 0; i < shownSections.length; i++) {\n          if (!(await check(shownSections[i]))) return setStep(i)\n        }\n      } finally {\n        setChecking(false)\n      }\n      await submit()\n    }\n\n    return (\n      <div className=\"flex flex-col gap-5\">\n        <ol className=\"flex items-center gap-2 overflow-x-auto pb-1\">\n          {labels.map((label, i) => {\n            const active = i === step\n            const done = i < step\n            const hasError = i <= last && stats(shownSections[i]).hasError\n            return (\n              <li key={label} className=\"flex shrink-0 items-center gap-2\">\n                {i > 0 && <span aria-hidden className=\"h-px w-6 bg-border\" />}\n                <button\n                  type=\"button\"\n                  disabled={i > reached}\n                  aria-current={active ? 'step' : undefined}\n                  onClick={() => setStep(i)}\n                  className=\"flex items-center gap-2 text-sm disabled:cursor-default\"\n                >\n                  <span\n                    className={cn(\n                      'flex size-6 items-center justify-center rounded-full border text-xs font-medium tabular-nums',\n                      hasError\n                        ? 'border-destructive text-destructive'\n                        : active\n                          ? 'border-primary bg-primary text-primary-foreground'\n                          : done\n                            ? 'border-foreground/20 bg-muted text-foreground'\n                            : 'text-muted-foreground',\n                    )}\n                  >\n                    {done && !hasError ? '✓' : i + 1}\n                  </span>\n                  <span\n                    className={cn(\n                      'whitespace-nowrap',\n                      active ? 'font-medium text-foreground' : 'text-muted-foreground',\n                    )}\n                  >\n                    {label}\n                  </span>\n                </button>\n              </li>\n            )\n          })}\n        </ol>\n\n        {reviewing ? (\n          <div className=\"flex flex-col gap-4\">\n            {shownSections.map((section, i) => (\n              <section key={section.id} className=\"rounded-xl bg-card p-4 shadow-(--shadow-card)\">\n                {heading(\n                  section,\n                  <button\n                    type=\"button\"\n                    onClick={() => setStep(i)}\n                    className=\"text-sm font-medium text-muted-foreground hover:text-foreground\"\n                  >\n                    {t('edit')}\n                  </button>,\n                )}\n                <dl className={cn('mt-3 grid gap-x-6 gap-y-3', gridCols[section.columns ?? 2])}>\n                  {section.fields.map((f) => (\n                    <div\n                      key={f.name}\n                      className={cn(\n                        'flex min-w-0 flex-col gap-0.5',\n                        spanClass(f, section.columns ?? 2),\n                      )}\n                    >\n                      <dt className=\"text-xs text-muted-foreground\">{f.label}</dt>\n                      <dd className=\"text-sm break-words\">\n                        {formatValue(f, valueOf(f), fieldTypes, { t, locale })}\n                      </dd>\n                    </div>\n                  ))}\n                </dl>\n              </section>\n            ))}\n          </div>\n        ) : (\n          <section className=\"flex flex-col gap-3\">\n            {heading(current, <span />)}\n            {fields(current)}\n          </section>\n        )}\n\n        <div className=\"flex items-center justify-between gap-2 border-t pt-4\">\n          <button\n            type=\"button\"\n            disabled={step === 0}\n            onClick={() => setStep(step - 1)}\n            className={cn(button, 'text-muted-foreground hover:bg-muted disabled:invisible')}\n          >\n            {t('back')}\n          </button>\n          <span className=\"text-xs text-muted-foreground tabular-nums\">\n            {t('stepOf', { step: step + 1, total: labels.length })}\n          </span>\n          <button\n            type=\"button\"\n            disabled={formState.isSubmitting || checking}\n            aria-busy={checking || undefined}\n            onClick={() => void (reviewing ? submitAll() : next())}\n            className={cn(button, 'bg-primary text-primary-foreground hover:bg-primary/90')}\n          >\n            {reviewing || (!review && step === last)\n              ? formState.isSubmitting\n                ? t('saving')\n                : (submitLabel ?? t('submit'))\n              : checking\n                ? t('checking')\n                : step === last\n                  ? t('review')\n                  : t('next')}\n          </button>\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <ConfigProvider value={config}>\n      <div className={cn('@container flex flex-col', className)}>\n        {draft && <DraftBanner draft={draft} />}\n        {autosave && autosave.status !== 'idle' && (\n          <div className=\"mb-2 flex justify-end\">\n            <SaveStatus state={autosave} />\n          </div>\n        )}\n        {searchable && (\n          <div className=\"mb-3 flex h-8 items-center gap-2 rounded-lg border border-input bg-card px-2.5 focus-within:border-ring\">\n            <input\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              onKeyDown={(e) => e.key === 'Escape' && setQuery('')}\n              placeholder={t('searchFields')}\n              aria-label={t('searchFields')}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n            />\n            {query && (\n              <button\n                type=\"button\"\n                aria-label={t('clearSearch')}\n                onClick={() => setQuery('')}\n                className=\"text-xs text-muted-foreground hover:text-foreground\"\n              >\n                ✕\n              </button>\n            )}\n          </div>\n        )}\n        {q\n          ? layout === 'accordion'\n            ? accordion(searched)\n            : cards(searched)\n          : layout === 'steps'\n            ? steps()\n            : layout === 'tabs'\n              ? tabs()\n              : layout === 'cards'\n                ? cards(shownSections)\n                : accordion(shownSections)}\n        {q && !searched.length && (\n          <p className=\"py-6 text-center text-sm text-muted-foreground\">\n            {t('noFieldsMatch', { query })}\n          </p>\n        )}\n      </div>\n    </ConfigProvider>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/record-form.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/changes.tsx",
      "content": "import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'\n\nimport { cn } from '@/lib/utils'\n\nimport { useFormConfig, useResolvedConfig, type RecordFormSettings } from './context'\nimport {\n  diffValues,\n  type Change,\n  type FieldTypeMap,\n  type FormSection,\n  type FormValues,\n} from './schema'\n\nexport function useFormChanges(\n  form: UseFormReturn<FormValues>,\n  sections: FormSection[],\n  { fieldTypes, ...settings }: { fieldTypes?: FieldTypeMap } & RecordFormSettings = {},\n): Change[] {\n  const { t, locale, roles } = useResolvedConfig(settings, useFormConfig())\n  const values = useWatch({ control: form.control }) as FormValues\n  const { defaultValues = {} } = useFormState({ control: form.control })\n  return diffValues(sections, defaultValues as FormValues, values, fieldTypes, { t, locale, roles })\n}\n\ntype ChangeListProps = {\n  changes: Change[]\n  title?: React.ReactNode\n  className?: string\n}\n\nexport function ChangeList({ changes, title = 'Unsaved changes', className }: ChangeListProps) {\n  if (!changes.length) return null\n  return (\n    <section className={cn('flex flex-col gap-3', className)}>\n      <h2 className=\"flex items-center gap-2 text-base font-semibold\">\n        {title}\n        <span className=\"rounded-full bg-muted px-2 text-xs font-medium tabular-nums\">\n          {changes.length}\n        </span>\n      </h2>\n      <ul className=\"divide-y rounded-xl bg-card px-4 shadow-(--shadow-card)\">\n        {changes.map((c) => (\n          <li\n            key={c.id}\n            className=\"grid grid-cols-[minmax(0,10rem)_1fr] items-baseline gap-4 py-2.5 text-sm\"\n          >\n            <span className=\"truncate text-muted-foreground\">{c.label}</span>\n            <span className=\"flex min-w-0 flex-wrap items-baseline gap-x-2\">\n              <span className=\"text-muted-foreground line-through\">{c.from}</span>\n              <span aria-hidden className=\"text-muted-foreground\">\n                →\n              </span>\n              <span className=\"font-medium\">{c.to}</span>\n            </span>\n          </li>\n        ))}\n      </ul>\n    </section>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/changes.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/context.tsx",
      "content": "'use client'\n\nimport { createContext, useContext, useMemo } from 'react'\n\nimport { createT, defaultT, type Messages, type T, type Translate } from './i18n'\nimport type { Uploader } from './upload'\n\nexport type RecordFormSettings = {\n  locale?: string\n  messages?: Messages\n  translate?: Translate\n  roles?: string[]\n  upload?: Uploader\n}\n\nexport type RecordFormConfig = Omit<RecordFormSettings, 'messages' | 'translate'> & { t: T }\n\nconst Config = createContext<RecordFormConfig>({ t: defaultT })\n\nexport const useFormConfig = () => useContext(Config)\n\nexport function RecordFormProvider({\n  children,\n  ...settings\n}: RecordFormSettings & { children: React.ReactNode }) {\n  const parent = useContext(Config)\n  const value = useResolvedConfig(settings, parent)\n  return <Config value={value}>{children}</Config>\n}\n\nexport function useResolvedConfig(settings: RecordFormSettings, parent?: RecordFormConfig) {\n  const { locale, messages, translate, roles, upload } = settings\n  return useMemo<RecordFormConfig>(() => {\n    const base = parent ?? { t: defaultT }\n    return {\n      locale: locale ?? base.locale,\n      roles: roles ?? base.roles,\n      upload: upload ?? base.upload,\n      t: messages || translate || locale ? createT({ messages, translate, locale }) : base.t,\n    }\n  }, [locale, messages, translate, roles, upload, parent])\n}\n\nexport const ConfigProvider = Config\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/context.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/controls.tsx",
      "content": "'use client'\n\nimport { useRef, useState } from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { useFormConfig } from './context'\nimport { Popover } from '@/registry/dashboard/components/popover/popover'\nimport { normalizeOptions, type Option, type OptionObject } from './schema'\n\nexport const triggerClass =\n  'border-input bg-card text-foreground hover:border-ring flex min-h-8 w-full items-center justify-between gap-2 rounded-lg border px-2.5 py-1 text-left text-sm outline-none focus-visible:border-ring disabled:cursor-default disabled:bg-muted/40 disabled:hover:border-input aria-disabled:cursor-default aria-disabled:bg-muted/40 aria-disabled:hover:border-input'\n\nconst chevron = (\n  <span aria-hidden className=\"text-[10px] text-muted-foreground\">\n    ▾\n  </span>\n)\n\nexport const optionRow =\n  'flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1 text-left text-sm hover:bg-muted'\n\nfunction OptionSearch({\n  value,\n  onChange,\n  onEnter,\n}: {\n  value: string\n  onChange: (value: string) => void\n  onEnter?: () => void\n}) {\n  const { t } = useFormConfig()\n  return (\n    <input\n      autoFocus\n      value={value}\n      onChange={(e) => onChange(e.target.value)}\n      onKeyDown={(e) => {\n        if (e.key === 'Enter') {\n          e.preventDefault()\n          onEnter?.()\n        }\n      }}\n      placeholder={t('search')}\n      aria-label={t('searchOptions')}\n      className=\"mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none\"\n    />\n  )\n}\n\nexport function Avatar({ option, size = 'sm' }: { option: OptionObject; size?: 'sm' | 'xs' }) {\n  if (!option.avatar && !option.color) return null\n  const cls = cn(\n    'flex shrink-0 items-center justify-center overflow-hidden rounded-full font-medium',\n    size === 'sm' ? 'size-6 text-[10px]' : 'size-4 text-[8px]',\n  )\n  if (option.avatar && /^(https?:|\\/|data:)/.test(option.avatar)) {\n    return <img src={option.avatar} alt=\"\" className={cls} />\n  }\n  if (!option.avatar) {\n    return (\n      <span\n        aria-hidden\n        className=\"size-2 shrink-0 rounded-full\"\n        style={{ background: option.color }}\n      />\n    )\n  }\n  return (\n    <span\n      aria-hidden\n      className={cn(cls, 'bg-muted text-foreground')}\n      style={option.color ? { background: option.color, color: 'white' } : undefined}\n    >\n      {option.avatar}\n    </span>\n  )\n}\n\nexport function OptionView({ option, wrap = false }: { option: OptionObject; wrap?: boolean }) {\n  return (\n    <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n      <Avatar option={option} />\n      <span className=\"flex min-w-0 flex-col\">\n        <span className={wrap ? 'break-words' : 'truncate'}>{option.label}</span>\n        {option.description && (\n          <span className=\"text-xs text-muted-foreground\">{option.description}</span>\n        )}\n      </span>\n    </span>\n  )\n}\n\nconst filterOptions = (options: OptionObject[], query: string) => {\n  const q = query.toLowerCase()\n  return options.filter(\n    (o) => o.label.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q),\n  )\n}\n\ntype BaseProps<T> = {\n  id: string\n  value: T\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: T) => void\n}\n\nexport function SelectField({\n  id,\n  value,\n  options,\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: BaseProps<string> & { options: Option[]; placeholder?: string }) {\n  const { t } = useFormConfig()\n  const [query, setQuery] = useState('')\n  const all = normalizeOptions(options)\n  const shown = filterOptions(all, query)\n  const selected = all.find((o) => o.value === value)\n\n  return (\n    <Popover\n      block\n      className=\"w-full\"\n      trigger={({ open, toggle }) => (\n        <button\n          id={id}\n          type=\"button\"\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          data-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          disabled={disabled}\n          onClick={toggle}\n          className={cn(triggerClass, 'data-invalid:border-destructive')}\n        >\n          {selected ? (\n            <span className=\"flex min-w-0 items-center gap-2\">\n              <Avatar option={selected} size=\"xs\" />\n              <span className=\"truncate\">{selected.label}</span>\n            </span>\n          ) : (\n            <span className=\"truncate text-muted-foreground\">\n              {value || (placeholder ?? t('select'))}\n            </span>\n          )}\n          {!disabled && chevron}\n        </button>\n      )}\n    >\n      {(close) => (\n        <>\n          {all.length > 7 && <OptionSearch value={query} onChange={setQuery} />}\n          <ul role=\"listbox\" className=\"max-h-64 overflow-y-auto\">\n            {shown.map((option) => (\n              <li key={option.value}>\n                <button\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={option.value === value}\n                  onClick={() => {\n                    onChange(option.value === value ? '' : option.value)\n                    setQuery('')\n                    close()\n                  }}\n                  className={optionRow}\n                >\n                  <OptionView option={option} />\n                  {option.value === value && <span aria-hidden>✓</span>}\n                </button>\n              </li>\n            ))}\n            {!shown.length && (\n              <li className=\"px-2 py-1.5 text-sm text-muted-foreground\">{t('noMatchesShort')}</li>\n            )}\n          </ul>\n        </>\n      )}\n    </Popover>\n  )\n}\n\nexport function Check({ on, round = false }: { on: boolean; round?: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        'flex size-4 shrink-0 items-center justify-center border text-[10px] transition-colors',\n        round ? 'rounded-full' : 'rounded',\n        on ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-card',\n      )}\n    >\n      {on && (round ? <span className=\"size-1.5 rounded-full bg-current\" /> : '✓')}\n    </span>\n  )\n}\n\nexport function MultiSelectField({\n  id,\n  value,\n  options,\n  creatable = false,\n  display,\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: BaseProps<string[]> & {\n  options: Option[]\n  creatable?: boolean\n  display?: 'checkboxes' | string\n  placeholder?: string\n}) {\n  const { t } = useFormConfig()\n  const [query, setQuery] = useState('')\n  const base = normalizeOptions(options)\n  const all = [\n    ...base,\n    ...value.filter((v) => !base.some((o) => o.value === v)).map((v) => ({ value: v, label: v })),\n  ]\n  const shown = filterOptions(all, query)\n  const toggle = (option: string) =>\n    onChange(value.includes(option) ? value.filter((v) => v !== option) : [...value, option])\n\n  if (display === 'checkboxes') {\n    return (\n      <div\n        id={id}\n        role=\"group\"\n        aria-describedby={describedBy}\n        className=\"grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5\"\n      >\n        {all.map((option) => {\n          const on = value.includes(option.value)\n          return (\n            <button\n              key={option.value}\n              type=\"button\"\n              role=\"checkbox\"\n              aria-checked={on}\n              disabled={disabled}\n              onClick={() => toggle(option.value)}\n              className=\"flex items-start gap-2 rounded-lg py-1 text-left text-sm disabled:cursor-default\"\n            >\n              <span className=\"pt-0.5\">\n                <Check on={on} />\n              </span>\n              <OptionView option={option} wrap />\n            </button>\n          )\n        })}\n      </div>\n    )\n  }\n\n  const create = () => {\n    const text = query.trim()\n    if (!creatable || !text || all.some((o) => o.label === text)) return\n    onChange([...value, text])\n    setQuery('')\n  }\n\n  return (\n    <Popover\n      block\n      className=\"w-full\"\n      trigger={({ open, toggle: toggleOpen }) => (\n        <div\n          id={id}\n          role=\"button\"\n          tabIndex={disabled ? -1 : 0}\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-disabled={disabled || undefined}\n          data-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          onClick={() => !disabled && toggleOpen()}\n          onKeyDown={(e) => !disabled && (e.key === 'Enter' || e.key === ' ') && toggleOpen()}\n          className={cn(triggerClass, 'flex-wrap justify-start data-invalid:border-destructive')}\n        >\n          {value.map((v) => {\n            const option = all.find((o) => o.value === v) ?? { value: v, label: v }\n            return (\n              <span\n                key={v}\n                className=\"flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-1.5 text-xs\"\n              >\n                <Avatar option={option} size=\"xs\" />\n                {option.label}\n                {!disabled && (\n                  <button\n                    type=\"button\"\n                    aria-label={t('removeOption', { label: option.label })}\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      toggle(v)\n                    }}\n                    className=\"px-0.5 text-muted-foreground hover:text-foreground\"\n                  >\n                    ✕\n                  </button>\n                )}\n              </span>\n            )\n          })}\n          {!value.length && (\n            <span className=\"text-muted-foreground\">{placeholder ?? t('add')}</span>\n          )}\n          {!disabled && <span className=\"ml-auto\">{chevron}</span>}\n        </div>\n      )}\n    >\n      <OptionSearch value={query} onChange={setQuery} onEnter={create} />\n      <ul role=\"listbox\" aria-multiselectable className=\"max-h-64 overflow-y-auto\">\n        {shown.map((option) => (\n          <li key={option.value}>\n            <button\n              type=\"button\"\n              role=\"option\"\n              aria-selected={value.includes(option.value)}\n              onClick={() => toggle(option.value)}\n              className={optionRow}\n            >\n              <Check on={value.includes(option.value)} />\n              <OptionView option={option} />\n            </button>\n          </li>\n        ))}\n        {creatable && query.trim() && !all.some((o) => o.label === query.trim()) && (\n          <li>\n            <button type=\"button\" onClick={create} className={optionRow}>\n              {t('createOption', { value: query.trim() })}\n            </button>\n          </li>\n        )}\n      </ul>\n    </Popover>\n  )\n}\n\nexport function ToggleField({\n  id,\n  value,\n  label,\n  disabled,\n  describedBy,\n  onChange,\n}: BaseProps<boolean> & { label?: string }) {\n  return (\n    <button\n      id={id}\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={value}\n      aria-describedby={describedBy}\n      disabled={disabled}\n      onClick={() => onChange(!value)}\n      className=\"flex min-h-8 items-center gap-2.5 text-sm disabled:cursor-default disabled:opacity-70\"\n    >\n      <span\n        className={cn(\n          'relative h-5 w-9 shrink-0 rounded-full transition-colors',\n          value ? 'bg-primary' : 'bg-input',\n        )}\n      >\n        <span\n          className={cn(\n            'absolute top-0.5 left-0.5 size-4 rounded-full bg-card shadow transition-transform',\n            value && 'translate-x-4',\n          )}\n        />\n      </span>\n      {label}\n    </button>\n  )\n}\n\nexport function RadioField({\n  id,\n  value,\n  options,\n  display,\n  disabled,\n  describedBy,\n  onChange,\n}: BaseProps<string> & { options: Option[]; display?: string }) {\n  const all = normalizeOptions(options)\n  const cards = display === 'cards' || (display !== 'segmented' && all.some((o) => o.description))\n\n  if (cards) {\n    return (\n      <div\n        id={id}\n        role=\"radiogroup\"\n        aria-describedby={describedBy}\n        className=\"flex flex-col gap-1.5\"\n      >\n        {all.map((option) => {\n          const on = value === option.value\n          return (\n            <button\n              key={option.value}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={on}\n              disabled={disabled}\n              onClick={() => onChange(option.value)}\n              className={cn(\n                'flex items-start gap-2.5 rounded-lg border p-2.5 text-left text-sm transition-colors disabled:cursor-default',\n                on ? 'border-foreground bg-muted/50' : 'hover:border-ring',\n              )}\n            >\n              <span className=\"pt-0.5\">\n                <Check on={on} round />\n              </span>\n              <span className=\"flex min-w-0 flex-col gap-0.5\">\n                <span className=\"font-medium\">{option.label}</span>\n                {option.description && (\n                  <span className=\"text-xs text-muted-foreground\">{option.description}</span>\n                )}\n              </span>\n            </button>\n          )\n        })}\n      </div>\n    )\n  }\n\n  return (\n    <div\n      id={id}\n      role=\"radiogroup\"\n      aria-describedby={describedBy}\n      className=\"flex rounded-lg bg-muted p-0.5\"\n    >\n      {all.map((option) => (\n        <button\n          key={option.value}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={value === option.value}\n          disabled={disabled}\n          onClick={() => onChange(option.value)}\n          className={cn(\n            'h-7 flex-1 truncate rounded-md px-2 text-sm transition-colors disabled:cursor-default',\n            value === option.value\n              ? 'bg-card font-medium text-foreground shadow-(--shadow-button)'\n              : 'text-muted-foreground enabled:hover:text-foreground',\n          )}\n        >\n          {option.label}\n        </button>\n      ))}\n    </div>\n  )\n}\n\nexport function ListField({\n  id,\n  value,\n  placeholder,\n  disabled,\n  describedBy,\n  onChange,\n}: BaseProps<string[]> & { placeholder?: string }) {\n  const { t } = useFormConfig()\n  const [draft, setDraft] = useState('')\n  const inputs = useRef<(HTMLInputElement | null)[]>([])\n  const add = () => {\n    const text = draft.trim()\n    if (!text) return\n    onChange([...value, text])\n    setDraft('')\n  }\n\n  return (\n    <div id={id} aria-describedby={describedBy} className=\"flex flex-col gap-1.5\">\n      {value.map((item, i) => (\n        <div key={i} className=\"flex items-center gap-2\">\n          <span className=\"w-4 text-right text-xs text-muted-foreground tabular-nums\">{i + 1}</span>\n          <input\n            ref={(el) => {\n              inputs.current[i] = el\n            }}\n            value={item}\n            readOnly={disabled}\n            aria-label={t('itemN', { n: i + 1 })}\n            onChange={(e) => onChange(value.map((v, j) => (j === i ? e.target.value : v)))}\n            onKeyDown={(e) => {\n              if (e.key === 'Backspace' && !item) {\n                e.preventDefault()\n                onChange(value.filter((_, j) => j !== i))\n                inputs.current[i - 1]?.focus()\n              }\n            }}\n            className=\"h-8 min-w-0 flex-1 rounded-lg border border-input bg-card px-2.5 text-sm outline-none read-only:bg-muted/40 focus:border-ring\"\n          />\n          {!disabled && (\n            <button\n              type=\"button\"\n              aria-label={t('removeItemN', { n: i + 1 })}\n              onClick={() => onChange(value.filter((_, j) => j !== i))}\n              className=\"flex size-8 items-center justify-center rounded-lg text-xs text-muted-foreground hover:bg-muted hover:text-foreground\"\n            >\n              ✕\n            </button>\n          )}\n        </div>\n      ))}\n      {!disabled && (\n        <div className=\"flex items-center gap-2\">\n          <span className=\"w-4 text-right text-xs text-muted-foreground\">+</span>\n          <input\n            value={draft}\n            placeholder={placeholder ?? t('listPlaceholder')}\n            aria-label={t('newItem')}\n            onChange={(e) => setDraft(e.target.value)}\n            onBlur={add}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') {\n                e.preventDefault()\n                add()\n              }\n            }}\n            className=\"h-8 min-w-0 flex-1 rounded-lg border border-dashed border-input bg-transparent px-2.5 text-sm outline-none placeholder:text-muted-foreground focus:border-solid focus:border-ring\"\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/controls.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/dates.ts",
      "content": "import dayjs from 'dayjs'\nimport customParseFormat from 'dayjs/plugin/customParseFormat'\nimport localizedFormat from 'dayjs/plugin/localizedFormat'\n\ndayjs.extend(customParseFormat)\ndayjs.extend(localizedFormat)\n\nexport { dayjs }\n\nconst ISO_DATE = 'YYYY-MM-DD'\nconst ISO_MONTH = 'YYYY-MM'\nconst ISO_TIME = 'HH:mm'\nconst ISO_DATETIME = 'YYYY-MM-DDTHH:mm'\n\nconst DATE_INPUTS = [\n  ISO_DATE,\n  'D/M/YYYY',\n  'DD/MM/YYYY',\n  'M/D/YYYY',\n  'MM/DD/YYYY',\n  'D.M.YYYY',\n  'D MMM YYYY',\n  'D MMMM YYYY',\n  'MMM D YYYY',\n  'MMM D, YYYY',\n  'MMMM D YYYY',\n  'MMMM D, YYYY',\n]\n\nconst local = (value: dayjs.ConfigType, format?: string, locale?: string) => {\n  const d = format ? dayjs(value, format, true) : dayjs(value)\n  return locale ? d.locale(locale) : d\n}\n\nexport const todayIso = () => dayjs().format(ISO_DATE)\nexport const thisMonthIso = () => dayjs().format(ISO_MONTH)\n\nexport function formatDate(iso: string, locale?: string) {\n  const d = local(iso, ISO_DATE, locale)\n  return d.isValid() ? d.format('ll') : iso\n}\n\nexport function formatMonth(iso: string, locale?: string) {\n  const d = local(iso, ISO_MONTH, locale)\n  return d.isValid() ? d.format('MMM YYYY') : iso\n}\n\nexport function formatTime(hhmm: string, locale?: string) {\n  const d = local(hhmm, ISO_TIME, locale)\n  return d.isValid() ? d.format('LT') : hhmm\n}\n\nexport function formatDateTime(value: string, locale?: string) {\n  const d = local(value, ISO_DATETIME, locale)\n  if (d.isValid()) return d.format('lll')\n  return formatDate(value.slice(0, 10), locale)\n}\n\nexport function formatDateRange(value: string, locale?: string) {\n  const [a, b] = value.split('/')\n  if (!a) return ''\n  return b ? `${formatDate(a, locale)} – ${formatDate(b, locale)}` : formatDate(a, locale)\n}\n\nexport function formatRelative(date: Date, locale?: string) {\n  const d = local(date, undefined, locale)\n  const minutes = dayjs().diff(d, 'minute')\n  if (minutes < 1) return d.format('LT')\n  if (dayjs().isSame(d, 'day')) return d.format('LT')\n  return d.format('lll')\n}\n\nexport function parseDateInput(text: string, locale?: string): string | null {\n  const t = text.trim()\n  if (!t) return null\n  for (const format of DATE_INPUTS) {\n    const d = locale ? dayjs(t, format, locale, true) : dayjs(t, format, true)\n    if (d.isValid()) return d.format(ISO_DATE)\n  }\n  return null\n}\n\nexport const isIsoDate = (value: string) => dayjs(value, ISO_DATE, true).isValid()\nexport const isIsoDateTime = (value: string) => dayjs(value, ISO_DATETIME, true).isValid()\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/dates.ts"
    },
    {
      "path": "registry/dashboard/components/record-form/drafts.tsx",
      "content": "'use client'\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { AlertCircle, Check, FileClock, Loader2 } from 'lucide-react'\nimport { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'\n\nimport { cn } from '@/lib/utils'\n\nimport { useFormConfig } from './context'\nimport { formatRelative } from './dates'\nimport type { FormValues } from './schema'\n\nexport type DraftState = { savedAt: Date; restore: () => void; discard: () => void }\n\nexport type AutosaveState = {\n  status: 'idle' | 'pending' | 'saving' | 'saved' | 'error'\n  savedAt?: Date\n  error?: string\n  retry: () => void\n}\n\ntype StoredDraft = { values: FormValues; savedAt: string }\n\nconst storageKey = (key: string) => `record-form:draft:${key}`\n\nexport function readDraft(key: string): { values: FormValues; savedAt: Date } | null {\n  try {\n    const raw = window.localStorage.getItem(storageKey(key))\n    if (!raw) return null\n    const parsed = JSON.parse(raw) as StoredDraft\n    return { values: parsed.values, savedAt: new Date(parsed.savedAt) }\n  } catch {\n    return null\n  }\n}\n\nexport function writeDraft(key: string, values: FormValues) {\n  try {\n    const stored: StoredDraft = { values, savedAt: new Date().toISOString() }\n    window.localStorage.setItem(storageKey(key), JSON.stringify(stored))\n  } catch {}\n}\n\nexport function clearDraft(key: string) {\n  try {\n    window.localStorage.removeItem(storageKey(key))\n  } catch {}\n}\n\nexport function useLeaveWarning(active: boolean, message?: string) {\n  const { t } = useFormConfig()\n  const text = message ?? t('leaveWarning')\n  useEffect(() => {\n    if (!active) return\n    const onBeforeUnload = (event: BeforeUnloadEvent) => {\n      event.preventDefault()\n      event.returnValue = text\n      return text\n    }\n    window.addEventListener('beforeunload', onBeforeUnload)\n    return () => window.removeEventListener('beforeunload', onBeforeUnload)\n  }, [active, text])\n}\n\nexport function useDraft(form: UseFormReturn<FormValues>, key?: string): DraftState | null {\n  const values = useWatch({ control: form.control }) as FormValues\n  const { isDirty } = useFormState({ control: form.control })\n  const [found, setFound] = useState<{ values: FormValues; savedAt: Date } | null>(null)\n\n  useEffect(() => {\n    if (!key) return\n    const draft = readDraft(key)\n    if (draft && JSON.stringify(draft.values) !== JSON.stringify(form.getValues()))\n      queueMicrotask(() => setFound(draft))\n  }, [key, form])\n\n  useEffect(() => {\n    if (!key || found) return\n    if (!isDirty) return clearDraft(key)\n    const timer = setTimeout(() => writeDraft(key, values), 600)\n    return () => clearTimeout(timer)\n  }, [key, found, isDirty, values])\n\n  if (!key || !found) return null\n  return {\n    savedAt: found.savedAt,\n    restore: () => {\n      form.reset(found.values, { keepDefaultValues: true })\n      setFound(null)\n    },\n    discard: () => {\n      clearDraft(key)\n      setFound(null)\n    },\n  }\n}\n\nexport type AutosaveOptions = { save: (values: FormValues) => Promise<void>; delay?: number }\n\nexport function useAutosave(\n  form: UseFormReturn<FormValues>,\n  options?: AutosaveOptions,\n): AutosaveState | undefined {\n  const values = useWatch({ control: form.control })\n  const { isDirty } = useFormState({ control: form.control })\n  const [state, setState] = useState<Omit<AutosaveState, 'retry'>>({ status: 'idle' })\n  const latest = useRef(options)\n  useEffect(() => {\n    latest.current = options\n  })\n\n  const saveNow = useCallback(async () => {\n    const save = latest.current?.save\n    if (!save) return\n    const snapshot = form.getValues()\n    setState((s) => ({ ...s, status: 'saving' }))\n    try {\n      await save(snapshot)\n      form.reset(snapshot, {\n        keepValues: true,\n        keepErrors: true,\n        keepTouched: true,\n        keepIsSubmitted: true,\n      })\n      setState({ status: 'saved', savedAt: new Date() })\n    } catch (error) {\n      setState({ status: 'error', error: error instanceof Error ? error.message : undefined })\n    }\n  }, [form])\n\n  const enabled = !!options\n  const delay = options?.delay ?? 1500\n  useEffect(() => {\n    if (!enabled || !isDirty) return\n    queueMicrotask(() => setState((s) => (s.status === 'saving' ? s : { ...s, status: 'pending' })))\n    const timer = setTimeout(() => void saveNow(), delay)\n    return () => clearTimeout(timer)\n  }, [enabled, isDirty, values, delay, saveNow])\n\n  return enabled ? { ...state, retry: () => void saveNow() } : undefined\n}\n\nexport function DraftBanner({ draft, className }: { draft: DraftState; className?: string }) {\n  const { t, locale } = useFormConfig()\n  return (\n    <div\n      role=\"status\"\n      className={cn(\n        'mb-3 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl bg-muted px-3 py-2.5 text-sm [&_svg]:size-4',\n        className,\n      )}\n    >\n      <FileClock className=\"shrink-0 text-muted-foreground\" />\n      <span className=\"min-w-0 flex-1\">\n        {t('draftFound', { time: formatRelative(draft.savedAt, locale) })}\n      </span>\n      <span className=\"flex gap-1.5\">\n        <button\n          type=\"button\"\n          onClick={draft.discard}\n          className=\"h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:bg-card hover:text-foreground\"\n        >\n          {t('discard')}\n        </button>\n        <button\n          type=\"button\"\n          onClick={draft.restore}\n          className=\"h-7 rounded-lg bg-primary px-2.5 text-xs font-medium text-primary-foreground hover:bg-primary/90\"\n        >\n          {t('restore')}\n        </button>\n      </span>\n    </div>\n  )\n}\n\nexport function SaveStatus({ state, className }: { state: AutosaveState; className?: string }) {\n  const { t, locale } = useFormConfig()\n  if (state.status === 'idle') return null\n  return (\n    <span\n      role=\"status\"\n      aria-live=\"polite\"\n      className={cn(\n        'inline-flex items-center gap-1.5 text-xs [&_svg]:size-3.5',\n        state.status === 'error' ? 'text-destructive' : 'text-muted-foreground',\n        className,\n      )}\n    >\n      {state.status === 'saving' && (\n        <>\n          <Loader2 className=\"animate-spin motion-reduce:animate-none\" /> {t('autosaving')}\n        </>\n      )}\n      {state.status === 'pending' && t('unsaved')}\n      {state.status === 'saved' && (\n        <>\n          <Check />\n          {state.savedAt\n            ? t('autosavedAt', { time: formatRelative(state.savedAt, locale) })\n            : t('autosaved')}\n        </>\n      )}\n      {state.status === 'error' && (\n        <>\n          <AlertCircle /> {t('autosaveFailed')}\n          <button\n            type=\"button\"\n            onClick={state.retry}\n            className=\"font-medium underline-offset-2 hover:underline\"\n          >\n            {t('retry')}\n          </button>\n        </>\n      )}\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/drafts.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/field-control.tsx",
      "content": "'use client'\n\nimport { useMemo } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { Input } from '@/registry/dashboard/components/input/input'\n\nimport { DatePicker } from '@/registry/dashboard/components/date-picker/date-picker'\nimport { pickerLabels, type PickerLabels } from '@/registry/dashboard/components/date-picker/locale'\nimport { DateRangeField } from '@/registry/dashboard/components/date-picker/date-range-field'\nimport { DateTimePicker } from '@/registry/dashboard/components/date-picker/date-time-picker'\nimport { MonthPicker } from '@/registry/dashboard/components/date-picker/month-picker'\nimport { TimePicker } from '@/registry/dashboard/components/date-picker/time-picker'\n\nimport { ListField, MultiSelectField, RadioField, SelectField, ToggleField } from './controls'\nimport { useFormConfig } from './context'\nimport { FileField } from './files'\nimport type { T } from './i18n'\nimport { GroupField } from './group'\nimport { AddressField, LookupField } from './lookup'\nimport { DurationField, NumberField } from './number-input'\nimport { PhoneField } from './phone'\nimport { RichTextField } from './rich-text'\nimport { RowsField } from './rows'\nimport { SignatureField } from './signature'\nimport type {\n  CustomFieldType,\n  FieldDef,\n  FieldValue,\n  FileItem,\n  FormErrors,\n  FormValues,\n} from './schema'\n\nexport type FieldRenderProps = {\n  id: string\n  field: FieldDef\n  value: FieldValue | undefined\n  values: FormValues\n  invalid: boolean\n  disabled: boolean\n  describedBy?: string\n  onChange: (value: FieldValue) => void\n}\n\nexport type FieldTypeRenderer = CustomFieldType & {\n  render: (props: FieldRenderProps) => React.ReactNode\n}\n\nexport type FieldControlProps = FieldRenderProps & {\n  path?: string\n  currency?: string\n  errors?: FormErrors\n  fieldTypes?: Record<string, FieldTypeRenderer>\n}\n\nconst pickerAliases: Partial<Record<keyof PickerLabels, string>> = {\n  today: 'today',\n  now: 'now',\n  thisMonth: 'thisMonth',\n  clear: 'clear',\n  clearValue: 'clear',\n  pickDate: 'pickDate',\n  pickTime: 'pickTime',\n  pickMonth: 'pickMonth',\n  pickDates: 'pickDates',\n  pickDateTime: 'pickDateTime',\n}\n\nfunction pickerLabelsFrom(t: T): PickerLabels {\n  return Object.fromEntries(\n    (Object.keys(pickerLabels) as (keyof PickerLabels)[]).map((key) => {\n      const own = t(`picker.${key}`)\n      if (own !== `picker.${key}`) return [key, own]\n      const alias = pickerAliases[key]\n      return [key, alias ? t(alias) : pickerLabels[key]]\n    }),\n  ) as PickerLabels\n}\n\nfunction limit(value: number | string | undefined, type: 'date' | 'time' | 'month') {\n  if (value === undefined) return undefined\n  if (value !== 'today') return String(value)\n  const d = new Date()\n  const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`\n  return type === 'month' ? ymd.slice(0, 7) : type === 'date' ? ymd : undefined\n}\n\nconst asText = (value: FieldValue | undefined) =>\n  typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''\nconst asList = (value: FieldValue | undefined) => (Array.isArray(value) ? (value as string[]) : [])\n\nexport function FieldControl(props: FieldControlProps) {\n  const {\n    id,\n    field,\n    value,\n    invalid,\n    disabled,\n    describedBy,\n    onChange,\n    currency = '$',\n    fieldTypes = {},\n  } = props\n  const { t, locale } = useFormConfig()\n  const labels = useMemo(() => pickerLabelsFrom(t), [t])\n  const custom = fieldTypes[field.type]\n  if (custom) return custom.render(props)\n\n  const text = asText(value)\n  const common = { id, disabled, invalid, describedBy }\n\n  switch (field.type) {\n    case 'textarea':\n      return (\n        <textarea\n          id={id}\n          value={text}\n          readOnly={disabled}\n          placeholder={field.placeholder}\n          aria-invalid={invalid}\n          aria-describedby={describedBy}\n          onChange={(e) => onChange(e.target.value)}\n          className=\"min-h-20 w-full rounded-lg border border-input bg-card px-2.5 py-2 text-sm leading-relaxed outline-none placeholder:text-muted-foreground read-only:bg-muted/40 focus:border-ring aria-invalid:border-destructive\"\n        />\n      )\n    case 'select':\n      return (\n        <SelectField\n          {...common}\n          value={text}\n          options={field.options ?? []}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'multiselect':\n      return (\n        <MultiSelectField\n          {...common}\n          value={asList(value)}\n          options={field.options ?? []}\n          creatable={field.creatable}\n          display={field.display}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'toggle':\n      return (\n        <ToggleField\n          {...common}\n          value={value === true}\n          label={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'radio':\n      return (\n        <RadioField\n          {...common}\n          value={text}\n          options={field.options ?? []}\n          display={field.display}\n          onChange={onChange}\n        />\n      )\n    case 'date':\n      return (\n        <DatePicker\n          {...common}\n          value={text}\n          min={limit(field.min, 'date')}\n          max={limit(field.max, 'date')}\n          placeholder={field.placeholder}\n          clearable={!field.required}\n          locale={locale}\n          labels={labels}\n          onChange={onChange}\n        />\n      )\n    case 'time':\n      return (\n        <TimePicker\n          {...common}\n          value={text}\n          step={field.step}\n          min={limit(field.min, 'time')}\n          max={limit(field.max, 'time')}\n          placeholder={field.placeholder}\n          clearable={!field.required}\n          locale={locale}\n          labels={labels}\n          onChange={onChange}\n        />\n      )\n    case 'month':\n      return (\n        <MonthPicker\n          {...common}\n          value={text}\n          min={limit(field.min, 'month')}\n          max={limit(field.max, 'month')}\n          placeholder={field.placeholder}\n          clearable={!field.required}\n          locale={locale}\n          labels={labels}\n          onChange={onChange}\n        />\n      )\n    case 'list':\n      return (\n        <ListField\n          {...common}\n          value={asList(value)}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'file':\n      return (\n        <FileField\n          {...common}\n          value={Array.isArray(value) ? (value as FileItem[]) : []}\n          accept={field.accept}\n          multiple={field.multiple ?? true}\n          maxSize={field.maxSize}\n          maxFiles={field.maxLength}\n          onChange={onChange}\n        />\n      )\n    case 'rows':\n      return <RowsField {...props} Control={FieldControl} />\n    case 'group':\n      return <GroupField {...props} Control={FieldControl} />\n    case 'number':\n    case 'currency':\n    case 'percent':\n      return (\n        <NumberField\n          {...common}\n          value={text}\n          decimals={field.decimals}\n          placeholder={field.placeholder}\n          leading={field.type === 'currency' ? currency.trim() : undefined}\n          trailing={field.type === 'percent' ? '%' : undefined}\n          onChange={onChange}\n        />\n      )\n    case 'duration':\n      return (\n        <DurationField\n          {...common}\n          value={text}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'phone':\n      return (\n        <PhoneField\n          {...common}\n          value={text}\n          defaultCountry={field.defaultCountry}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'lookup':\n      return (\n        <LookupField\n          {...common}\n          value={field.multiple ? asList(value) : text}\n          multiple={field.multiple}\n          search={field.search}\n          options={field.options}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'address':\n      return (\n        <AddressField\n          {...common}\n          value={text}\n          search={field.search}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'daterange':\n      return (\n        <DateRangeField\n          {...common}\n          value={text}\n          min={limit(field.min, 'date')}\n          max={limit(field.max, 'date')}\n          placeholder={field.placeholder}\n          clearable={!field.required}\n          locale={locale}\n          labels={labels}\n          onChange={onChange}\n        />\n      )\n    case 'datetime':\n      return (\n        <DateTimePicker\n          {...common}\n          value={text}\n          step={field.step}\n          min={field.min === undefined ? undefined : String(field.min)}\n          max={field.max === undefined ? undefined : String(field.max)}\n          placeholder={field.placeholder}\n          clearable={!field.required}\n          locale={locale}\n          labels={labels}\n          onChange={onChange}\n        />\n      )\n    case 'richtext':\n      return (\n        <RichTextField\n          {...common}\n          value={text}\n          placeholder={field.placeholder}\n          onChange={onChange}\n        />\n      )\n    case 'signature':\n      return <SignatureField {...common} value={text} onChange={onChange} />\n    default:\n      return (\n        <Input\n          id={id}\n          value={text}\n          readOnly={disabled}\n          placeholder={field.placeholder}\n          aria-invalid={invalid}\n          aria-describedby={describedBy}\n          type={field.type === 'email' ? 'email' : 'text'}\n          inputMode={field.type === 'url' ? 'url' : field.type === 'email' ? 'email' : undefined}\n          onChange={(e) => onChange(e.target.value)}\n          className={cn(disabled && 'bg-muted/40')}\n        />\n      )\n  }\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/field-control.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/files.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { RotateCw, Upload, X } from 'lucide-react'\nimport { useDropzone, type Accept, type FileRejection } from 'react-dropzone'\n\nimport { cn } from '@/lib/utils'\nimport { toast } from '@/registry/dashboard/components/toast/toast'\n\nimport { useFormConfig } from './context'\nimport { showSize, type FileItem } from './schema'\n\ntype Rejection = { file: File; reason: 'size' | 'type' | 'count' }\n\nfunction toAccept(accept?: string): Accept | undefined {\n  if (!accept) return undefined\n  const result: Accept = {}\n  const extensions: string[] = []\n  for (const part of accept.split(',').map((a) => a.trim())) {\n    if (part.startsWith('.')) extensions.push(part)\n    else result[part] = []\n  }\n  if (extensions.length) result['application/octet-stream'] = extensions\n  return result\n}\n\nconst reasonOf = (r: FileRejection): Rejection['reason'] => {\n  const code = r.errors[0]?.code\n  return code === 'file-too-large' ? 'size' : code === 'too-many-files' ? 'count' : 'type'\n}\n\nconst newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`\n\nexport function FileField({\n  id,\n  value,\n  accept,\n  multiple = true,\n  maxSize,\n  maxFiles,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: FileItem[]\n  accept?: string\n  multiple?: boolean\n  maxSize?: number\n  maxFiles?: number\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: FileItem[]) => void\n}) {\n  const { upload, t } = useFormConfig()\n  const current = useRef(value)\n  const sources = useRef(new Map<string, File>())\n  const controllers = useRef(new Map<string, AbortController>())\n  const [retryable, setRetryable] = useState<string[]>([])\n  const [rejected, setRejected] = useState<string[]>([])\n\n  useEffect(() => {\n    current.current = value\n  }, [value])\n\n  useEffect(() => {\n    const running = controllers.current\n    return () => running.forEach((c) => c.abort())\n  }, [])\n\n  const commit = (next: FileItem[]) => {\n    current.current = next\n    onChange(next)\n  }\n  const patch = (fileId: string, change: Partial<FileItem>) =>\n    commit(current.current.map((f) => (f.id === fileId ? { ...f, ...change } : f)))\n\n  const start = (item: FileItem, file: File) => {\n    if (!upload) return\n    const controller = new AbortController()\n    controllers.current.set(item.id, controller)\n    let last = 0\n    upload(file, {\n      signal: controller.signal,\n      onProgress: (p) => {\n        const progress = Math.max(0, Math.min(99, Math.round(p)))\n        if (progress - last < 2) return\n        last = progress\n        patch(item.id, { progress })\n      },\n    }).then(\n      (result) => {\n        controllers.current.delete(item.id)\n        sources.current.delete(item.id)\n        setRetryable((r) => r.filter((x) => x !== item.id))\n        patch(item.id, { status: 'done', progress: 100, url: result.url, error: undefined })\n      },\n      (error: unknown) => {\n        controllers.current.delete(item.id)\n        if (error instanceof DOMException && error.name === 'AbortError') return\n        setRetryable((r) => (r.includes(item.id) ? r : [...r, item.id]))\n        patch(item.id, {\n          status: 'error',\n          error: error instanceof Error ? error.message : t('uploadError'),\n        })\n      },\n    )\n  }\n\n  const add = (files: File[]) => {\n    setRejected([])\n    const items = files.map((file) => {\n      const item: FileItem = {\n        id: newId(),\n        name: file.name,\n        size: file.size,\n        type: file.type,\n        url: upload ? undefined : URL.createObjectURL(file),\n        status: upload ? 'uploading' : 'done',\n        progress: upload ? 0 : 100,\n      }\n      sources.current.set(item.id, file)\n      return item\n    })\n    commit(multiple ? [...current.current, ...items] : items)\n    items.forEach((item, i) => start(item, files[i]))\n  }\n\n  const reject = (rejections: Rejection[]) => {\n    const messages = rejections.map((r) =>\n      r.reason === 'count'\n        ? t('rejectedCount')\n        : t(r.reason === 'size' ? 'rejectedSize' : 'rejectedType', { name: r.file.name }),\n    )\n    const unique = [...new Set(messages)]\n    setRejected(unique)\n    if (toast.isMounted()) unique.forEach((m) => toast.error(m))\n  }\n\n  const retry = (item: FileItem) => {\n    const file = sources.current.get(item.id)\n    if (!file) return\n    patch(item.id, { status: 'uploading', progress: 0, error: undefined })\n    start({ ...item, status: 'uploading' }, file)\n  }\n\n  const remove = (item: FileItem) => {\n    controllers.current.get(item.id)?.abort()\n    controllers.current.delete(item.id)\n    sources.current.delete(item.id)\n    setRetryable((r) => r.filter((x) => x !== item.id))\n    commit(current.current.filter((f) => f.id !== item.id))\n  }\n\n  const rules = [\n    accept &&\n      accept\n        .split(',')\n        .map((a) => {\n          const k = a.trim().toLowerCase()\n          if (k.endsWith('/*')) return `${k.slice(0, -2).replace(/^./, (c) => c.toUpperCase())}s`\n          return (k.includes('/') ? k.split('/')[1] : k.replace(/^\\./, '')).toUpperCase()\n        })\n        .join(', '),\n    maxSize && t('upTo', { size: showSize(maxSize) }),\n  ]\n    .filter(Boolean)\n    .join(' · ')\n\n  const full = maxFiles !== undefined && value.length >= maxFiles\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {!disabled && !full && (\n        <DropArea\n          id={id}\n          accept={accept}\n          multiple={multiple}\n          maxSize={maxSize}\n          maxFiles={maxFiles !== undefined ? Math.max(0, maxFiles - value.length) : undefined}\n          invalid={invalid}\n          describedBy={describedBy}\n          rules={rules}\n          onFiles={add}\n          onReject={reject}\n        />\n      )}\n      {rejected.length > 0 && !toast.isMounted() && (\n        <ul className=\"text-xs text-destructive\">\n          {rejected.map((m) => (\n            <li key={m}>{m}</li>\n          ))}\n        </ul>\n      )}\n      {value.length > 0 && (\n        <ul className=\"flex flex-col divide-y rounded-lg border\">\n          {value.map((file) => {\n            const uploading = file.status === 'uploading'\n            const failed = file.status === 'error'\n            return (\n              <li key={file.id} className=\"flex flex-col gap-1.5 px-2.5 py-2 text-sm\">\n                <div className=\"flex items-center gap-2.5\">\n                  <span className=\"flex h-8 w-7 shrink-0 items-center justify-center rounded bg-muted font-mono text-[9px] text-muted-foreground uppercase\">\n                    {file.name.split('.').pop()?.slice(0, 4)}\n                  </span>\n                  <span className=\"flex min-w-0 flex-1 flex-col\">\n                    {file.url && !uploading ? (\n                      <a\n                        href={file.url}\n                        target=\"_blank\"\n                        rel=\"noreferrer\"\n                        className=\"truncate hover:underline\"\n                      >\n                        {file.name}\n                      </a>\n                    ) : (\n                      <span className=\"truncate\">{file.name}</span>\n                    )}\n                    <span\n                      aria-live=\"polite\"\n                      className={cn(\n                        'text-xs',\n                        failed ? 'text-destructive' : 'text-muted-foreground',\n                      )}\n                    >\n                      {failed\n                        ? `${t('uploadError')}${file.error ? ` · ${file.error}` : ''}`\n                        : uploading\n                          ? t('uploadingPercent', { progress: file.progress ?? 0 })\n                          : showSize(file.size)}\n                    </span>\n                  </span>\n                  {failed && retryable.includes(file.id) && !disabled && (\n                    <button\n                      type=\"button\"\n                      onClick={() => retry(file)}\n                      className=\"flex h-7 items-center gap-1 rounded-lg px-2 text-xs font-medium hover:bg-muted [&_svg]:size-3\"\n                    >\n                      <RotateCw /> {t('retry')}\n                    </button>\n                  )}\n                  {!disabled && (\n                    <button\n                      type=\"button\"\n                      aria-label={uploading ? t('cancelUpload') : t('remove', { name: file.name })}\n                      title={uploading ? t('cancelUpload') : undefined}\n                      onClick={() => remove(file)}\n                      className=\"flex size-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5\"\n                    >\n                      <X />\n                    </button>\n                  )}\n                </div>\n                {uploading && (\n                  <div\n                    role=\"progressbar\"\n                    aria-label={file.name}\n                    aria-valuemin={0}\n                    aria-valuemax={100}\n                    aria-valuenow={file.progress ?? 0}\n                    className=\"h-1 overflow-hidden rounded-full bg-muted\"\n                  >\n                    <div\n                      className=\"h-full rounded-full bg-primary transition-[width] duration-200 motion-reduce:transition-none\"\n                      style={{ width: `${file.progress ?? 0}%` }}\n                    />\n                  </div>\n                )}\n              </li>\n            )\n          })}\n        </ul>\n      )}\n      {disabled && !value.length && (\n        <span className=\"text-sm text-muted-foreground\">{t('noFiles')}</span>\n      )}\n    </div>\n  )\n}\n\nfunction DropArea({\n  id,\n  accept,\n  multiple,\n  maxSize,\n  maxFiles,\n  invalid,\n  describedBy,\n  rules,\n  onFiles,\n  onReject,\n}: {\n  id: string\n  accept?: string\n  multiple: boolean\n  maxSize?: number\n  maxFiles?: number\n  invalid?: boolean\n  describedBy?: string\n  rules: string\n  onFiles: (files: File[]) => void\n  onReject: (rejections: Rejection[]) => void\n}) {\n  const { t } = useFormConfig()\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    accept: toAccept(accept),\n    multiple,\n    maxSize,\n    maxFiles: multiple ? (maxFiles ?? 0) : 1,\n    onDrop: (accepted, rejections) => {\n      if (accepted.length) onFiles(accepted)\n      if (rejections.length)\n        onReject(rejections.map((r) => ({ file: r.file, reason: reasonOf(r) })))\n    },\n  })\n  return (\n    <div\n      {...getRootProps()}\n      id={id}\n      role=\"button\"\n      tabIndex={0}\n      aria-describedby={describedBy}\n      data-invalid={invalid || undefined}\n      className={cn(\n        'flex cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed px-3 py-4 text-center text-sm transition-colors outline-none focus-visible:border-ring data-invalid:border-destructive',\n        isDragActive ? 'border-ring bg-muted' : 'border-input hover:border-ring',\n      )}\n    >\n      <input {...getInputProps()} />\n      <Upload className=\"size-4 text-muted-foreground\" />\n      {isDragActive ? (\n        <span className=\"font-medium\">{t('dropNow')}</span>\n      ) : (\n        <span>\n          <span className=\"font-medium\">{t(multiple ? 'chooseFiles' : 'chooseFile')}</span>{' '}\n          <span className=\"text-muted-foreground\">{t(multiple ? 'orDropMany' : 'orDropOne')}</span>\n        </span>\n      )}\n      {rules && <span className=\"text-xs text-muted-foreground\">{rules}</span>}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/files.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/group.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { ChevronDown, Plus } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\nimport { Field } from '@/registry/dashboard/components/input/input'\n\nimport { useFormConfig } from './context'\nimport { applyAction, ItemMenu, newItem, UndoBar, useUndo, type ItemAction } from './items'\nimport { hasErrorUnder, type NestedProps } from './rows'\nimport {\n  formatValue,\n  isReadOnly,\n  isRequired,\n  isVisible,\n  scope,\n  type FieldDef,\n  type FieldValue,\n  type Row,\n} from './schema'\n\nconst gridCols = {\n  1: '',\n  2: '@sm:grid-cols-2',\n  3: '@sm:grid-cols-2 @xl:grid-cols-3',\n}\n\nconst wideTypes = new Set(['rows', 'group', 'textarea', 'richtext', 'signature', 'file'])\n\nexport function GroupField({ Control, ...props }: NestedProps) {\n  const {\n    id,\n    field,\n    value,\n    values,\n    disabled,\n    describedBy,\n    onChange,\n    currency,\n    errors = {},\n    fieldTypes,\n    path = field.name,\n  } = props\n  const fields = field.fields ?? []\n  const items = Array.isArray(value) ? (value as Row[]) : []\n  const { t, roles, locale } = useFormConfig()\n  const noun = field.itemLabel ?? t('noun.item_one').replace(/^./, (c) => c.toUpperCase())\n  const nounPlural = (field.itemLabelPlural ?? `${noun}s`).toLowerCase()\n  const canAdd = !disabled && (field.maxLength === undefined || items.length < field.maxLength)\n  const [collapsed, setCollapsed] = useState<string[]>([])\n  const wrap = useRef<HTMLDivElement>(null)\n  const pendingFocus = useRef<number | null>(null)\n  const { undo, offer, dismiss } = useUndo()\n\n  useEffect(() => {\n    const index = pendingFocus.current\n    if (index === null) return\n    pendingFocus.current = null\n    wrap.current\n      ?.querySelector<HTMLElement>(\n        `[data-item=\"${index}\"] [data-body] :is(input:not([type=file]), textarea, button, [role=combobox])`,\n      )\n      ?.focus()\n  }, [value])\n\n  const blank = () => newItem(fields)\n  const setValue = (i: number, name: string, v: FieldValue) =>\n    onChange(items.map((item, j) => (j === i ? { ...item, [name]: v } : item)))\n\n  const act = (i: number, action: ItemAction) => {\n    if (action === 'delete') {\n      const before = items\n      onChange(items.filter((_, j) => j !== i))\n      return offer(t('itemDeleted', { label: noun, n: i + 1 }), () => onChange(before))\n    }\n    const result = applyAction(items, i, action, blank)\n    pendingFocus.current = result.focus\n    onChange(result.list)\n    if (action === 'up' || action === 'down') return\n    setCollapsed((c) => c.filter((x) => x !== result.list[result.focus].id))\n  }\n\n  const title = (item: Row, i: number) => {\n    const titleField = fields.find((f) => f.name === field.titleField)\n    const text = titleField\n      ? formatValue(titleField, item[titleField.name], fieldTypes, { t, locale })\n      : t('empty')\n    return text !== t('empty') ? text : `${noun} ${i + 1}`\n  }\n\n  const subtitle = (item: Row) =>\n    fields\n      .filter(\n        (f) => f.name !== field.titleField && !wideTypes.has(f.type) && f.type !== 'signature',\n      )\n      .map((f) =>\n        formatValue(f, f.compute ? f.compute(scope(values, item)) : item[f.name], fieldTypes, {\n          t,\n          locale,\n        }),\n      )\n      .filter((text) => text !== t('empty'))\n      .slice(0, 3)\n      .join(' · ')\n\n  const body = (item: Row, i: number) => {\n    const ctx = scope(values, item)\n    const columns = field.groupColumns ?? 2\n    return (\n      <div data-body className=\"@container\">\n        <div className={cn('grid gap-4 [&>*]:min-w-0', gridCols[columns])}>\n          {fields.map((f: FieldDef) => {\n            if (!isVisible(f, ctx, roles)) return null\n            const key = `${path}.${i}.${f.name}`\n            const error = errors[key]\n            return (\n              <Field\n                key={f.name}\n                label={f.label}\n                htmlFor={`field-${key}`}\n                hint={f.hint ?? (f.compute ? t('calculated') : undefined)}\n                error={error}\n                required={!disabled && isRequired(f, ctx)}\n                className={cn(wideTypes.has(f.type) && 'col-span-full')}\n              >\n                <Control\n                  id={`field-${key}`}\n                  path={key}\n                  field={f}\n                  value={f.compute ? f.compute(ctx) : item[f.name]}\n                  values={ctx}\n                  invalid={!!error}\n                  disabled={!!disabled || isReadOnly(f, ctx, roles)}\n                  describedBy={\n                    error ? `field-${key}-error` : f.hint ? `field-${key}-hint` : undefined\n                  }\n                  currency={currency}\n                  errors={errors}\n                  fieldTypes={fieldTypes}\n                  onChange={(v) => setValue(i, f.name, v)}\n                />\n              </Field>\n            )\n          })}\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <div ref={wrap} id={id} aria-describedby={describedBy} className=\"flex min-w-0 flex-col gap-2\">\n      {items.map((item, i) => {\n        const failing = hasErrorUnder(errors, `${path}.${i}`)\n        const open = !collapsed.includes(item.id) || failing\n        return (\n          <section\n            key={item.id}\n            data-item={i}\n            className={cn(\n              'rounded-xl border bg-card transition-colors',\n              failing ? 'border-destructive/60' : 'border-border',\n            )}\n            onKeyDown={(e) => {\n              if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return\n              e.preventDefault()\n              e.stopPropagation()\n              act(i, e.key === 'ArrowUp' ? 'up' : 'down')\n            }}\n          >\n            <header className=\"flex items-center gap-2 px-3 py-2\">\n              <button\n                type=\"button\"\n                aria-expanded={open}\n                onClick={() =>\n                  setCollapsed((c) =>\n                    c.includes(item.id) ? c.filter((x) => x !== item.id) : [...c, item.id],\n                  )\n                }\n                className=\"flex min-w-0 flex-1 items-center gap-2 text-left\"\n              >\n                <ChevronDown\n                  className={cn(\n                    'size-4 shrink-0 text-muted-foreground transition-transform',\n                    !open && '-rotate-90',\n                  )}\n                />\n                <span className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium tabular-nums\">\n                  {i + 1}\n                </span>\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate text-sm font-medium\">{title(item, i)}</span>\n                  {!open && subtitle(item) && (\n                    <span className=\"block truncate text-xs text-muted-foreground\">\n                      {subtitle(item)}\n                    </span>\n                  )}\n                </span>\n                {failing && (\n                  <span className=\"shrink-0 text-xs text-destructive\">{t('needsAttention')}</span>\n                )}\n              </button>\n              {!disabled && (\n                <ItemMenu\n                  label={title(item, i)}\n                  index={i}\n                  count={items.length}\n                  canAdd={canAdd}\n                  onAction={(a) => act(i, a)}\n                />\n              )}\n            </header>\n            {open && <div className=\"border-t px-3 py-3\">{body(item, i)}</div>}\n          </section>\n        )\n      })}\n\n      {!items.length && (\n        <div className=\"rounded-xl border border-dashed px-3 py-6 text-center text-sm text-muted-foreground\">\n          {t('noItems', { label: nounPlural })}\n        </div>\n      )}\n\n      {undo && (\n        <UndoBar\n          message={undo.message}\n          onUndo={() => {\n            undo.restore()\n            dismiss()\n          }}\n          onDismiss={dismiss}\n        />\n      )}\n\n      {canAdd && (\n        <div className=\"flex items-center gap-2\">\n          <button\n            type=\"button\"\n            onClick={() => {\n              pendingFocus.current = items.length\n              onChange([...items, blank()])\n            }}\n            className=\"flex h-8 items-center gap-1.5 rounded-lg border border-dashed px-3 text-sm font-medium text-muted-foreground hover:border-ring hover:text-foreground [&_svg]:size-3.5\"\n          >\n            <Plus /> {field.addLabel ?? t('addItem', { label: noun.toLowerCase() })}\n          </button>\n          {items.length > 1 && (\n            <button\n              type=\"button\"\n              onClick={() => setCollapsed(collapsed.length ? [] : items.map((x) => x.id))}\n              className=\"h-8 rounded-lg px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground\"\n            >\n              {collapsed.length ? t('expandAll') : t('collapseAll')}\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/group.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/i18n.ts",
      "content": "export const en = {\n  required: '{label} is required',\n  email: 'Enter a valid email',\n  url: 'Enter a valid URL',\n  number: 'Enter a number',\n  phone: 'Enter a valid phone number',\n  duration: 'Enter a duration like 1h 30m',\n  pattern: 'Invalid format',\n  minNumber: 'Must be at least {min}',\n  maxNumber: 'Must be at most {max}',\n  minDate: 'Must be on or after {min}',\n  maxDate: 'Must be on or before {max}',\n  minTime: 'Must be at or after {min}',\n  maxTime: 'Must be at or before {max}',\n  minChars_one: 'Use at least {count} character',\n  minChars_other: 'Use at least {count} characters',\n  maxChars_one: 'Use at most {count} character',\n  maxChars_other: 'Use at most {count} characters',\n  minCount: 'Add at least {count} {noun}',\n  maxCount: 'Use at most {count} {noun}',\n  maxSize: '{name} is over {max}',\n  accept: '{name} isn’t an accepted file type',\n  sumEquals: '{label} must add up to {equals} (now {total})',\n  sumMin: '{label} must total at least {min}',\n  sumMax: '{label} must total at most {max}',\n  rangeIncomplete: 'Pick a start and an end date',\n  rangeOrder: 'The end date is before the start date',\n  rangeMin: 'Must start on or after {min}',\n  rangeMax: 'Must end on or before {max}',\n  datetime: 'Pick a date and a time',\n  uploading: 'Wait for the upload to finish',\n  uploadFailed: 'Some files failed to upload',\n\n  'noun.row_one': 'row',\n  'noun.row_other': 'rows',\n  'noun.item_one': 'item',\n  'noun.item_other': 'items',\n  'noun.option_one': 'option',\n  'noun.option_other': 'options',\n  'noun.file_one': 'file',\n  'noun.file_other': 'files',\n\n  yes: 'Yes',\n  no: 'No',\n  empty: '—',\n  signed: 'Signed',\n  notSigned: 'Not signed',\n  rows_one: '{count} row',\n  rows_other: '{count} rows',\n  searchFields: 'Search fields',\n  clearSearch: 'Clear search',\n  noFieldsMatch: 'No fields match “{query}”',\n  needsAttention: 'Needs attention',\n  calculated: 'Calculated automatically',\n  clickToEdit: 'Click to edit',\n  editField: 'Edit {label}',\n  done: 'Done',\n  cancel: 'Cancel',\n  edit: 'Edit',\n  next: 'Next',\n  back: 'Back',\n  review: 'Review',\n  submit: 'Submit',\n  saving: 'Saving…',\n  checking: 'Checking…',\n  stepOf: 'Step {step} of {total}',\n\n  addRow: 'Add row',\n  noRows: 'No rows yet',\n  noRowsHint: 'No rows yet — add one below or paste from a spreadsheet',\n  pasteTip: 'Tip: paste from Excel or Sheets · Enter on the last row adds a row',\n  pasted_one: 'Pasted {count} cell',\n  pasted_other: 'Pasted {count} cells',\n  rowsAdded_one: 'added {count} row',\n  rowsAdded_other: 'added {count} rows',\n  rowLabel: 'Row {n}',\n  rowDeleted: 'Row {n} deleted',\n  rowsDeleted: '{count} rows deleted',\n  itemDeleted: '{label} {n} deleted',\n  selected: '{count} selected',\n  selectAll: 'Select all rows',\n  selectRow: 'Select row {n}',\n  dragRow: 'Drag row {n} to reorder (or Alt + arrow keys)',\n  showDetails: 'Show details for row {n}',\n  hideDetails: 'Hide details for row {n}',\n  actionsFor: 'Actions for {label}',\n  insertAbove: 'Insert above',\n  insertBelow: 'Insert below',\n  duplicate: 'Duplicate',\n  moveUp: 'Move up',\n  moveDown: 'Move down',\n  delete: 'Delete',\n  clear: 'Clear',\n  undo: 'Undo',\n  dismiss: 'Dismiss',\n  addItem: 'Add {label}',\n  noItems: 'No {label} yet',\n  expandAll: 'Expand all',\n  collapseAll: 'Collapse all',\n\n  chooseFile: 'Choose a file',\n  chooseFiles: 'Choose files',\n  orDropOne: 'or drop it here',\n  orDropMany: 'or drop them here',\n  dropNow: 'Drop to upload',\n  upTo: 'up to {size}',\n  noFiles: 'No files',\n  uploadingPercent: 'Uploading… {progress}%',\n  uploadError: 'Upload failed',\n  retry: 'Retry',\n  cancelUpload: 'Cancel upload',\n  remove: 'Remove {name}',\n  rejectedSize: '{name} is too large',\n  rejectedType: '{name} isn’t an accepted file type',\n  rejectedCount: 'Too many files',\n\n  searching: 'Searching…',\n  updating: 'Updating…',\n  typeToSearch: 'Type to search',\n  startTyping: 'Start typing to search',\n  noMatches: 'No matches for “{query}”',\n  loadFailed: 'Couldn’t load results.',\n  tryAgain: 'Try again',\n\n  today: 'Today',\n  now: 'Now',\n  thisMonth: 'This month',\n  pickDate: 'Pick a date',\n  pickTime: 'Pick a time',\n  pickMonth: 'Pick a month',\n  pickDates: 'Pick dates',\n  pickDateTime: 'Pick a date and time',\n\n  draftFound: 'You have an unsaved draft from {time}.',\n  restore: 'Restore',\n  discard: 'Discard',\n  autosaving: 'Saving…',\n  autosaved: 'All changes saved',\n  autosavedAt: 'Saved {time}',\n  autosaveFailed: 'Couldn’t save',\n  unsaved: 'Unsaved changes',\n  leaveWarning: 'You have unsaved changes. Leave anyway?',\n\n  search: 'Search',\n  searchOptions: 'Search options',\n  select: 'Select',\n  add: 'Add',\n  createOption: 'Create “{value}”',\n  noMatchesShort: 'No matches',\n  removeOption: 'Remove {label}',\n  listPlaceholder: 'Add an item and press Enter',\n  newItem: 'New item',\n  itemN: 'Item {n}',\n  removeItemN: 'Remove item {n}',\n  addressPlaceholder: 'Start typing an address',\n  phoneNumber: 'Phone number',\n  searchCountry: 'Search country or code',\n  searchCountries: 'Search countries',\n  countryCode: 'Country code, {name} +{dial}',\n  draw: 'Draw',\n  type: 'Type',\n  sign: 'Sign',\n  signatureMethod: 'Signature method',\n  typeName: 'Type your full name',\n  signAbove: 'Sign above the line',\n  expandSignature: 'Expand signature pad',\n  expand: 'Expand',\n  closeWithoutSaving: 'Close without saving',\n  signaturePad: 'Signature pad. Draw with a mouse, finger or pen.',\n  typedSignature: 'Typed signature',\n  bold: 'Bold',\n  italic: 'Italic',\n  underline: 'Underline',\n  bulletList: 'Bulleted list',\n  numberedList: 'Numbered list',\n  link: 'Link',\n  linkAddress: 'Link address',\n  clearFormatting: 'Clear formatting',\n  formatting: 'Formatting',\n  writeSomething: 'Write something…',\n} as const\n\nexport type MessageKey = keyof typeof en\nexport type Messages = Partial<Record<MessageKey | (string & {}), string>>\nexport type Translate = (key: string, vars?: Record<string, unknown>) => string | undefined\nexport type T = (key: MessageKey | (string & {}), vars?: Record<string, unknown>) => string\n\nconst fill = (template: string, vars: Record<string, unknown> = {}) =>\n  template.replace(/\\{(\\w+)\\}/g, (_, k: string) =>\n    vars[k] === undefined ? `{${k}}` : String(vars[k]),\n  )\n\nexport function createT(\n  options: { translate?: Translate; messages?: Messages; locale?: string } = {},\n): T {\n  const plural = new Intl.PluralRules(options.locale ?? 'en')\n  const lookup = (key: string, vars?: Record<string, unknown>) =>\n    options.translate?.(key, vars) ?? options.messages?.[key] ?? (en as Record<string, string>)[key]\n  return (key, vars) => {\n    if (vars && typeof vars.count === 'number') {\n      const form = plural.select(vars.count)\n      const found = lookup(`${key}_${form}`, vars) ?? lookup(`${key}_other`, vars)\n      if (found) return fill(found, vars)\n    }\n    const found = lookup(key, vars)\n    return found === undefined ? key : fill(found, vars)\n  }\n}\n\nexport const defaultT = createT()\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/i18n.ts"
    },
    {
      "path": "registry/dashboard/components/record-form/items.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport {\n  ArrowDown,\n  ArrowUp,\n  Copy,\n  CornerDownLeft,\n  CornerUpLeft,\n  MoreHorizontal,\n  Trash2,\n} from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nimport { toast } from '@/registry/dashboard/components/toast/toast'\n\nimport { useFormConfig } from './context'\nimport { optionRow } from './controls'\nimport { Popover } from '@/registry/dashboard/components/popover/popover'\nimport { isNested, type FieldDef, type FieldValue, type Row } from './schema'\n\nexport const newId = () => Math.random().toString(36).slice(2, 10)\n\nexport function newItem(fields: FieldDef[]): Row {\n  const item: Row = { id: newId() }\n  for (const f of fields) {\n    if (f.compute) continue\n    item[f.name] =\n      f.type === 'toggle'\n        ? false\n        : isNested(f) || f.type === 'multiselect' || f.type === 'list' || f.type === 'file'\n          ? []\n          : ''\n  }\n  return item\n}\n\nexport function cloneItem(item: Row): Row {\n  const copy = JSON.parse(JSON.stringify(item)) as Row\n  const reId = (value: FieldValue | undefined): FieldValue | undefined =>\n    Array.isArray(value)\n      ? ((value as unknown[]).map((v) =>\n          v && typeof v === 'object' && 'id' in (v as object)\n            ? Object.fromEntries(\n                Object.entries(v as Row).map(([k, inner]) => [\n                  k,\n                  k === 'id' ? newId() : reId(inner),\n                ]),\n              )\n            : v,\n        ) as FieldValue)\n      : value\n  return Object.fromEntries(\n    Object.entries(copy).map(([k, v]) => [k, k === 'id' ? newId() : reId(v)]),\n  ) as Row\n}\n\nexport function move<T>(list: T[], from: number, to: number) {\n  if (to < 0 || to >= list.length || from === to) return list\n  const next = [...list]\n  const [item] = next.splice(from, 1)\n  next.splice(to, 0, item)\n  return next\n}\n\nexport type ItemAction = 'above' | 'below' | 'duplicate' | 'up' | 'down' | 'delete'\n\nexport function ItemMenu({\n  label,\n  index,\n  count,\n  canAdd,\n  onAction,\n}: {\n  label: string\n  index: number\n  count: number\n  canAdd: boolean\n  onAction: (action: ItemAction) => void\n}) {\n  const { t } = useFormConfig()\n  const items: {\n    action: ItemAction\n    text: string\n    icon: React.ReactNode\n    hidden?: boolean\n    danger?: boolean\n  }[] = [\n    { action: 'above', text: t('insertAbove'), icon: <CornerUpLeft />, hidden: !canAdd },\n    { action: 'below', text: t('insertBelow'), icon: <CornerDownLeft />, hidden: !canAdd },\n    { action: 'duplicate', text: t('duplicate'), icon: <Copy />, hidden: !canAdd },\n    { action: 'up', text: t('moveUp'), icon: <ArrowUp />, hidden: index === 0 },\n    { action: 'down', text: t('moveDown'), icon: <ArrowDown />, hidden: index === count - 1 },\n    { action: 'delete', text: t('delete'), icon: <Trash2 />, danger: true },\n  ]\n  return (\n    <div className=\"shrink-0\">\n      <Popover\n        align=\"end\"\n        className=\"w-44\"\n        trigger={({ open, toggle }) => (\n          <button\n            type=\"button\"\n            aria-label={t('actionsFor', { label })}\n            aria-haspopup=\"menu\"\n            aria-expanded={open}\n            onClick={toggle}\n            className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5\"\n          >\n            <MoreHorizontal />\n          </button>\n        )}\n      >\n        {(close) => (\n          <ul role=\"menu\">\n            {items\n              .filter((i) => !i.hidden)\n              .map((i) => (\n                <li key={i.action}>\n                  <button\n                    type=\"button\"\n                    role=\"menuitem\"\n                    onClick={() => {\n                      close()\n                      onAction(i.action)\n                    }}\n                    className={cn(\n                      optionRow,\n                      '[&_svg]:size-3.5 [&_svg]:text-muted-foreground',\n                      i.danger && 'text-destructive [&_svg]:text-destructive',\n                    )}\n                  >\n                    {i.icon}\n                    {i.text}\n                  </button>\n                </li>\n              ))}\n          </ul>\n        )}\n      </Popover>\n    </div>\n  )\n}\n\nexport function useUndo() {\n  const { t } = useFormConfig()\n  const [undo, setUndo] = useState<{ message: string; restore: () => void } | null>(null)\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)\n  useEffect(() => () => void (timer.current && clearTimeout(timer.current)), [])\n  const offer = (message: string, restore: () => void) => {\n    if (toast.isMounted()) {\n      toast(message, { duration: 6000, action: { label: t('undo'), onClick: restore } })\n      return\n    }\n    if (timer.current) clearTimeout(timer.current)\n    setUndo({ message, restore })\n    timer.current = setTimeout(() => setUndo(null), 6000)\n  }\n  const dismiss = () => setUndo(null)\n  return { undo, offer, dismiss }\n}\n\nexport function UndoBar({\n  message,\n  onUndo,\n  onDismiss,\n}: {\n  message: string\n  onUndo: () => void\n  onDismiss: () => void\n}) {\n  const { t } = useFormConfig()\n  return (\n    <div\n      role=\"status\"\n      className=\"flex items-center justify-between gap-3 rounded-lg bg-foreground px-3 py-2 text-sm text-background\"\n    >\n      <span>{message}</span>\n      <span className=\"flex items-center gap-1\">\n        <button\n          type=\"button\"\n          onClick={onUndo}\n          className=\"rounded px-2 py-0.5 font-medium hover:bg-background/15\"\n        >\n          {t('undo')}\n        </button>\n        <button\n          type=\"button\"\n          aria-label={t('dismiss')}\n          onClick={onDismiss}\n          className=\"rounded px-1.5 py-0.5 opacity-70 hover:opacity-100\"\n        >\n          ✕\n        </button>\n      </span>\n    </div>\n  )\n}\n\nexport function applyAction(list: Row[], index: number, action: ItemAction, blank: () => Row) {\n  switch (action) {\n    case 'above':\n      return { list: [...list.slice(0, index), blank(), ...list.slice(index)], focus: index }\n    case 'below':\n      return {\n        list: [...list.slice(0, index + 1), blank(), ...list.slice(index + 1)],\n        focus: index + 1,\n      }\n    case 'duplicate':\n      return {\n        list: [...list.slice(0, index + 1), cloneItem(list[index]), ...list.slice(index + 1)],\n        focus: index + 1,\n      }\n    case 'up':\n      return { list: move(list, index, index - 1), focus: index - 1 }\n    case 'down':\n      return { list: move(list, index, index + 1), focus: index + 1 }\n    case 'delete':\n      return { list: list.filter((_, i) => i !== index), focus: Math.max(0, index - 1) }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/items.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/lookup.tsx",
      "content": "'use client'\n\nimport { useId, useRef, useState } from 'react'\nimport { Loader2, MapPin, Search } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Avatar, Check, OptionView, optionRow, triggerClass } from './controls'\nimport { useFormConfig } from './context'\nimport { Popover } from '@/registry/dashboard/components/popover/popover'\nimport { findOption, rememberOptions, type Option, type OptionObject } from './schema'\n\ntype Status = 'idle' | 'loading' | 'done' | 'error'\n\nfunction useSearch(search?: (query: string) => Promise<OptionObject[]>) {\n  const [items, setItems] = useState<OptionObject[]>([])\n  const [status, setStatus] = useState<Status>('idle')\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const latest = useRef(0)\n\n  const run = (query: string, delay = 250) => {\n    if (!search) return\n    if (timer.current) clearTimeout(timer.current)\n    const id = ++latest.current\n    setStatus('loading')\n    timer.current = setTimeout(() => {\n      search(query).then(\n        (found) => {\n          if (id !== latest.current) return\n          rememberOptions(found)\n          setItems(found)\n          setStatus('done')\n        },\n        () => id === latest.current && setStatus('error'),\n      )\n    }, delay)\n  }\n\n  return { items, status, run }\n}\n\nfunction Results({\n  listId,\n  items,\n  status,\n  query,\n  active,\n  selected,\n  multiple,\n  onPick,\n  onRetry,\n  onHover,\n}: {\n  listId: string\n  items: OptionObject[]\n  status: Status\n  query: string\n  active: number\n  selected: string[]\n  multiple?: boolean\n  onPick: (option: OptionObject) => void\n  onRetry: () => void\n  onHover: (index: number) => void\n}) {\n  const { t } = useFormConfig()\n  if (status === 'error')\n    return (\n      <div className=\"flex items-center justify-between gap-2 px-2 py-2 text-sm text-muted-foreground\">\n        {t('loadFailed')}\n        <button\n          type=\"button\"\n          onClick={onRetry}\n          className=\"font-medium text-foreground hover:underline\"\n        >\n          {t('tryAgain')}\n        </button>\n      </div>\n    )\n  return (\n    <ul\n      id={listId}\n      role=\"listbox\"\n      aria-multiselectable={multiple}\n      className=\"max-h-64 overflow-y-auto\"\n    >\n      {status === 'loading' && !items.length && (\n        <li className=\"flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground\">\n          <Loader2 className=\"size-3.5 animate-spin\" /> {t('searching')}\n        </li>\n      )}\n      {items.map((option, i) => {\n        const on = selected.includes(option.value)\n        return (\n          <li key={option.value}>\n            <button\n              type=\"button\"\n              role=\"option\"\n              id={`${listId}-${i}`}\n              aria-selected={on}\n              tabIndex={-1}\n              onMouseEnter={() => onHover(i)}\n              onClick={() => onPick(option)}\n              className={cn(optionRow, i === active && 'bg-muted')}\n            >\n              {multiple && <Check on={on} />}\n              <OptionView option={option} />\n              {!multiple && on && <span aria-hidden>✓</span>}\n            </button>\n          </li>\n        )\n      })}\n      {status === 'done' && !items.length && (\n        <li className=\"px-2 py-2 text-sm text-muted-foreground\">\n          {query.trim() ? t('noMatches', { query: query.trim() }) : t('startTyping')}\n        </li>\n      )}\n      {status === 'loading' && items.length > 0 && (\n        <li className=\"flex items-center gap-2 px-2 pt-1 text-xs text-muted-foreground\">\n          <Loader2 className=\"size-3 animate-spin\" /> {t('updating')}\n        </li>\n      )}\n    </ul>\n  )\n}\n\nexport function LookupField({\n  id,\n  value,\n  multiple = false,\n  search,\n  options = [],\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string | string[]\n  multiple?: boolean\n  search?: (query: string) => Promise<OptionObject[]>\n  options?: Option[]\n  placeholder?: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string | string[]) => void\n}) {\n  const listId = useId()\n  const { t } = useFormConfig()\n  const [query, setQuery] = useState('')\n  const [active, setActive] = useState(0)\n  const { items, status, run } = useSearch(search)\n  const selected = Array.isArray(value) ? value : value ? [value] : []\n  const label = (v: string) => findOption({ options }, v) ?? { value: v, label: v }\n\n  const pick = (option: OptionObject, close: () => void) => {\n    rememberOptions([option])\n    if (multiple) {\n      onChange(\n        selected.includes(option.value)\n          ? selected.filter((v) => v !== option.value)\n          : [...selected, option.value],\n      )\n    } else {\n      onChange(option.value === value ? '' : option.value)\n      close()\n    }\n  }\n\n  return (\n    <Popover\n      block\n      className=\"w-full min-w-64\"\n      trigger={({ open, toggle }) => (\n        <div\n          id={id}\n          role=\"combobox\"\n          tabIndex={disabled ? -1 : 0}\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={listId}\n          aria-disabled={disabled || undefined}\n          aria-describedby={describedBy}\n          data-invalid={invalid || undefined}\n          onClick={() => {\n            if (disabled) return\n            if (!open) {\n              setQuery('')\n              setActive(0)\n              run('', 0)\n            }\n            toggle()\n          }}\n          onKeyDown={(e) => {\n            if (disabled || open || !['Enter', ' ', 'ArrowDown'].includes(e.key)) return\n            e.preventDefault()\n            setQuery('')\n            run('', 0)\n            toggle()\n          }}\n          className={cn(\n            triggerClass,\n            'cursor-pointer justify-start data-invalid:border-destructive',\n            multiple && 'flex-wrap',\n          )}\n        >\n          <Search className=\"size-3.5 shrink-0 text-muted-foreground\" />\n          {selected.length === 0 && (\n            <span className=\"truncate text-muted-foreground\">{placeholder ?? t('search')}</span>\n          )}\n          {!multiple && selected[0] && (\n            <span className=\"flex min-w-0 items-center gap-2\">\n              <Avatar option={label(selected[0])} size=\"xs\" />\n              <span className=\"truncate\">{label(selected[0]).label}</span>\n            </span>\n          )}\n          {multiple &&\n            selected.map((v) => (\n              <span\n                key={v}\n                className=\"flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-1.5 text-xs\"\n              >\n                <Avatar option={label(v)} size=\"xs\" />\n                {label(v).label}\n                {!disabled && (\n                  <button\n                    type=\"button\"\n                    aria-label={t('removeOption', { label: label(v).label })}\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      onChange(selected.filter((s) => s !== v))\n                    }}\n                    className=\"px-0.5 text-muted-foreground hover:text-foreground\"\n                  >\n                    ✕\n                  </button>\n                )}\n              </span>\n            ))}\n        </div>\n      )}\n    >\n      {(close) => (\n        <>\n          <input\n            autoFocus\n            value={query}\n            placeholder={t('typeToSearch')}\n            aria-label={t('search')}\n            aria-controls={listId}\n            aria-activedescendant={items[active] ? `${listId}-${active}` : undefined}\n            onChange={(e) => {\n              setQuery(e.target.value)\n              setActive(0)\n              run(e.target.value)\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'ArrowDown') {\n                e.preventDefault()\n                setActive(Math.min(items.length - 1, active + 1))\n              } else if (e.key === 'ArrowUp') {\n                e.preventDefault()\n                setActive(Math.max(0, active - 1))\n              } else if (e.key === 'Enter' && items[active]) {\n                e.preventDefault()\n                pick(items[active], close)\n              }\n            }}\n            className=\"mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring\"\n          />\n          <Results\n            listId={listId}\n            items={items}\n            status={status}\n            query={query}\n            active={active}\n            selected={selected}\n            multiple={multiple}\n            onPick={(o) => pick(o, close)}\n            onRetry={() => run(query, 0)}\n            onHover={setActive}\n          />\n        </>\n      )}\n    </Popover>\n  )\n}\n\nexport function AddressField({\n  id,\n  value,\n  search,\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string\n  search?: (query: string) => Promise<OptionObject[]>\n  placeholder?: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string) => void\n}) {\n  const listId = useId()\n  const { t } = useFormConfig()\n  const [open, setOpen] = useState(false)\n  const [active, setActive] = useState(-1)\n  const { items, status, run } = useSearch(search)\n  const show = open && value.trim().length >= 3 && status !== 'idle'\n\n  const choose = (option: OptionObject) => {\n    onChange(option.label)\n    setOpen(false)\n  }\n\n  return (\n    <div className=\"relative w-full\">\n      <div className=\"flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-input bg-card px-2.5 text-sm focus-within:border-ring has-read-only:bg-muted/40 has-aria-invalid:border-destructive\">\n        <MapPin className=\"size-3.5 shrink-0 text-muted-foreground\" />\n        <input\n          id={id}\n          role=\"combobox\"\n          aria-expanded={show}\n          aria-controls={listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={show && items[active] ? `${listId}-${active}` : undefined}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          autoComplete=\"off\"\n          readOnly={disabled}\n          value={value}\n          placeholder={placeholder ?? t('addressPlaceholder')}\n          onChange={(e) => {\n            onChange(e.target.value)\n            setActive(-1)\n            setOpen(true)\n            if (e.target.value.trim().length >= 3) run(e.target.value)\n          }}\n          onBlur={() => setTimeout(() => setOpen(false), 120)}\n          onKeyDown={(e) => {\n            if (!show) return\n            if (e.key === 'ArrowDown') {\n              e.preventDefault()\n              setActive(Math.min(items.length - 1, active + 1))\n            } else if (e.key === 'ArrowUp') {\n              e.preventDefault()\n              setActive(Math.max(0, active - 1))\n            } else if (e.key === 'Enter' && items[active]) {\n              e.preventDefault()\n              choose(items[active])\n            } else if (e.key === 'Escape') setOpen(false)\n          }}\n          className=\"h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground\"\n        />\n        {status === 'loading' && open && (\n          <Loader2 className=\"size-3.5 shrink-0 animate-spin text-muted-foreground\" />\n        )}\n      </div>\n      {show && (\n        <div className=\"absolute top-full left-0 z-50 mt-1.5 w-full min-w-64 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-(--shadow-overlay)\">\n          <Results\n            listId={listId}\n            items={items}\n            status={status}\n            query={value}\n            active={active}\n            selected={[]}\n            onPick={choose}\n            onRetry={() => run(value, 0)}\n            onHover={setActive}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/lookup.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/number-input.tsx",
      "content": "'use client'\n\nimport { useLayoutEffect, useRef, useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { Input } from '@/registry/dashboard/components/input/input'\n\nimport { parseDuration, showDuration } from './schema'\n\nconst significant = /[\\d.-]/\n\nfunction clean(raw: string, decimals?: number) {\n  let out = raw.replace(/[^\\d.-]/g, '')\n  const negative = out.startsWith('-')\n  out = out.replace(/-/g, '')\n  const [int, ...rest] = out.split('.')\n  let frac = rest.join('')\n  if (decimals !== undefined) frac = frac.slice(0, decimals)\n  const dot = out.includes('.') && decimals !== 0\n  return `${negative ? '-' : ''}${int.replace(/^0+(?=\\d)/, '')}${dot ? `.${frac}` : ''}`\n}\n\nfunction display(value: string) {\n  if (!value) return ''\n  const negative = value.startsWith('-')\n  const [int, frac] = value.replace('-', '').split('.')\n  const grouped = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')\n  return `${negative ? '-' : ''}${grouped}${frac !== undefined ? `.${frac}` : ''}`\n}\n\nexport function NumberField({\n  id,\n  value,\n  decimals,\n  leading,\n  trailing,\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string\n  decimals?: number\n  leading?: React.ReactNode\n  trailing?: React.ReactNode\n  placeholder?: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string) => void\n}) {\n  const input = useRef<HTMLInputElement>(null)\n  const [caret, setCaret] = useState<number | null>(null)\n  const shown = display(value)\n\n  useLayoutEffect(() => {\n    const el = input.current\n    if (caret === null || !el || document.activeElement !== el) return\n    let seen = 0\n    let pos = 0\n    while (pos < shown.length && seen < caret) {\n      if (significant.test(shown[pos])) seen++\n      pos++\n    }\n    el.setSelectionRange(pos, pos)\n  }, [caret, shown])\n\n  return (\n    <Input\n      ref={input}\n      id={id}\n      inputMode=\"decimal\"\n      value={shown}\n      readOnly={disabled}\n      placeholder={placeholder}\n      aria-invalid={invalid || undefined}\n      aria-describedby={describedBy}\n      leading={leading}\n      trailing={trailing}\n      onChange={(e) => {\n        const before = e.target.value.slice(0, e.target.selectionStart ?? e.target.value.length)\n        setCaret([...before].filter((c) => significant.test(c)).length)\n        onChange(clean(e.target.value, decimals))\n      }}\n      onBlur={() => {\n        if (decimals === undefined || !value || value === '-') return\n        const n = Number(value)\n        if (!Number.isNaN(n)) onChange(n.toFixed(decimals))\n      }}\n      className={cn('tabular-nums', disabled && 'bg-muted/40')}\n    />\n  )\n}\n\nexport function DurationField({\n  id,\n  value,\n  placeholder = 'e.g. 1h 30m',\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string\n  placeholder?: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string) => void\n}) {\n  const [draft, setDraft] = useState<string | null>(null)\n  const minutes = value === '' ? null : Number(value)\n  const pretty = minutes !== null && !Number.isNaN(minutes) ? showDuration(minutes) : value\n  const preview = draft !== null ? parseDuration(draft) : null\n\n  const commit = () => {\n    if (draft === null) return\n    const parsed = parseDuration(draft)\n    onChange(parsed === null ? draft.trim() : String(parsed))\n    setDraft(null)\n  }\n\n  return (\n    <Input\n      id={id}\n      value={draft ?? pretty}\n      readOnly={disabled}\n      placeholder={placeholder}\n      aria-invalid={invalid || undefined}\n      aria-describedby={describedBy}\n      onFocus={() => setDraft(pretty)}\n      onChange={(e) => setDraft(e.target.value)}\n      onBlur={commit}\n      onKeyDown={(e) => e.key === 'Enter' && commit()}\n      trailing={\n        draft !== null && draft.trim() ? (\n          <span className={cn('text-xs', preview === null && 'text-destructive')}>\n            {preview === null ? 'Not a duration' : `= ${showDuration(preview)}`}\n          </span>\n        ) : undefined\n      }\n      className={cn(disabled && 'bg-muted/40')}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/number-input.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/phone.tsx",
      "content": "'use client'\n\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { useFormConfig } from './context'\nimport { optionRow } from './controls'\nimport { Popover } from '@/registry/dashboard/components/popover/popover'\n\nexport type Country = { code: string; name: string; dial: string }\n\nexport const countries: Country[] = [\n  { code: 'ET', name: 'Ethiopia', dial: '251' },\n  { code: 'US', name: 'United States', dial: '1' },\n  { code: 'CA', name: 'Canada', dial: '1' },\n  { code: 'GB', name: 'United Kingdom', dial: '44' },\n  { code: 'DE', name: 'Germany', dial: '49' },\n  { code: 'FR', name: 'France', dial: '33' },\n  { code: 'IT', name: 'Italy', dial: '39' },\n  { code: 'ES', name: 'Spain', dial: '34' },\n  { code: 'NL', name: 'Netherlands', dial: '31' },\n  { code: 'SE', name: 'Sweden', dial: '46' },\n  { code: 'CH', name: 'Switzerland', dial: '41' },\n  { code: 'TR', name: 'Turkey', dial: '90' },\n  { code: 'AE', name: 'United Arab Emirates', dial: '971' },\n  { code: 'SA', name: 'Saudi Arabia', dial: '966' },\n  { code: 'EG', name: 'Egypt', dial: '20' },\n  { code: 'KE', name: 'Kenya', dial: '254' },\n  { code: 'UG', name: 'Uganda', dial: '256' },\n  { code: 'TZ', name: 'Tanzania', dial: '255' },\n  { code: 'RW', name: 'Rwanda', dial: '250' },\n  { code: 'DJ', name: 'Djibouti', dial: '253' },\n  { code: 'SO', name: 'Somalia', dial: '252' },\n  { code: 'SD', name: 'Sudan', dial: '249' },\n  { code: 'NG', name: 'Nigeria', dial: '234' },\n  { code: 'GH', name: 'Ghana', dial: '233' },\n  { code: 'ZA', name: 'South Africa', dial: '27' },\n  { code: 'IN', name: 'India', dial: '91' },\n  { code: 'CN', name: 'China', dial: '86' },\n  { code: 'JP', name: 'Japan', dial: '81' },\n  { code: 'KR', name: 'South Korea', dial: '82' },\n  { code: 'SG', name: 'Singapore', dial: '65' },\n  { code: 'AU', name: 'Australia', dial: '61' },\n  { code: 'BR', name: 'Brazil', dial: '55' },\n  { code: 'MX', name: 'Mexico', dial: '52' },\n]\n\nconst flag = (code: string) =>\n  String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))\n\nconst group = (digits: string) => digits.replace(/(\\d{3})(?=\\d)/g, '$1 ').trim()\n\nexport function splitPhone(value: string, fallback: string) {\n  const match = /^\\+(\\d+)\\s*(.*)$/.exec(value.trim())\n  if (!match)\n    return {\n      country: countries.find((c) => c.code === fallback) ?? countries[0],\n      national: value.replace(/\\D/g, ''),\n    }\n  const digits = match[1] + match[2].replace(/\\D/g, '')\n  const preferred = countries.find((c) => c.code === fallback)\n  const country =\n    (preferred && digits.startsWith(preferred.dial) ? preferred : undefined) ??\n    [...countries]\n      .sort((a, b) => b.dial.length - a.dial.length)\n      .find((c) => digits.startsWith(c.dial)) ??\n    countries[0]\n  return { country, national: digits.slice(country.dial.length) }\n}\n\nexport function PhoneField({\n  id,\n  value,\n  defaultCountry = 'US',\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string\n  defaultCountry?: string\n  placeholder?: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string) => void\n}) {\n  const { t } = useFormConfig()\n  const parsed = splitPhone(value, defaultCountry)\n  const [chosen, setChosen] = useState<Country | null>(null)\n  const country = chosen ?? parsed.country\n  const [query, setQuery] = useState('')\n  const shown = countries.filter(\n    (c) =>\n      c.name.toLowerCase().includes(query.toLowerCase()) ||\n      c.dial.startsWith(query.replace(/\\D/g, '') || '§'),\n  )\n  const emit = (c: Country, national: string) =>\n    onChange(national ? `+${c.dial} ${group(national)}` : '')\n\n  return (\n    <div\n      className={cn(\n        'flex h-8 w-full min-w-0 items-center rounded-lg border border-input bg-card text-sm focus-within:border-ring',\n        invalid && 'border-destructive',\n        disabled && 'bg-muted/40',\n      )}\n    >\n      <div className=\"shrink-0\">\n        <Popover\n          className=\"w-64\"\n          trigger={({ open, toggle }) => (\n            <button\n              type=\"button\"\n              aria-label={t('countryCode', { name: country.name, dial: country.dial })}\n              aria-haspopup=\"listbox\"\n              aria-expanded={open}\n              disabled={disabled}\n              onClick={toggle}\n              className=\"flex h-[30px] items-center gap-1.5 rounded-l-lg border-r border-input px-2 tabular-nums hover:bg-muted disabled:hover:bg-transparent\"\n            >\n              <span aria-hidden>{flag(country.code)}</span>\n              <span className=\"text-muted-foreground\">+{country.dial}</span>\n            </button>\n          )}\n        >\n          {(close) => (\n            <>\n              <input\n                autoFocus\n                value={query}\n                placeholder={t('searchCountry')}\n                aria-label={t('searchCountries')}\n                onChange={(e) => setQuery(e.target.value)}\n                className=\"mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring\"\n              />\n              <ul role=\"listbox\" className=\"max-h-64 overflow-y-auto\">\n                {shown.map((c) => (\n                  <li key={c.code}>\n                    <button\n                      type=\"button\"\n                      role=\"option\"\n                      aria-selected={c.code === country.code}\n                      onClick={() => {\n                        setChosen(c)\n                        emit(c, parsed.national)\n                        setQuery('')\n                        close()\n                      }}\n                      className={optionRow}\n                    >\n                      <span aria-hidden>{flag(c.code)}</span>\n                      <span className=\"flex-1 truncate\">{c.name}</span>\n                      <span className=\"text-muted-foreground tabular-nums\">+{c.dial}</span>\n                    </button>\n                  </li>\n                ))}\n              </ul>\n            </>\n          )}\n        </Popover>\n      </div>\n      <input\n        id={id}\n        type=\"tel\"\n        inputMode=\"tel\"\n        autoComplete=\"tel-national\"\n        readOnly={disabled}\n        aria-invalid={invalid || undefined}\n        aria-describedby={describedBy}\n        value={group(parsed.national)}\n        placeholder={placeholder ?? t('phoneNumber')}\n        onChange={(e) => {\n          const raw = e.target.value.trim()\n          if (raw.startsWith('+')) {\n            const next = splitPhone(raw, country.code)\n            setChosen(next.country)\n            return emit(next.country, next.national)\n          }\n          emit(country, raw.replace(/\\D/g, '').slice(0, 15))\n        }}\n        className=\"h-full min-w-0 flex-1 bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground\"\n      />\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/phone.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/resolver.ts",
      "content": "import type { FieldErrors, Resolver } from 'react-hook-form'\n\nimport { createT, type Messages, type Translate } from './i18n'\nimport {\n  applyComputed,\n  validateRecord,\n  type FieldTypeMap,\n  type FormErrors,\n  type FormSection,\n  type FormValues,\n} from './schema'\n\ntype PathSegment = PropertyKey | { readonly key: PropertyKey }\n\ntype StandardIssue = { readonly message: string; readonly path?: ReadonlyArray<PathSegment> }\n\ntype StandardResult =\n  | { readonly value: unknown; readonly issues?: undefined }\n  | { readonly issues: ReadonlyArray<StandardIssue> }\n\nexport type StandardSchema = {\n  readonly '~standard': {\n    readonly version: 1\n    readonly vendor: string\n    readonly validate: (value: unknown) => StandardResult | Promise<StandardResult>\n  }\n}\n\nexport const FORM_ERROR = '_form'\n\nexport type RecordFormResolverOptions = {\n  sections: FormSection[]\n  schema?: StandardSchema\n  fieldTypes?: FieldTypeMap\n  roles?: string[]\n  locale?: string\n  messages?: Messages\n  translate?: Translate\n}\n\nfunction issuesToErrors(issues: ReadonlyArray<StandardIssue>): FormErrors {\n  const errors: FormErrors = {}\n  for (const issue of issues) {\n    const key =\n      issue.path\n        ?.map((p) => String(typeof p === 'object' && p !== null && 'key' in p ? p.key : p))\n        .join('.') || FORM_ERROR\n    errors[key] ??= issue.message\n  }\n  return errors\n}\n\nfunction nestErrors(flat: FormErrors): FieldErrors<FormValues> {\n  const out: Record<string, unknown> = {}\n  for (const [path, message] of Object.entries(flat)) {\n    const keys = path.split('.')\n    let node = out\n    keys.forEach((key, i) => {\n      if (i === keys.length - 1) node[key] = { ...(node[key] as object), type: 'validate', message }\n      else node = (node[key] ??= {}) as Record<string, unknown>\n    })\n  }\n  return out as FieldErrors<FormValues>\n}\n\nexport function flattenErrors(errors: unknown, prefix = '', out: FormErrors = {}): FormErrors {\n  if (!errors || typeof errors !== 'object') return out\n  for (const [key, node] of Object.entries(errors as Record<string, unknown>)) {\n    if (key === 'ref' || key === 'types' || !node || typeof node !== 'object') continue\n    const path = prefix ? `${prefix}.${key}` : key\n    const message = (node as { message?: unknown }).message\n    if (typeof message === 'string' && message) out[path] = message\n    flattenErrors(node, path, out)\n  }\n  return out\n}\n\nexport function recordFormResolver({\n  sections,\n  schema,\n  fieldTypes,\n  roles,\n  locale,\n  messages,\n  translate,\n}: RecordFormResolverOptions): Resolver<FormValues> {\n  const t = createT({ messages, translate, locale })\n  return async (values) => {\n    const computed = applyComputed(sections, values)\n    const result = schema ? await schema['~standard'].validate(computed) : undefined\n    const errors = {\n      ...validateRecord(sections, computed, fieldTypes, { t, locale, roles }),\n      ...(result?.issues ? issuesToErrors(result.issues) : {}),\n    }\n    return Object.keys(errors).length\n      ? { values: {}, errors: nestErrors(errors) }\n      : { values: computed, errors: {} }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/resolver.ts"
    },
    {
      "path": "registry/dashboard/components/record-form/rich-text.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { Bold, Italic, Link2, List, ListOrdered, RemoveFormatting, Underline } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nimport { useFormConfig } from './context'\nimport { plainText } from './schema'\n\nconst ALLOWED = new Set(['B', 'STRONG', 'I', 'EM', 'U', 'P', 'BR', 'UL', 'OL', 'LI', 'A', 'DIV'])\nconst DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'TEMPLATE', 'NOSCRIPT'])\nconst SAFE_URL = /^(https?:|mailto:)/i\n\nexport function sanitizeHtml(html: string) {\n  if (typeof window === 'undefined') return ''\n  const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')\n  const root = doc.body.firstElementChild as HTMLElement\n  const clean = (node: Element) => {\n    for (const child of [...node.children]) {\n      if (DROP.has(child.tagName)) {\n        child.remove()\n        continue\n      }\n      clean(child)\n      if (!ALLOWED.has(child.tagName)) {\n        child.replaceWith(...child.childNodes)\n        continue\n      }\n      const href = child.getAttribute('href')\n      for (const attr of [...child.attributes]) child.removeAttribute(attr.name)\n      if (child.tagName === 'A') {\n        if (href && SAFE_URL.test(href.trim())) {\n          child.setAttribute('href', href.trim())\n          child.setAttribute('target', '_blank')\n          child.setAttribute('rel', 'noopener noreferrer')\n        } else child.replaceWith(...child.childNodes)\n      }\n    }\n  }\n  clean(root)\n  return root.innerHTML\n}\n\nconst tools = [\n  { command: 'bold', label: 'bold', icon: <Bold />, keys: 'Ctrl+B' },\n  { command: 'italic', label: 'italic', icon: <Italic />, keys: 'Ctrl+I' },\n  { command: 'underline', label: 'underline', icon: <Underline />, keys: 'Ctrl+U' },\n  { command: 'insertUnorderedList', label: 'bulletList', icon: <List /> },\n  { command: 'insertOrderedList', label: 'numberedList', icon: <ListOrdered /> },\n]\n\nconst prose =\n  '[&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_p]:my-1'\n\nexport function RichTextField({\n  id,\n  value,\n  placeholder,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string\n  placeholder?: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string) => void\n}) {\n  const { t } = useFormConfig()\n  const editor = useRef<HTMLDivElement>(null)\n  const emitted = useRef<string | null>(null)\n  const saved = useRef<Range | null>(null)\n  const [active, setActive] = useState<Record<string, boolean>>({})\n  const [linking, setLinking] = useState(false)\n  const [url, setUrl] = useState('')\n  const empty = !plainText(value)\n\n  useEffect(() => {\n    const el = editor.current\n    if (!el || (value === emitted.current && el.dataset.filled)) return\n    el.innerHTML = sanitizeHtml(value)\n    el.dataset.filled = '1'\n    emitted.current = value\n  }, [value, disabled])\n\n  useEffect(() => {\n    if (disabled) return\n    const update = () => {\n      if (!editor.current?.contains(document.getSelection()?.anchorNode ?? null)) return\n      setActive(\n        Object.fromEntries(\n          tools.map((tool) => [tool.command, document.queryCommandState(tool.command)]),\n        ),\n      )\n    }\n    document.addEventListener('selectionchange', update)\n    return () => document.removeEventListener('selectionchange', update)\n  }, [disabled])\n\n  const emit = () => {\n    const html = sanitizeHtml(editor.current?.innerHTML ?? '')\n    emitted.current = html\n    onChange(plainText(html) || /<img/i.test(html) ? html : '')\n  }\n\n  const run = (command: string, arg?: string) => {\n    editor.current?.focus()\n    document.execCommand(command, false, arg)\n    emit()\n  }\n\n  const startLink = () => {\n    const selection = document.getSelection()\n    saved.current = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null\n    setUrl('https://')\n    setLinking(true)\n  }\n\n  const applyLink = () => {\n    const href = url.trim()\n    setLinking(false)\n    if (!SAFE_URL.test(href) || !saved.current) return\n    editor.current?.focus()\n    const selection = document.getSelection()\n    selection?.removeAllRanges()\n    selection?.addRange(saved.current)\n    run('createLink', href)\n  }\n\n  if (disabled) {\n    return (\n      <div\n        ref={editor}\n        id={id}\n        aria-describedby={describedBy}\n        className={cn(\n          'min-h-8 rounded-lg bg-muted/40 px-2.5 py-1.5 text-sm leading-relaxed',\n          prose,\n        )}\n      />\n    )\n  }\n\n  return (\n    <div\n      className={cn(\n        'rounded-lg border border-input bg-card transition-[border-color] focus-within:border-ring',\n        invalid && 'border-destructive',\n      )}\n    >\n      <div\n        role=\"toolbar\"\n        aria-label={t('formatting')}\n        className=\"flex flex-wrap items-center gap-0.5 border-b p-1\"\n      >\n        {tools.map((tool) => (\n          <button\n            key={tool.command}\n            type=\"button\"\n            title={tool.keys ? `${t(tool.label)} (${tool.keys})` : t(tool.label)}\n            aria-label={t(tool.label)}\n            aria-pressed={!!active[tool.command]}\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={() => run(tool.command)}\n            className={cn(\n              'flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5',\n              active[tool.command] && 'bg-muted text-foreground',\n            )}\n          >\n            {tool.icon}\n          </button>\n        ))}\n        <span aria-hidden className=\"mx-1 h-4 w-px bg-border\" />\n        <button\n          type=\"button\"\n          title={t('link')}\n          aria-label={t('link')}\n          onMouseDown={(e) => e.preventDefault()}\n          onClick={startLink}\n          className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5\"\n        >\n          <Link2 />\n        </button>\n        <button\n          type=\"button\"\n          title={t('clearFormatting')}\n          aria-label={t('clearFormatting')}\n          onMouseDown={(e) => e.preventDefault()}\n          onClick={() => run('removeFormat')}\n          className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5\"\n        >\n          <RemoveFormatting />\n        </button>\n        {linking && (\n          <div className=\"flex min-w-0 flex-1 items-center gap-1\">\n            <input\n              autoFocus\n              value={url}\n              aria-label={t('linkAddress')}\n              onChange={(e) => setUrl(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  e.preventDefault()\n                  applyLink()\n                }\n                if (e.key === 'Escape') {\n                  e.stopPropagation()\n                  setLinking(false)\n                }\n              }}\n              className=\"h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring\"\n            />\n            <button\n              type=\"button\"\n              onClick={applyLink}\n              className=\"h-7 rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground\"\n            >\n              {t('add')}\n            </button>\n          </div>\n        )}\n      </div>\n      <div className=\"relative\">\n        {empty && (\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute top-2 left-2.5 text-sm text-muted-foreground\"\n          >\n            {placeholder ?? t('writeSomething')}\n          </span>\n        )}\n        <div\n          ref={editor}\n          id={id}\n          role=\"textbox\"\n          aria-multiline\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          contentEditable\n          suppressContentEditableWarning\n          onInput={emit}\n          onPaste={(e) => {\n            e.preventDefault()\n            document.execCommand('insertText', false, e.clipboardData.getData('text/plain'))\n          }}\n          className={cn('min-h-24 px-2.5 py-2 text-sm leading-relaxed outline-none', prose)}\n        />\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/rich-text.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/rows.tsx",
      "content": "'use client'\n\nimport { Fragment, useEffect, useRef, useState } from 'react'\nimport { ChevronRight, Copy, GripVertical, Plus, Trash2 } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\nimport { parseDate } from '@/registry/dashboard/components/date-picker/date-picker'\nimport { toInputValue } from '@/registry/dashboard/components/date-range-picker/dates'\nimport { Field } from '@/registry/dashboard/components/input/input'\nimport { toast } from '@/registry/dashboard/components/toast/toast'\n\nimport { Check } from './controls'\nimport { useFormConfig } from './context'\nimport type { FieldControlProps } from './field-control'\nimport {\n  applyAction,\n  cloneItem,\n  ItemMenu,\n  move,\n  newItem,\n  UndoBar,\n  useUndo,\n  type ItemAction,\n} from './items'\nimport {\n  isReadOnly,\n  isRequired,\n  isVisible,\n  normalizeOptions,\n  scope,\n  type FieldDef,\n  type FieldValue,\n  type Row,\n} from './schema'\n\nexport type NestedProps = FieldControlProps & {\n  Control: React.ComponentType<FieldControlProps>\n}\n\nconst cellWidth: Record<string, string> = {\n  date: 'min-w-40',\n  datetime: 'min-w-52',\n  daterange: 'min-w-56',\n  time: 'min-w-32',\n  month: 'min-w-36',\n  select: 'min-w-32',\n  multiselect: 'min-w-40',\n  lookup: 'min-w-44',\n  phone: 'min-w-48',\n  number: 'min-w-20',\n  currency: 'min-w-28',\n  percent: 'min-w-24',\n  duration: 'min-w-28',\n  toggle: 'min-w-14',\n}\n\nconst PASTEABLE = new Set([\n  'text',\n  'email',\n  'url',\n  'phone',\n  'textarea',\n  'number',\n  'currency',\n  'percent',\n  'select',\n  'radio',\n  'date',\n  'toggle',\n])\n\nfunction fromPaste(col: FieldDef, raw: string): FieldValue | undefined {\n  const t = raw.trim()\n  switch (col.type) {\n    case 'number':\n    case 'currency':\n    case 'percent':\n      return t.replace(/[^\\d.-]/g, '')\n    case 'toggle':\n      return /^(y|yes|true|1|x|✓|✔)$/i.test(t)\n    case 'select':\n    case 'radio': {\n      const match = normalizeOptions(col.options).find(\n        (o) =>\n          o.value.toLowerCase() === t.toLowerCase() || o.label.toLowerCase() === t.toLowerCase(),\n      )\n      return match ? match.value : undefined\n    }\n    case 'date': {\n      const d = parseDate(t)\n      return d ? toInputValue(d) : undefined\n    }\n    default:\n      return t\n  }\n}\n\nexport const hasErrorUnder = (errors: Record<string, string>, prefix: string) =>\n  Object.keys(errors).some((k) => errors[k] && k.startsWith(`${prefix}.`))\n\nexport function RowsField({ Control, ...props }: NestedProps) {\n  const {\n    id,\n    field,\n    value,\n    values,\n    disabled,\n    describedBy,\n    onChange,\n    currency = '$',\n    errors = {},\n    fieldTypes,\n    path = field.name,\n  } = props\n  const { t, roles } = useFormConfig()\n  const columns = field.columns ?? []\n  const details = field.details ?? []\n  const all = [...columns, ...details]\n  const rows = Array.isArray(value) ? (value as Row[]) : []\n  const canAdd = !disabled && (field.maxLength === undefined || rows.length < field.maxLength)\n\n  const [selected, setSelected] = useState<string[]>([])\n  const [open, setOpen] = useState<string[]>([])\n  const [compact, setCompact] = useState(false)\n  const [width, setWidth] = useState(0)\n  const [drag, setDrag] = useState<{ from: number; over: number } | null>(null)\n  const [armed, setArmed] = useState<string | null>(null)\n  const [note, setNote] = useState('')\n  const wrap = useRef<HTMLDivElement>(null)\n  const pendingFocus = useRef<number | null>(null)\n  const noteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const { undo, offer, dismiss } = useUndo()\n\n  useEffect(() => {\n    const el = wrap.current\n    if (!el) return\n    const observer = new ResizeObserver(([entry]) => {\n      setCompact(entry.contentRect.width < 560)\n      setWidth(entry.contentRect.width)\n    })\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n\n  useEffect(() => {\n    const index = pendingFocus.current\n    if (index === null) return\n    pendingFocus.current = null\n    wrap.current\n      ?.querySelector<HTMLElement>(\n        `[data-row=\"${index}\"] [data-cell] :is(input:not([type=file]), textarea, button, [role=combobox])`,\n      )\n      ?.focus()\n  }, [value])\n\n  const ctxOf = (row: Row) => scope(values, row)\n  const cellValue = (row: Row, col: FieldDef) =>\n    col.compute ? col.compute(ctxOf(row)) : row[col.name]\n  const blank = () => newItem(all)\n  const setCell = (i: number, name: string, v: FieldValue) =>\n    onChange(rows.map((r, j) => (j === i ? { ...r, [name]: v } : r)))\n\n  const flash = (text: string) => {\n    if (toast.isMounted()) return void toast.success(text)\n    setNote(text)\n    if (noteTimer.current) clearTimeout(noteTimer.current)\n    noteTimer.current = setTimeout(() => setNote(''), 4000)\n  }\n\n  const remove = (ids: string[]) => {\n    const before = rows\n    onChange(rows.filter((r) => !ids.includes(r.id)))\n    setSelected((s) => s.filter((x) => !ids.includes(x)))\n    offer(\n      ids.length === 1\n        ? t('rowDeleted', { n: before.findIndex((r) => r.id === ids[0]) + 1 })\n        : t('rowsDeleted', { count: ids.length }),\n      () => onChange(before),\n    )\n  }\n\n  const act = (i: number, action: ItemAction) => {\n    if (action === 'delete') return remove([rows[i].id])\n    const result = applyAction(rows, i, action, blank)\n    pendingFocus.current = result.focus\n    onChange(result.list)\n  }\n\n  const add = () => {\n    pendingFocus.current = rows.length\n    onChange([...rows, blank()])\n  }\n\n  const duplicateSelected = () => {\n    const next: Row[] = []\n    for (const r of rows) {\n      next.push(r)\n      if (selected.includes(r.id)) next.push(cloneItem(r))\n    }\n    onChange(field.maxLength !== undefined ? next.slice(0, field.maxLength) : next)\n    setSelected([])\n  }\n\n  const paste = (e: React.ClipboardEvent) => {\n    if (disabled) return\n    const cell = (e.target as HTMLElement).closest<HTMLElement>('[data-cell]')\n    if (!cell || cell.closest('[data-nested]') !== e.currentTarget.closest('[data-nested]')) return\n    const text = e.clipboardData.getData('text/plain')\n    if (!/[\\t\\n]/.test(text.trim())) return\n    e.preventDefault()\n    const grid = text\n      .replace(/\\r/g, '')\n      .replace(/\\n+$/, '')\n      .split('\\n')\n      .map((line) => line.split('\\t'))\n    const r0 = Number(cell.dataset.row)\n    const c0 = Number(cell.dataset.col)\n    const next = [...rows]\n    let added = 0\n    let filled = 0\n    grid.forEach((cells, dr) => {\n      const ri = r0 + dr\n      if (ri >= next.length) {\n        if (field.maxLength !== undefined && next.length >= field.maxLength) return\n        next.push(blank())\n        added++\n      }\n      const row = { ...next[ri] }\n      cells.forEach((raw, dc) => {\n        const col = columns[c0 + dc]\n        if (!col || col.compute || col.readOnly || !PASTEABLE.has(col.type)) return\n        const v = fromPaste(col, raw)\n        if (v !== undefined) {\n          row[col.name] = v\n          filled++\n        }\n      })\n      next[ri] = row\n    })\n    onChange(next)\n    flash(\n      `${t('pasted', { count: filled })}${added ? ` · ${t('rowsAdded', { count: added })}` : ''}`,\n    )\n  }\n\n  const rowKeys = (i: number) => (e: React.KeyboardEvent) => {\n    const target = e.target as HTMLElement\n    if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {\n      e.preventDefault()\n      e.stopPropagation()\n      act(i, e.key === 'ArrowUp' ? 'up' : 'down')\n    } else if (\n      e.key === 'Enter' &&\n      !e.shiftKey &&\n      target.tagName === 'INPUT' &&\n      target.id.startsWith('field-') &&\n      i === rows.length - 1 &&\n      canAdd\n    ) {\n      e.preventDefault()\n      e.stopPropagation()\n      add()\n    }\n  }\n\n  const control = (row: Row, i: number, col: FieldDef, j: number, label: boolean) => {\n    const ctx = ctxOf(row)\n    const key = `${path}.${i}.${col.name}`\n    const error = errors[key]\n    if (!isVisible(col, ctx, roles))\n      return label ? null : <span className=\"px-2 text-muted-foreground\">—</span>\n    const node = (\n      <Control\n        id={`field-${key}`}\n        path={key}\n        field={label ? col : { ...col, label: `${col.label}, row ${i + 1}` }}\n        value={cellValue(row, col)}\n        values={ctx}\n        invalid={!!error}\n        disabled={!!disabled || isReadOnly(col, ctx, roles)}\n        describedBy={error ? `field-${key}-error` : undefined}\n        currency={currency}\n        errors={errors}\n        fieldTypes={fieldTypes}\n        onChange={(v) => setCell(i, col.name, v)}\n      />\n    )\n    if (label)\n      return (\n        <div\n          key={col.name}\n          data-cell\n          data-row={i}\n          data-col={j}\n          className={cn(\n            'min-w-0',\n            (col.type === 'rows' ||\n              col.type === 'group' ||\n              col.type === 'textarea' ||\n              col.type === 'richtext') &&\n              'col-span-full',\n          )}\n        >\n          <Field\n            label={col.label}\n            htmlFor={`field-${key}`}\n            hint={col.hint ?? (col.compute ? t('calculated') : undefined)}\n            error={error}\n            required={!disabled && isRequired(col, ctx)}\n          >\n            {node}\n          </Field>\n        </div>\n      )\n    return (\n      <>\n        {node}\n        {error && (\n          <span id={`field-${key}-error`} className=\"mt-1 block text-xs text-destructive\">\n            {error}\n          </span>\n        )}\n      </>\n    )\n  }\n\n  const totals =\n    field.totals?.length && rows.length\n      ? field.totals.map((name) => {\n          const col = columns.find((c) => c.name === name)\n          const sum = rows.reduce((s, r) => s + (Number(col ? cellValue(r, col) : r[name]) || 0), 0)\n          const text = new Intl.NumberFormat('en-US', {\n            maximumFractionDigits: col?.decimals ?? 2,\n          }).format(sum)\n          return {\n            name,\n            label: col?.label ?? name,\n            text: `${col?.type === 'currency' ? currency : ''}${text}${col?.type === 'percent' ? '%' : ''}`,\n          }\n        })\n      : null\n\n  const allSelected = rows.length > 0 && selected.length === rows.length\n  const toggleAll = () => setSelected(allSelected ? [] : rows.map((r) => r.id))\n  const toggleOne = (rowId: string) =>\n    setSelected((s) => (s.includes(rowId) ? s.filter((x) => x !== rowId) : [...s, rowId]))\n  const detailHasError = (i: number) =>\n    details.some((d) =>\n      Object.keys(errors).some(\n        (k) =>\n          errors[k] && (k === `${path}.${i}.${d.name}` || k.startsWith(`${path}.${i}.${d.name}.`)),\n      ),\n    )\n  const isOpen = (row: Row, i: number) => open.includes(row.id) || detailHasError(i)\n  const toggleOpen = (rowId: string) =>\n    setOpen((o) => (o.includes(rowId) ? o.filter((x) => x !== rowId) : [...o, rowId]))\n\n  const checkbox = (on: boolean, label: string, onToggle: () => void) => (\n    <button\n      type=\"button\"\n      role=\"checkbox\"\n      aria-checked={on}\n      aria-label={label}\n      onClick={onToggle}\n      className=\"flex size-6 items-center justify-center rounded\"\n    >\n      <Check on={on} />\n    </button>\n  )\n\n  const span = (disabled ? 0 : 2) + 1 + (details.length ? 1 : 0) + columns.length\n  const leads = { check: 0, number: disabled ? 0 : 2, details: (disabled ? 0 : 2) + 2.25 }\n  const firstLeft = leads.details + (details.length ? 1.75 : 0)\n  const pinned = (part: 'head' | 'body' | 'foot', left: number, edge = false) => ({\n    style: { left: `${left}rem` },\n    className: cn(\n      'sticky z-10',\n      part === 'head' && 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]',\n      part === 'foot' && 'bg-[color-mix(in_oklab,var(--muted)_30%,var(--card))]',\n      part === 'body' && 'bg-card group-data-[selected=true]:bg-muted',\n      edge && 'shadow-[1px_0_0_var(--border)]',\n    ),\n  })\n\n  return (\n    <div\n      ref={wrap}\n      id={id}\n      data-nested\n      aria-describedby={describedBy}\n      className=\"flex min-w-0 flex-col gap-2\"\n      onPaste={paste}\n    >\n      {selected.length > 0 && (\n        <div className=\"flex items-center gap-2 rounded-lg bg-muted px-2 py-1.5 text-sm\">\n          <span className=\"px-1 font-medium tabular-nums\">\n            {t('selected', { count: selected.length })}\n          </span>\n          {canAdd && (\n            <button\n              type=\"button\"\n              onClick={duplicateSelected}\n              className=\"flex h-7 items-center gap-1.5 rounded-md px-2 hover:bg-card [&_svg]:size-3.5\"\n            >\n              <Copy /> {t('duplicate')}\n            </button>\n          )}\n          <button\n            type=\"button\"\n            onClick={() => remove(selected)}\n            className=\"flex h-7 items-center gap-1.5 rounded-md px-2 text-destructive hover:bg-card [&_svg]:size-3.5\"\n          >\n            <Trash2 /> {t('delete')}\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => setSelected([])}\n            className=\"ml-auto h-7 rounded-md px-2 text-muted-foreground hover:bg-card hover:text-foreground\"\n          >\n            {t('clear')}\n          </button>\n        </div>\n      )}\n\n      {compact ? (\n        <ul className=\"flex flex-col gap-2\">\n          {rows.map((row, i) => (\n            <li\n              key={row.id}\n              data-row={i}\n              onKeyDown={rowKeys(i)}\n              className={cn(\n                'rounded-lg border bg-card p-3',\n                selected.includes(row.id) && 'border-ring',\n              )}\n            >\n              <div className=\"mb-3 flex items-center gap-2\">\n                {!disabled &&\n                  checkbox(selected.includes(row.id), t('selectRow', { n: i + 1 }), () =>\n                    toggleOne(row.id),\n                  )}\n                <span className=\"flex-1 text-sm font-medium\">{t('rowLabel', { n: i + 1 })}</span>\n                {!disabled && (\n                  <ItemMenu\n                    label={t('rowLabel', { n: i + 1 })}\n                    index={i}\n                    count={rows.length}\n                    canAdd={canAdd}\n                    onAction={(a) => act(i, a)}\n                  />\n                )}\n              </div>\n              <div className=\"grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3\">\n                {all.map((col) => control(row, i, col, columns.indexOf(col), true))}\n              </div>\n            </li>\n          ))}\n          {!rows.length && (\n            <li className=\"rounded-lg border border-dashed px-3 py-5 text-center text-sm text-muted-foreground\">\n              {t('noRows')}\n            </li>\n          )}\n          {totals && (\n            <li className=\"flex flex-wrap justify-end gap-x-4 gap-y-1 px-1 text-sm\">\n              {totals.map((t) => (\n                <span key={t.name}>\n                  <span className=\"text-muted-foreground\">Total {t.label.toLowerCase()}: </span>\n                  <span className=\"font-medium tabular-nums\">{t.text}</span>\n                </span>\n              ))}\n            </li>\n          )}\n        </ul>\n      ) : (\n        <div className=\"overflow-x-auto rounded-lg border\">\n          <table className=\"w-full border-collapse text-sm\">\n            <thead>\n              <tr className=\"border-b bg-muted/50 text-left text-xs text-muted-foreground\">\n                {!disabled && (\n                  <th\n                    style={pinned('head', leads.check).style}\n                    className={cn(pinned('head', leads.check).className, 'w-8 min-w-8 pl-1.5')}\n                  >\n                    {checkbox(allSelected, t('selectAll'), toggleAll)}\n                  </th>\n                )}\n                <th\n                  style={pinned('head', leads.number).style}\n                  className={cn(\n                    pinned('head', leads.number).className,\n                    'w-9 min-w-9 px-1 text-center font-medium',\n                  )}\n                >\n                  #\n                </th>\n                {details.length > 0 && (\n                  <th\n                    aria-label=\"Details\"\n                    style={pinned('head', leads.details).style}\n                    className={cn(pinned('head', leads.details).className, 'w-7 min-w-7')}\n                  />\n                )}\n                {columns.map((c, j) => (\n                  <th\n                    key={c.name}\n                    scope=\"col\"\n                    style={j === 0 ? pinned('head', firstLeft).style : undefined}\n                    className={cn(\n                      'px-2 py-1.5 font-medium whitespace-nowrap',\n                      j === 0 && pinned('head', firstLeft, true).className,\n                    )}\n                  >\n                    {c.label}\n                    {c.required && <span aria-hidden> *</span>}\n                  </th>\n                ))}\n                {!disabled && <th className=\"w-9\" aria-label=\"Actions\" />}\n              </tr>\n            </thead>\n            <tbody>\n              {rows.map((row, i) => {\n                const expanded = details.length > 0 && isOpen(row, i)\n                const detailError = detailHasError(i)\n                const dropping = drag && drag.over === i && drag.from !== i\n                return (\n                  <Fragment key={row.id}>\n                    <tr\n                      data-row={i}\n                      draggable={armed === row.id}\n                      onKeyDown={rowKeys(i)}\n                      onDragStart={(e) => {\n                        e.dataTransfer.effectAllowed = 'move'\n                        e.dataTransfer.setData('text/plain', row.id)\n                        setDrag({ from: i, over: i })\n                      }}\n                      onDragOver={(e) => {\n                        if (!drag) return\n                        e.preventDefault()\n                        if (drag.over !== i) setDrag({ ...drag, over: i })\n                      }}\n                      onDrop={(e) => {\n                        e.preventDefault()\n                        if (drag) onChange(move(rows, drag.from, drag.over))\n                        setDrag(null)\n                        setArmed(null)\n                      }}\n                      onDragEnd={() => {\n                        setDrag(null)\n                        setArmed(null)\n                      }}\n                      data-selected={selected.includes(row.id) || undefined}\n                      className={cn(\n                        'group border-b last:border-0',\n                        selected.includes(row.id) && 'bg-muted',\n                        drag?.from === i && 'opacity-50',\n                        dropping &&\n                          (drag!.over < drag!.from\n                            ? 'shadow-[inset_0_2px_0_var(--primary)]'\n                            : 'shadow-[inset_0_-2px_0_var(--primary)]'),\n                      )}\n                    >\n                      {!disabled && (\n                        <td\n                          style={pinned('body', leads.check).style}\n                          className={cn(\n                            pinned('body', leads.check).className,\n                            'w-8 min-w-8 pl-1.5 align-top',\n                          )}\n                        >\n                          <div className=\"flex h-11 items-center\">\n                            {checkbox(selected.includes(row.id), t('selectRow', { n: i + 1 }), () =>\n                              toggleOne(row.id),\n                            )}\n                          </div>\n                        </td>\n                      )}\n                      <td\n                        style={pinned('body', leads.number).style}\n                        className={cn(\n                          pinned('body', leads.number).className,\n                          'w-9 min-w-9 px-1 align-top',\n                        )}\n                      >\n                        <div className=\"flex h-11 items-center justify-center text-xs text-muted-foreground tabular-nums\">\n                          {!disabled ? (\n                            <>\n                              <span className=\"group-focus-within:hidden group-hover:hidden\">\n                                {i + 1}\n                              </span>\n                              <button\n                                type=\"button\"\n                                aria-label={t('dragRow', { n: i + 1 })}\n                                title={t('dragRow', { n: i + 1 })}\n                                onPointerDown={() => setArmed(row.id)}\n                                onPointerUp={() => !drag && setArmed(null)}\n                                className=\"hidden cursor-grab rounded p-0.5 group-focus-within:flex group-hover:flex hover:bg-muted active:cursor-grabbing [&_svg]:size-3.5\"\n                              >\n                                <GripVertical />\n                              </button>\n                            </>\n                          ) : (\n                            i + 1\n                          )}\n                        </div>\n                      </td>\n                      {details.length > 0 && (\n                        <td\n                          style={pinned('body', leads.details).style}\n                          className={cn(\n                            pinned('body', leads.details).className,\n                            'w-7 min-w-7 align-top',\n                          )}\n                        >\n                          <div className=\"flex h-11 items-center\">\n                            <button\n                              type=\"button\"\n                              aria-expanded={expanded}\n                              aria-label={t(expanded ? 'hideDetails' : 'showDetails', { n: i + 1 })}\n                              onClick={() => toggleOpen(row.id)}\n                              className=\"relative flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5\"\n                            >\n                              <ChevronRight\n                                className={cn('transition-transform', expanded && 'rotate-90')}\n                              />\n                              {detailError && (\n                                <span\n                                  aria-hidden\n                                  className=\"absolute top-0.5 right-0.5 size-1.5 rounded-full bg-destructive\"\n                                />\n                              )}\n                            </button>\n                          </div>\n                        </td>\n                      )}\n                      {columns.map((col, j) => (\n                        <td\n                          key={col.name}\n                          data-cell\n                          data-row={i}\n                          data-col={j}\n                          style={j === 0 ? pinned('body', firstLeft).style : undefined}\n                          className={cn(\n                            'px-1.5 py-1.5 align-top',\n                            cellWidth[col.type] ?? 'min-w-36',\n                            j === 0 && pinned('body', firstLeft, true).className,\n                          )}\n                        >\n                          {control(row, i, col, j, false)}\n                        </td>\n                      ))}\n                      {!disabled && (\n                        <td className=\"px-1 align-top\">\n                          <div className=\"flex h-11 items-center\">\n                            <ItemMenu\n                              label={t('rowLabel', { n: i + 1 })}\n                              index={i}\n                              count={rows.length}\n                              canAdd={canAdd}\n                              onAction={(a) => act(i, a)}\n                            />\n                          </div>\n                        </td>\n                      )}\n                    </tr>\n                    {expanded && (\n                      <tr data-row={i} className=\"border-b bg-muted/20\" onKeyDown={rowKeys(i)}>\n                        <td colSpan={span} className=\"p-0\">\n                          <div\n                            className=\"sticky left-0 grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-3 px-3 py-3\"\n                            style={width ? { width: width - 2 } : undefined}\n                          >\n                            {details.map((d) => control(row, i, d, -1, true))}\n                          </div>\n                        </td>\n                      </tr>\n                    )}\n                  </Fragment>\n                )\n              })}\n              {!rows.length && (\n                <tr>\n                  <td\n                    colSpan={span}\n                    className=\"px-3 py-6 text-center text-sm text-muted-foreground\"\n                  >\n                    {canAdd ? t('noRowsHint') : t('noRows')}\n                  </td>\n                </tr>\n              )}\n            </tbody>\n            {totals && (\n              <tfoot>\n                <tr className=\"border-t bg-muted/30 font-medium\">\n                  {!disabled && <td {...pinned('foot', leads.check)} />}\n                  <td\n                    style={pinned('foot', leads.number).style}\n                    className={cn(\n                      pinned('foot', leads.number).className,\n                      'px-1 py-1.5 text-center text-xs text-muted-foreground',\n                    )}\n                  >\n                    Σ\n                  </td>\n                  {details.length > 0 && <td {...pinned('foot', leads.details)} />}\n                  {columns.map((c, j) => (\n                    <td\n                      key={c.name}\n                      style={j === 0 ? pinned('foot', firstLeft).style : undefined}\n                      className={cn(\n                        'px-3 py-1.5 tabular-nums',\n                        j === 0 && pinned('foot', firstLeft, true).className,\n                      )}\n                    >\n                      {totals.find((t) => t.name === c.name)?.text ?? ''}\n                    </td>\n                  ))}\n                  {!disabled && <td />}\n                </tr>\n              </tfoot>\n            )}\n          </table>\n        </div>\n      )}\n\n      {undo && (\n        <UndoBar\n          message={undo.message}\n          onUndo={() => {\n            undo.restore()\n            dismiss()\n          }}\n          onDismiss={dismiss}\n        />\n      )}\n\n      {!disabled && (\n        <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n          {canAdd && (\n            <button\n              type=\"button\"\n              onClick={add}\n              className=\"flex h-7 items-center gap-1 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5\"\n            >\n              <Plus /> {field.addLabel ?? t('addRow')}\n            </button>\n          )}\n          <span aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n            {note || (rows.length > 0 && !compact ? t('pasteTip') : '')}\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/rows.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/schema.ts",
      "content": "import {\n  formatDate,\n  formatDateRange,\n  formatDateTime,\n  formatMonth,\n  formatTime,\n  thisMonthIso,\n  todayIso,\n} from './dates'\nimport { defaultT, type T } from './i18n'\n\nexport type BuiltInFieldType =\n  | 'text'\n  | 'textarea'\n  | 'email'\n  | 'phone'\n  | 'url'\n  | 'number'\n  | 'currency'\n  | 'date'\n  | 'time'\n  | 'month'\n  | 'select'\n  | 'multiselect'\n  | 'toggle'\n  | 'radio'\n  | 'list'\n  | 'file'\n  | 'rows'\n  | 'lookup'\n  | 'address'\n  | 'daterange'\n  | 'datetime'\n  | 'percent'\n  | 'duration'\n  | 'richtext'\n  | 'signature'\n  | 'group'\n\nexport type FieldType = BuiltInFieldType | (string & {})\n\nexport type OptionObject = {\n  value: string\n  label: string\n  description?: string\n  avatar?: string\n  color?: string\n}\nexport type Option = string | OptionObject\n\nexport type FileItem = {\n  id: string\n  name: string\n  size: number\n  type: string\n  url?: string\n  status?: 'uploading' | 'done' | 'error'\n  progress?: number\n  error?: string\n}\nexport type Row = { id: string; [column: string]: FieldValue | undefined }\n\nexport type FieldValue = string | number | boolean | null | string[] | FileItem[] | Row[]\nexport type FormValues = Record<string, FieldValue | undefined>\nexport type FormErrors = Record<string, string>\n\nexport type Condition =\n  | {\n      field: string\n      is?: FieldValue | FieldValue[]\n      not?: FieldValue | FieldValue[]\n      filled?: boolean\n    }\n  | { any: Condition[] }\n  | { all: Condition[] }\n\nexport type Rule =\n  | 'required'\n  | 'type'\n  | 'min'\n  | 'max'\n  | 'minLength'\n  | 'maxLength'\n  | 'pattern'\n  | 'maxSize'\n  | 'accept'\n  | 'sum'\n\nexport type FieldDef = {\n  name: string\n  label: string\n  type: FieldType\n  required?: boolean\n  requiredWhen?: Condition | Condition[]\n  visibleWhen?: Condition | Condition[]\n  readOnly?: boolean\n  readOnlyWhen?: Condition | Condition[]\n  compute?: (values: FormValues) => FieldValue\n  options?: Option[]\n  display?: 'segmented' | 'cards' | 'checkboxes'\n  creatable?: boolean\n  placeholder?: string\n  hint?: string\n  span?: 1 | 2 | 3 | 4 | 'full'\n  min?: number | string\n  max?: number | string\n  step?: number\n  minLength?: number\n  maxLength?: number\n  pattern?: string\n  accept?: string\n  maxSize?: number\n  multiple?: boolean\n  columns?: FieldDef[]\n  details?: FieldDef[]\n  fields?: FieldDef[]\n  itemLabel?: string\n  titleField?: string\n  groupColumns?: 1 | 2 | 3\n  totals?: string[]\n  sum?: { column: string; equals?: number; min?: number; max?: number }\n  addLabel?: string\n  messages?: Partial<Record<Rule, string>>\n  search?: (query: string) => Promise<OptionObject[]>\n  decimals?: number\n  defaultCountry?: string\n  itemLabelPlural?: string\n  permissions?: Permissions\n  props?: Record<string, unknown>\n}\n\nexport type Permissions = { view?: string[]; edit?: string[] }\n\nexport type Context = { t?: T; locale?: string; roles?: string[] }\n\nexport type FormSection = {\n  id: string\n  title: string\n  description?: string\n  icon?: React.ReactNode\n  columns?: 1 | 2 | 3 | 4\n  span?: 1 | 'full'\n  defaultOpen?: boolean\n  visibleWhen?: Condition | Condition[]\n  permissions?: Permissions\n  fields: FieldDef[]\n}\n\nexport type CustomFieldType = {\n  validate?: (\n    value: FieldValue | undefined,\n    field: FieldDef,\n    values: FormValues,\n  ) => string | undefined\n  isEmpty?: (value: FieldValue | undefined) => boolean\n  format?: (value: FieldValue | undefined, field: FieldDef) => string\n}\n\nexport type FieldTypeMap = Record<string, CustomFieldType>\n\nexport type Change = { id: string; label: string; from: string; to: string }\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst URL_PATTERN = /^(https?:\\/\\/)?[\\w-]+(\\.[\\w-]+)+(\\/\\S*)?$/\n\nexport function normalizeOptions(options: Option[] = []): OptionObject[] {\n  return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))\n}\n\nconst known = new Map<string, OptionObject>()\n\nexport function rememberOptions(options: OptionObject[]) {\n  for (const o of options) known.set(o.value, o)\n}\n\nexport function findOption(field: Pick<FieldDef, 'options'>, value: string) {\n  return normalizeOptions(field.options).find((o) => o.value === value) ?? known.get(value)\n}\n\nexport function optionLabel(field: Pick<FieldDef, 'options'>, value: string) {\n  return findOption(field, value)?.label ?? value\n}\n\nexport function plainText(html: string) {\n  return html\n    .replace(/<(br|\\/p|\\/li|\\/div)[^>]*>/gi, '\\n')\n    .replace(/<[^>]+>/g, '')\n    .replace(/&nbsp;/g, ' ')\n    .replace(/&amp;/g, '&')\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>')\n    .replace(/\\n{2,}/g, '\\n')\n    .trim()\n}\n\nexport function parseDuration(text: string): number | null {\n  const t = text.trim().toLowerCase().replace(/\\s+/g, '')\n  if (!t) return null\n  if (/^\\d+(\\.\\d+)?$/.test(t)) return Math.round(Number(t))\n  const clock = /^(\\d+):([0-5]\\d)$/.exec(t)\n  if (clock) return Number(clock[1]) * 60 + Number(clock[2])\n  const parts = /^(?:(\\d+(?:\\.\\d+)?)d)?(?:(\\d+(?:\\.\\d+)?)h)?(?:(\\d+)m(?:in)?)?$/.exec(t)\n  if (!parts || !(parts[1] || parts[2] || parts[3])) return null\n  return Math.round(\n    Number(parts[1] ?? 0) * 1440 + Number(parts[2] ?? 0) * 60 + Number(parts[3] ?? 0),\n  )\n}\n\nexport function showDuration(minutes: number) {\n  if (!minutes) return '0m'\n  const d = Math.floor(minutes / 1440)\n  const h = Math.floor((minutes % 1440) / 60)\n  const m = minutes % 60\n  return [d && `${d}d`, h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ')\n}\n\nexport function phoneDigits(value: string) {\n  return value.replace(/\\D/g, '')\n}\n\nexport function formatNumber(value: number, decimals?: number, locale?: string) {\n  return new Intl.NumberFormat(locale ?? 'en-US', {\n    minimumFractionDigits: decimals ?? 0,\n    maximumFractionDigits: decimals ?? 20,\n  }).format(value)\n}\n\nconst isRichEmpty = (value: FieldValue | undefined) =>\n  typeof value === 'string' && !plainText(value) && !/<img/i.test(value)\n\nexport function isEmptyValue(value: FieldValue | undefined) {\n  return (\n    value === undefined ||\n    value === null ||\n    value === '' ||\n    (Array.isArray(value) && value.length === 0)\n  )\n}\n\nconst matches = (value: FieldValue | undefined, expected: FieldValue | FieldValue[]) => {\n  const wanted = Array.isArray(expected) ? expected : [expected]\n  if (Array.isArray(value)) return value.some((v) => wanted.includes(v as never))\n  return wanted.some((w) => w === value || (w !== null && String(w) === String(value ?? '')))\n}\n\nexport function evaluate(\n  condition: Condition | Condition[] | undefined,\n  values: FormValues,\n): boolean {\n  if (!condition) return true\n  if (Array.isArray(condition)) return condition.every((c) => evaluate(c, values))\n  if ('any' in condition) return condition.any.some((c) => evaluate(c, values))\n  if ('all' in condition) return condition.all.every((c) => evaluate(c, values))\n  const value = values[condition.field]\n  if (condition.filled !== undefined && isEmptyValue(value) === condition.filled) return false\n  if (condition.is !== undefined && !matches(value, condition.is)) return false\n  if (condition.not !== undefined && matches(value, condition.not)) return false\n  return true\n}\n\nconst allowed = (list: string[] | undefined, roles: string[] | undefined) =>\n  !list || list.length === 0 || (!!roles && roles.some((r) => list.includes(r)))\n\nexport const canView = (item: { permissions?: Permissions }, roles?: string[]) =>\n  allowed(item.permissions?.view, roles)\n\nexport const canEdit = (item: { permissions?: Permissions }, roles?: string[]) =>\n  allowed(item.permissions?.edit, roles)\n\nexport const isVisible = (\n  item: { visibleWhen?: FieldDef['visibleWhen']; permissions?: Permissions },\n  values: FormValues,\n  roles?: string[],\n) => canView(item, roles) && evaluate(item.visibleWhen, values)\n\nexport const isRequired = (field: FieldDef, values: FormValues) =>\n  !!field.required || (field.requiredWhen !== undefined && evaluate(field.requiredWhen, values))\n\nexport const isReadOnly = (field: FieldDef, values: FormValues, roles?: string[]) =>\n  !!field.readOnly ||\n  !!field.compute ||\n  !canEdit(field, roles) ||\n  (field.readOnlyWhen !== undefined && evaluate(field.readOnlyWhen, values))\n\nexport function visibleFields(sections: FormSection[], values: FormValues, roles?: string[]) {\n  return sections\n    .filter((s) => isVisible(s, values, roles))\n    .flatMap((s) =>\n      canEdit(s, roles) ? s.fields : s.fields.map((f) => ({ ...f, readOnly: true })),\n    )\n    .filter((f) => isVisible(f, values, roles))\n}\n\nexport function editableFields(sections: FormSection[], values: FormValues, roles?: string[]) {\n  return visibleFields(sections, values, roles).filter(\n    (f) => !isReadOnly(f, values, roles) || f.compute,\n  )\n}\n\nexport const itemFields = (field: FieldDef) =>\n  field.type === 'group'\n    ? (field.fields ?? [])\n    : [...(field.columns ?? []), ...(field.details ?? [])]\n\nexport const isNested = (field: FieldDef) => field.type === 'rows' || field.type === 'group'\n\nexport const scope = (values: FormValues, item: Row): FormValues => ({ ...values, ...item })\n\nexport function computeItems(field: FieldDef, items: Row[], values: FormValues): Row[] {\n  return items.map((item) => {\n    let next = item\n    for (const sub of itemFields(field)) {\n      if (sub.compute) {\n        const v = sub.compute(scope(values, next))\n        if (JSON.stringify(v) !== JSON.stringify(next[sub.name])) next = { ...next, [sub.name]: v }\n      }\n      const nested = next[sub.name]\n      if (isNested(sub) && Array.isArray(nested)) {\n        const inner = computeItems(sub, nested as Row[], scope(values, next))\n        if (JSON.stringify(inner) !== JSON.stringify(nested)) next = { ...next, [sub.name]: inner }\n      }\n    }\n    return next\n  })\n}\n\nexport function applyComputed(sections: FormSection[], values: FormValues): FormValues {\n  let next = values\n  for (const field of sections.flatMap((s) => s.fields)) {\n    if (field.compute) {\n      const value = field.compute(next)\n      if (JSON.stringify(value) !== JSON.stringify(next[field.name]))\n        next = { ...next, [field.name]: value }\n    }\n    const items = next[field.name]\n    if (isNested(field) && Array.isArray(items)) {\n      const computed = computeItems(field, items as Row[], next)\n      if (JSON.stringify(computed) !== JSON.stringify(items))\n        next = { ...next, [field.name]: computed }\n    }\n  }\n  return next\n}\n\nconst limitOf = (limit: number | string, type: FieldType) =>\n  limit === 'today' ? (type === 'month' ? thisMonthIso() : todayIso()) : String(limit)\n\nexport function showDate(iso: string, locale?: string) {\n  return formatDate(iso, locale)\n}\nexport function showMonth(iso: string, locale?: string) {\n  return formatMonth(iso, locale)\n}\nexport function showTime(hhmm: string, locale?: string) {\n  return formatTime(hhmm, locale)\n}\nexport function showDateTime(value: string, locale?: string) {\n  return formatDateTime(value, locale)\n}\nexport function showSize(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`\n  return `${(bytes / 1024 / 1024).toFixed(1)} MB`\n}\n\nconst showLimit = (limit: string, type: FieldType, locale?: string) =>\n  type === 'date'\n    ? showDate(limit, locale)\n    : type === 'month'\n      ? showMonth(limit, locale)\n      : type === 'time'\n        ? showTime(limit, locale)\n        : limit\n\nfunction acceptsFile(accept: string, file: FileItem) {\n  return accept\n    .split(',')\n    .map((a) => a.trim().toLowerCase())\n    .some((a) =>\n      a.startsWith('.')\n        ? file.name.toLowerCase().endsWith(a)\n        : a.endsWith('/*')\n          ? file.type.startsWith(a.slice(0, -1))\n          : file.type === a,\n    )\n}\n\nfunction nounFor(field: FieldDef, count: number, t: T) {\n  if (field.type === 'group') {\n    const one = (field.itemLabel ?? t('noun.item_one')).toLowerCase()\n    return count === 1 ? one : (field.itemLabelPlural ?? `${one}s`).toLowerCase()\n  }\n  const key =\n    field.type === 'rows'\n      ? 'noun.row'\n      : field.type === 'file'\n        ? 'noun.file'\n        : field.type === 'list'\n          ? 'noun.item'\n          : 'noun.option'\n  return t(key, { count })\n}\n\nexport function validateField(\n  field: FieldDef,\n  value: FieldValue | undefined,\n  values: FormValues = {},\n  fieldTypes: FieldTypeMap = {},\n  context: Context = {},\n): string | undefined {\n  const t = context.t ?? defaultT\n  const { locale } = context\n  const say = (rule: Rule, key: string, vars: Record<string, unknown> = {}) => {\n    const all = { label: field.label, ...vars }\n    const template = field.messages?.[rule]\n    if (!template) return t(key, all)\n    return template.replace(/\\{(\\w+)\\}/g, (_, k: string) =>\n      String(all[k as keyof typeof all] ?? field[k as keyof FieldDef] ?? ''),\n    )\n  }\n  const count = (rule: Rule, key: 'minCount' | 'maxCount', n: number) =>\n    say(rule, key, { count: n, noun: nounFor(field, n, t), min: n, max: n })\n\n  const custom = fieldTypes[field.type]\n  const empty = custom?.isEmpty\n    ? custom.isEmpty(value)\n    : isEmptyValue(value) ||\n      (field.type === 'richtext' && isRichEmpty(value)) ||\n      (field.type === 'daterange' && value === '/')\n\n  if (empty && (field.type === 'rows' || field.type === 'group') && (field.minLength ?? 0) > 0)\n    return count('minLength', 'minCount', field.minLength!)\n  if (empty) return isRequired(field, values) ? say('required', 'required') : undefined\n  if (custom?.validate) {\n    const error = custom.validate(value, field, values)\n    if (error) return error\n  }\n\n  const { min, max, minLength, maxLength, type } = field\n\n  if (type === 'file' && Array.isArray(value)) {\n    const files = value as FileItem[]\n    if (files.some((f) => f.status === 'uploading')) return t('uploading')\n    if (files.some((f) => f.status === 'error')) return t('uploadFailed')\n    if (maxLength !== undefined && files.length > maxLength)\n      return count('maxLength', 'maxCount', maxLength)\n    const big = field.maxSize ? files.find((f) => f.size > field.maxSize!) : undefined\n    if (big)\n      return say('maxSize', 'maxSize', {\n        name: big.name,\n        max: showSize(field.maxSize!),\n        maxSize: showSize(field.maxSize!),\n      })\n    const wrong = field.accept ? files.find((f) => !acceptsFile(field.accept!, f)) : undefined\n    if (wrong) return say('accept', 'accept', { name: wrong.name })\n    return undefined\n  }\n\n  if ((type === 'rows' || type === 'group') && Array.isArray(value)) {\n    const rows = value as Row[]\n    if (minLength !== undefined && rows.length < minLength)\n      return count('minLength', 'minCount', minLength)\n    if (maxLength !== undefined && rows.length > maxLength)\n      return count('maxLength', 'maxCount', maxLength)\n    if (field.sum) {\n      const { column, equals, min: lo, max: hi } = field.sum\n      const total = Math.round(rows.reduce((s, r) => s + (Number(r[column]) || 0), 0) * 100) / 100\n      const label = field.columns?.find((c) => c.name === column)?.label ?? column\n      if (equals !== undefined && total !== equals)\n        return say('sum', 'sumEquals', { label, equals, total })\n      if (lo !== undefined && total < lo) return say('sum', 'sumMin', { label, min: lo, total })\n      if (hi !== undefined && total > hi) return say('sum', 'sumMax', { label, max: hi, total })\n    }\n    return undefined\n  }\n\n  if (Array.isArray(value)) {\n    if (minLength !== undefined && value.length < minLength)\n      return count('minLength', 'minCount', minLength)\n    if (maxLength !== undefined && value.length > maxLength)\n      return count('maxLength', 'maxCount', maxLength)\n    return undefined\n  }\n\n  if (type === 'duration') {\n    const n = Number(value)\n    if (Number.isNaN(n)) return say('type', 'duration')\n    if (min !== undefined && n < Number(min))\n      return say('min', 'minNumber', { min: showDuration(Number(min)) })\n    if (max !== undefined && n > Number(max))\n      return say('max', 'maxNumber', { max: showDuration(Number(max)) })\n    return undefined\n  }\n\n  if (type === 'number' || type === 'currency' || type === 'percent' || typeof value === 'number') {\n    const n = Number(value)\n    if (Number.isNaN(n)) return say('type', 'number')\n    if (min !== undefined && n < Number(min))\n      return say('min', 'minNumber', { min: formatNumber(Number(min), undefined, locale) })\n    if (max !== undefined && n > Number(max))\n      return say('max', 'maxNumber', { max: formatNumber(Number(max), undefined, locale) })\n    return undefined\n  }\n\n  if (typeof value !== 'string') return undefined\n\n  if (type === 'phone') {\n    const digits = phoneDigits(value)\n    return digits.length < 6 || digits.length > 15 ? say('type', 'phone') : undefined\n  }\n\n  if (type === 'richtext') {\n    const length = plainText(value).length\n    if (maxLength !== undefined && length > maxLength)\n      return say('maxLength', 'maxChars', { count: maxLength, max: maxLength })\n    if (minLength !== undefined && length < minLength)\n      return say('minLength', 'minChars', { count: minLength, min: minLength })\n    return undefined\n  }\n\n  if (type === 'daterange') {\n    const [start, end] = value.split('/')\n    if (!start || !end) return say('type', 'rangeIncomplete')\n    if (end < start) return say('type', 'rangeOrder')\n    if (min !== undefined && start < limitOf(min, 'date'))\n      return say('min', 'rangeMin', { min: showDate(limitOf(min, 'date'), locale) })\n    if (max !== undefined && end > limitOf(max, 'date'))\n      return say('max', 'rangeMax', { max: showDate(limitOf(max, 'date'), locale) })\n    return undefined\n  }\n\n  if (type === 'datetime') {\n    if (!/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/.test(value)) return say('type', 'datetime')\n    if (min !== undefined && value < String(min))\n      return say('min', 'minTime', { min: showDateTime(String(min), locale) })\n    if (max !== undefined && value > String(max))\n      return say('max', 'maxTime', { max: showDateTime(String(max), locale) })\n    return undefined\n  }\n\n  if (type === 'email' && !EMAIL.test(value)) return say('type', 'email')\n  if (type === 'url' && !URL_PATTERN.test(value)) return say('type', 'url')\n\n  if (type === 'date' || type === 'time' || type === 'month') {\n    const [minKey, maxKey] = type === 'time' ? ['minTime', 'maxTime'] : ['minDate', 'maxDate']\n    if (min !== undefined && value < limitOf(min, type))\n      return say('min', minKey, { min: showLimit(limitOf(min, type), type, locale) })\n    if (max !== undefined && value > limitOf(max, type))\n      return say('max', maxKey, { max: showLimit(limitOf(max, type), type, locale) })\n    return undefined\n  }\n\n  if (minLength !== undefined && value.length < minLength)\n    return say('minLength', 'minChars', { count: minLength, min: minLength })\n  if (maxLength !== undefined && value.length > maxLength)\n    return say('maxLength', 'maxChars', { count: maxLength, max: maxLength })\n  if (field.pattern && !new RegExp(field.pattern).test(value)) return say('pattern', 'pattern')\n\n  return undefined\n}\n\nfunction validateItems(\n  field: FieldDef,\n  items: Row[],\n  values: FormValues,\n  path: string,\n  fieldTypes: FieldTypeMap,\n  context: Context,\n  errors: FormErrors,\n) {\n  items.forEach((item, i) => {\n    const ctx = scope(values, item)\n    for (const sub of itemFields(field)) {\n      if (!isVisible(sub, ctx, context.roles)) continue\n      const value = sub.compute ? sub.compute(ctx) : item[sub.name]\n      const key = `${path}.${i}.${sub.name}`\n      const error = validateField(sub, value, ctx, fieldTypes, context)\n      if (error) errors[key] = error\n      if (isNested(sub) && Array.isArray(value))\n        validateItems(sub, value as Row[], ctx, key, fieldTypes, context, errors)\n    }\n  })\n}\n\nexport function validateFields(\n  fields: FieldDef[],\n  values: FormValues,\n  fieldTypes: FieldTypeMap = {},\n  context: Context = {},\n): FormErrors {\n  const errors: FormErrors = {}\n  for (const field of fields) {\n    if (!isVisible(field, values, context.roles)) continue\n    const value = field.compute ? field.compute(values) : values[field.name]\n    const error = validateField(field, value, values, fieldTypes, context)\n    if (error) errors[field.name] = error\n    if (isNested(field) && Array.isArray(value))\n      validateItems(field, value as Row[], values, field.name, fieldTypes, context, errors)\n  }\n  return errors\n}\n\nexport function validateRecord(\n  sections: FormSection[],\n  values: FormValues,\n  fieldTypes: FieldTypeMap = {},\n  context: Context = {},\n) {\n  return validateFields(visibleFields(sections, values, context.roles), values, fieldTypes, context)\n}\n\nexport function formatValue(\n  field: FieldDef,\n  value: FieldValue | undefined,\n  fieldTypes: FieldTypeMap = {},\n  context: Context = {},\n): string {\n  const t = context.t ?? defaultT\n  const { locale } = context\n  const custom = fieldTypes[field.type]\n  if (custom?.format) return custom.format(value, field)\n  if (isEmptyValue(value) || (field.type === 'richtext' && isRichEmpty(value))) return t('empty')\n  if (typeof value === 'boolean') return value ? t('yes') : t('no')\n  if (field.type === 'file') return (value as FileItem[]).map((f) => f.name).join(', ')\n  if (field.type === 'rows') return t('rows', { count: (value as Row[]).length })\n  if (field.type === 'group') {\n    const n = (value as Row[]).length\n    return `${n} ${nounFor(field, n, t)}`\n  }\n  if (Array.isArray(value)) return (value as string[]).map((v) => optionLabel(field, v)).join(', ')\n  const text = String(value)\n  if (field.type === 'date') return showDate(text, locale)\n  if (field.type === 'datetime') return showDateTime(text, locale)\n  if (field.type === 'daterange') return formatDateRange(text, locale) || t('empty')\n  if (field.type === 'lookup') return optionLabel(field, text)\n  if (field.type === 'richtext') return plainText(text)\n  if (field.type === 'signature') return t('signed')\n  if (field.type === 'duration') return showDuration(Number(text))\n  if (field.type === 'percent') return `${formatNumber(Number(text), field.decimals, locale)}%`\n  if ((field.type === 'number' || field.type === 'currency') && !Number.isNaN(Number(text)))\n    return formatNumber(Number(text), field.decimals, locale)\n  if (field.type === 'month') return showMonth(text, locale)\n  if (field.type === 'time') return showTime(text, locale)\n  if (field.type === 'select' || field.type === 'radio') return optionLabel(field, text)\n  return text\n}\n\nexport function diffValues(\n  sections: FormSection[],\n  before: FormValues,\n  after: FormValues,\n  fieldTypes: FieldTypeMap = {},\n  context: Context = {},\n): Change[] {\n  const changes: Change[] = []\n  for (const field of visibleFields(sections, after, context.roles)) {\n    const a = formatValue(field, before[field.name], fieldTypes, context)\n    const b = formatValue(field, after[field.name], fieldTypes, context)\n    if (a !== b) changes.push({ id: field.name, label: field.label, from: a, to: b })\n    else if (JSON.stringify(before[field.name]) !== JSON.stringify(after[field.name]))\n      changes.push({ id: field.name, label: field.label, from: a, to: `${b} *` })\n  }\n  return changes\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/schema.ts"
    },
    {
      "path": "registry/dashboard/components/record-form/signature.tsx",
      "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { Maximize2, Undo2 } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Dialog } from '@/registry/dashboard/components/dialog/dialog'\n\nimport { useFormConfig } from './context'\n\ntype Point = { x: number; y: number }\ntype Stroke = Point[]\ntype Mode = 'draw' | 'type'\n\nconst W = 1000\nconst H = 250\nconst INK = '#111111'\nconst FONT = 'italic 96px \"Brush Script MT\", \"Segoe Script\", \"Snell Roundhand\", cursive'\n\nfunction paint(\n  canvas: HTMLCanvasElement,\n  strokes: Stroke[],\n  typed: string,\n  mode: Mode,\n  base: HTMLImageElement | null,\n) {\n  const ctx = canvas.getContext('2d')\n  if (!ctx) return\n  const scale = canvas.width / W\n  ctx.setTransform(1, 0, 0, 1, 0, 0)\n  ctx.clearRect(0, 0, canvas.width, canvas.height)\n  ctx.setTransform(scale, 0, 0, scale, 0, 0)\n  if (base) ctx.drawImage(base, 0, 0, W, H)\n  ctx.strokeStyle = INK\n  ctx.fillStyle = INK\n  if (mode === 'type') {\n    if (!typed.trim()) return\n    ctx.font = FONT\n    ctx.textBaseline = 'middle'\n    ctx.fillText(typed, 40, H / 2, W - 80)\n    return\n  }\n  ctx.lineCap = 'round'\n  ctx.lineJoin = 'round'\n  ctx.lineWidth = 5\n  for (const stroke of strokes) {\n    if (!stroke.length) continue\n    ctx.beginPath()\n    ctx.moveTo(stroke[0].x, stroke[0].y)\n    if (stroke.length === 1) ctx.lineTo(stroke[0].x + 0.1, stroke[0].y)\n    for (let i = 1; i < stroke.length - 1; i++) {\n      const mid = { x: (stroke[i].x + stroke[i + 1].x) / 2, y: (stroke[i].y + stroke[i + 1].y) / 2 }\n      ctx.quadraticCurveTo(stroke[i].x, stroke[i].y, mid.x, mid.y)\n    }\n    if (stroke.length > 1) ctx.lineTo(stroke.at(-1)!.x, stroke.at(-1)!.y)\n    ctx.stroke()\n  }\n}\n\nfunction exportPng(strokes: Stroke[], typed: string, mode: Mode, base: HTMLImageElement | null) {\n  const canvas = document.createElement('canvas')\n  canvas.width = W * 2\n  canvas.height = H * 2\n  paint(canvas, strokes, typed, mode, base)\n  return canvas.toDataURL('image/png')\n}\n\nfunction Pad({\n  strokes,\n  typed,\n  mode,\n  base,\n  label,\n  onStroke,\n  className,\n}: {\n  strokes: Stroke[]\n  typed: string\n  mode: Mode\n  base: HTMLImageElement | null\n  label: string\n  onStroke: (stroke: Stroke) => void\n  className?: string\n}) {\n  const canvas = useRef<HTMLCanvasElement>(null)\n  const live = useRef<Stroke | null>(null)\n  const latest = useRef({ strokes, typed, mode, base })\n\n  useEffect(() => {\n    latest.current = { strokes, typed, mode, base }\n    if (canvas.current) paint(canvas.current, strokes, typed, mode, base)\n  }, [strokes, typed, mode, base])\n\n  useEffect(() => {\n    const c = canvas.current\n    if (!c) return\n    const observer = new ResizeObserver(() => {\n      const ratio = window.devicePixelRatio || 1\n      c.width = Math.round(c.clientWidth * ratio)\n      c.height = Math.round(c.clientHeight * ratio)\n      const { strokes: s, typed: t, mode: m, base: b } = latest.current\n      paint(c, s, t, m, b)\n    })\n    observer.observe(c)\n    return () => observer.disconnect()\n  }, [])\n\n  const point = (e: React.PointerEvent<HTMLCanvasElement>): Point => {\n    const rect = e.currentTarget.getBoundingClientRect()\n    return {\n      x: ((e.clientX - rect.left) / rect.width) * W,\n      y: ((e.clientY - rect.top) / rect.height) * H,\n    }\n  }\n\n  const repaintLive = () => {\n    const c = canvas.current\n    if (!c || !live.current) return\n    paint(c, [...strokes, live.current], typed, mode, base)\n  }\n\n  return (\n    <canvas\n      ref={canvas}\n      role=\"img\"\n      aria-label={label}\n      className={cn(\n        'block aspect-[4/1] w-full touch-none',\n        mode === 'draw' && 'cursor-crosshair',\n        className,\n      )}\n      onPointerDown={(e) => {\n        if (mode !== 'draw') return\n        e.currentTarget.setPointerCapture(e.pointerId)\n        live.current = [point(e)]\n        repaintLive()\n      }}\n      onPointerMove={(e) => {\n        if (!live.current) return\n        const events = e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]\n        const rect = e.currentTarget.getBoundingClientRect()\n        for (const ev of events)\n          live.current.push({\n            x: ((ev.clientX - rect.left) / rect.width) * W,\n            y: ((ev.clientY - rect.top) / rect.height) * H,\n          })\n        repaintLive()\n      }}\n      onPointerUp={() => {\n        if (!live.current) return\n        const stroke = live.current\n        live.current = null\n        onStroke(stroke)\n      }}\n      onPointerCancel={() => {\n        live.current = null\n        repaintLive()\n      }}\n    />\n  )\n}\n\nfunction Paper({ children, invalid }: { children: React.ReactNode; invalid?: boolean }) {\n  return (\n    <div\n      className={cn(\n        'relative overflow-hidden rounded-lg border bg-white text-neutral-900',\n        invalid ? 'border-destructive' : 'border-input',\n      )}\n    >\n      {children}\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute right-[4%] bottom-[22%] left-[4%] border-b border-dashed border-neutral-300\"\n      />\n    </div>\n  )\n}\n\nfunction Controls({\n  mode,\n  typed,\n  canUndo,\n  canClear,\n  onMode,\n  onTyped,\n  onUndo,\n  onClear,\n  children,\n}: {\n  mode: Mode\n  typed: string\n  canUndo: boolean\n  canClear: boolean\n  onMode: (mode: Mode) => void\n  onTyped: (text: string) => void\n  onUndo: () => void\n  onClear: () => void\n  children?: React.ReactNode\n}) {\n  const { t } = useFormConfig()\n  return (\n    <div className=\"flex flex-wrap items-center gap-2\">\n      <div\n        role=\"radiogroup\"\n        aria-label={t('signatureMethod')}\n        className=\"flex rounded-md bg-muted p-0.5 text-xs\"\n      >\n        {(['draw', 'type'] as const).map((m) => (\n          <button\n            key={m}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={mode === m}\n            onClick={() => onMode(m)}\n            className={cn(\n              'h-6 rounded px-2 capitalize',\n              mode === m ? 'bg-card font-medium shadow-(--shadow-button)' : 'text-muted-foreground',\n            )}\n          >\n            {t(m)}\n          </button>\n        ))}\n      </div>\n      {mode === 'type' && (\n        <input\n          autoFocus\n          value={typed}\n          placeholder={t('typeName')}\n          aria-label={t('typeName')}\n          onChange={(e) => onTyped(e.target.value)}\n          className=\"h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:border-ring\"\n        />\n      )}\n      <div className=\"ml-auto flex items-center gap-1\">\n        {mode === 'draw' && (\n          <button\n            type=\"button\"\n            onClick={onUndo}\n            disabled={!canUndo}\n            className=\"flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 [&_svg]:size-3\"\n          >\n            <Undo2 /> {t('undo')}\n          </button>\n        )}\n        <button\n          type=\"button\"\n          onClick={onClear}\n          disabled={!canClear}\n          className=\"h-7 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40\"\n        >\n          {t('clear')}\n        </button>\n        {children}\n      </div>\n    </div>\n  )\n}\n\nexport function SignatureField({\n  id,\n  value,\n  disabled,\n  invalid,\n  describedBy,\n  onChange,\n}: {\n  id: string\n  value: string\n  disabled?: boolean\n  invalid?: boolean\n  describedBy?: string\n  onChange: (value: string) => void\n}) {\n  const { t } = useFormConfig()\n  const [strokes, setStrokes] = useState<Stroke[]>([])\n  const [typed, setTyped] = useState('')\n  const [mode, setMode] = useState<Mode>('draw')\n  const [base, setBase] = useState<HTMLImageElement | null>(null)\n  const [expanded, setExpanded] = useState(false)\n  const snapshot = useRef<{\n    strokes: Stroke[]\n    typed: string\n    mode: Mode\n    base: HTMLImageElement | null\n    value: string\n  } | null>(null)\n  const loaded = useRef(false)\n\n  useEffect(() => {\n    if (loaded.current || !value) return\n    loaded.current = true\n    const img = new Image()\n    img.onload = () => setBase(img)\n    img.src = value\n  }, [value])\n\n  const update = (next: {\n    strokes?: Stroke[]\n    typed?: string\n    mode?: Mode\n    base?: HTMLImageElement | null\n  }) => {\n    const s = next.strokes ?? strokes\n    const t = next.typed ?? typed\n    const m = next.mode ?? mode\n    const b = next.base === undefined ? base : next.base\n    if (next.strokes) setStrokes(s)\n    if (next.typed !== undefined) setTyped(t)\n    if (next.mode) setMode(m)\n    if (next.base !== undefined) setBase(b)\n    const empty = m === 'type' ? !t.trim() : !s.length && !b\n    onChange(empty ? '' : exportPng(s, t, m, b))\n  }\n\n  const clear = () => update({ strokes: [], typed: '', base: null })\n  const hasInk = mode === 'type' ? !!typed.trim() : strokes.length > 0 || !!base\n\n  const open = () => {\n    snapshot.current = { strokes, typed, mode, base, value }\n    setExpanded(true)\n  }\n  const cancel = () => {\n    const s = snapshot.current\n    if (s) {\n      setStrokes(s.strokes)\n      setTyped(s.typed)\n      setMode(s.mode)\n      setBase(s.base)\n      onChange(s.value)\n    }\n    setExpanded(false)\n  }\n\n  if (disabled) {\n    return value ? (\n      <div className=\"overflow-hidden rounded-lg border bg-white\">\n        <img\n          id={id}\n          src={value}\n          alt={t('sign')}\n          className=\"block aspect-[4/1] w-full object-contain\"\n        />\n      </div>\n    ) : (\n      <span id={id} className=\"text-sm text-muted-foreground\">\n        {t('notSigned')}\n      </span>\n    )\n  }\n\n  const controls = (\n    <Controls\n      mode={mode}\n      typed={typed}\n      canUndo={strokes.length > 0}\n      canClear={hasInk}\n      onMode={(m) => update({ mode: m, strokes: [], typed: '', base: null })}\n      onTyped={(t) => update({ typed: t })}\n      onUndo={() => update({ strokes: strokes.slice(0, -1) })}\n      onClear={clear}\n    />\n  )\n\n  return (\n    <div id={id} aria-describedby={describedBy} className=\"flex flex-col gap-1.5\">\n      <Paper invalid={invalid}>\n        <Pad\n          strokes={strokes}\n          typed={typed}\n          mode={mode}\n          base={base}\n          label={\n            mode === 'draw' ? 'Signature pad. Draw with a mouse, finger or pen.' : 'Typed signature'\n          }\n          onStroke={(s) => update({ strokes: [...strokes, s] })}\n        />\n        {!hasInk && mode === 'draw' && (\n          <span className=\"pointer-events-none absolute bottom-[6%] left-[4%] text-xs text-neutral-400\">\n            {t('signAbove')}\n          </span>\n        )}\n        <button\n          type=\"button\"\n          aria-label={t('expandSignature')}\n          title={t('expand')}\n          onClick={open}\n          className=\"absolute top-2 right-2 flex size-7 items-center justify-center rounded-md bg-white/90 text-neutral-500 shadow-sm ring-1 ring-neutral-200 hover:text-neutral-900 [&_svg]:size-3.5\"\n        >\n          <Maximize2 />\n        </button>\n      </Paper>\n      {controls}\n\n      <Dialog\n        open={expanded}\n        onOpenChange={(next) => !next && cancel()}\n        title={t('sign')}\n        size=\"full\"\n        closeLabel={t('closeWithoutSaving')}\n      >\n        <div className=\"flex flex-col gap-3\">\n          <Paper>\n            <Pad\n              strokes={strokes}\n              typed={typed}\n              mode={mode}\n              base={base}\n              label={t('signaturePad')}\n              onStroke={(s) => update({ strokes: [...strokes, s] })}\n            />\n            {!hasInk && mode === 'draw' && (\n              <span className=\"pointer-events-none absolute bottom-[6%] left-[4%] text-sm text-neutral-400\">\n                {t('signAbove')}\n              </span>\n            )}\n          </Paper>\n          <Controls\n            mode={mode}\n            typed={typed}\n            canUndo={strokes.length > 0}\n            canClear={hasInk}\n            onMode={(m) => update({ mode: m, strokes: [], typed: '', base: null })}\n            onTyped={(t) => update({ typed: t })}\n            onUndo={() => update({ strokes: strokes.slice(0, -1) })}\n            onClear={clear}\n          >\n            <button\n              type=\"button\"\n              data-autofocus\n              onClick={() => setExpanded(false)}\n              className=\"ml-1 h-8 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90\"\n            >\n              {t('done')}\n            </button>\n          </Controls>\n        </div>\n      </Dialog>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/signature.tsx"
    },
    {
      "path": "registry/dashboard/components/record-form/upload.ts",
      "content": "export type UploadContext = {\n  onProgress: (percent: number) => void\n  signal: AbortSignal\n}\n\nexport type UploadResult = { url: string; id?: string }\n\nexport type Uploader = (file: File, context: UploadContext) => Promise<UploadResult>\n\nexport function xhrUploader({\n  url,\n  field = 'file',\n  headers,\n  method = 'POST',\n  parse = (body) => body as UploadResult,\n}: {\n  url: string\n  field?: string\n  headers?: Record<string, string>\n  method?: 'POST' | 'PUT'\n  parse?: (body: unknown) => UploadResult\n}): Uploader {\n  return (file, { onProgress, signal }) =>\n    new Promise((resolve, reject) => {\n      const xhr = new XMLHttpRequest()\n      xhr.open(method, url)\n      for (const [k, v] of Object.entries(headers ?? {})) xhr.setRequestHeader(k, v)\n      xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100)\n      xhr.onload = () => {\n        if (xhr.status < 200 || xhr.status >= 300)\n          return reject(new Error(`Upload failed (${xhr.status})`))\n        try {\n          resolve(parse(xhr.responseText ? JSON.parse(xhr.responseText) : {}))\n        } catch (error) {\n          reject(error)\n        }\n      }\n      xhr.onerror = () => reject(new Error('Network error'))\n      xhr.onabort = () => reject(new DOMException('Upload cancelled', 'AbortError'))\n      signal.addEventListener('abort', () => xhr.abort())\n      const body = new FormData()\n      body.append(field, file)\n      xhr.send(method === 'PUT' ? file : body)\n    })\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/record-form/upload.ts"
    }
  ],
  "cssVars": {
    "light": {
      "shadow-button": "0 1px 2px rgb(0 0 0 / 0.05), 0 0 0 1px rgb(0 0 0 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.8)",
      "shadow-card": "0 1px 2px rgb(0 0 0 / 0.04), 0 0 0 1px rgb(0 0 0 / 0.07)",
      "shadow-overlay": "0 16px 40px rgb(0 0 0 / 0.12), 0 0 0 1px rgb(0 0 0 / 0.08)"
    },
    "dark": {
      "shadow-button": "0 0 0 1px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.06)",
      "shadow-card": "0 4px 4px rgb(42 42 42 / 0.32), 0 0 0 1px #0e0e0e, inset 0 1px 0 rgb(255 255 255 / 0.08), inset 0 0 0 1px rgb(255 255 255 / 0.08)",
      "shadow-overlay": "0 16px 40px rgb(0 0 0 / 0.5), 0 0 0 1px #0e0e0e"
    }
  },
  "meta": {
    "scenario": "dashboard",
    "kind": "components"
  },
  "docs": "const form = useForm<FormValues>({ defaultValues, resolver: recordFormResolver({ sections }) }) then <RecordForm form={form} sections={sections} />.\nZod, Valibot or ArkType: recordFormResolver({ sections, schema }). Validation timing is plain react-hook-form: useForm({ mode: \"onBlur\" }).\nSaving: <RecordForm onSubmit={save} /> for the steps layout, or form.handleSubmit(save) on your own button; call form.reset(values) after a save.\nChanges: const changes = useFormChanges(form, sections) then <ChangeList changes={changes} />.\nUploads: pass upload={xhrUploader({ url: \"/api/upload\" })} or your own (file, { onProgress, signal }) => Promise<{ url }>.\nRoles: permissions: { view, edit } on fields and sections, then roles={user.roles} on RecordForm and recordFormResolver.\nTranslations: messages={{ required: \"{label} est obligatoire\" }} or translate={(key, vars) => i18n.t(`form.${key}`, vars)}; import a dayjs locale and pass locale=\"fr\". Pass the same to recordFormResolver for error messages.\nDrafts and autosave: <RecordForm draftKey=\"order-1078\" autosave={{ save }} />.",
  "categories": [
    "dashboard",
    "forms",
    "crm"
  ],
  "type": "registry:component"
}