Files
2026-03-21 16:07:45 -06:00

33 KiB

Phase 12.1: Per-Field Text Editing and Quick-Fill — Research

Researched: 2026-03-21 Domain: React client state management, per-field inline editing, PDF text fill pipeline Confidence: HIGH

<phase_requirements>

Phase Requirements

ID Description Research Support
TXTF-01 Agent can click a placed text field box on the PDF to select it and type a value for that specific field — each text field holds its own independent value (keyed by field ID) FieldPlacer already has per-field IDs and click handling infrastructure; need to add selectedFieldId state + inline input overlay
TXTF-02 When a text field is selected, PreparePanel shows quick-fill suggestion buttons (Client Name, Property Address, Client Email) that insert the corresponding value into the selected field selectedFieldId must be shared between FieldPlacer and PreparePanel via DocumentPageClient; quick-fill buttons call a setValue(fieldId, value) callback
TXTF-03 Text fill values entered per-field appear correctly embedded in the preview PDF and the final prepared PDF; the staleness token resets on any text field value change textFillData must become a Record<fieldId, string> (keyed by field ID, not label); preparePdf must match by field ID; onPreviewTokenChange(null) called on every value change
</phase_requirements>

Summary

Phase 12.1 replaces the current broken positional text fill approach (TextFillForm with generic label/value rows) with a per-field model where each placed text type field box holds its own independent value keyed by its UUID field ID. The core UX change is: agent clicks a text field box on the PDF canvas to select it, sees an inline input overlay, and can type directly. Simultaneously, the PreparePanel sidebar shows quick-fill buttons (Client Name, Property Address, Client Email) that insert the relevant client profile value into the currently selected field.

This phase touches three layers. First, the UI layer: FieldPlacer gains selectedFieldId state and per-field value display/editing; DocumentPageClient gains shared state to bridge FieldPlacer (left column) and PreparePanel (right sidebar) — same pattern already established in Phase 12. Second, the data model: textFillData transitions from Record<label, value> (human-typed keys) to Record<fieldId, value> (UUID keys from SignatureFieldData.id). Third, the PDF layer: preparePdf in prepare-document.ts switches from positional sequential assignment to direct field ID lookup when drawing text onto placed text field boxes.

The existing infrastructure is almost entirely reusable. The onFieldsChanged callback chain through DocumentPageClient already handles previewToken invalidation. The quick-fill data sources (clientName, clientPropertyAddress, defaultEmail) are already passed as props to DocumentPageClient from the server page. No new dependencies are needed.

Primary recommendation: Key textFillData by SignatureFieldData.id (UUID), add selectedFieldId state to DocumentPageClient bridging FieldPlacer and PreparePanel, replace TextFillForm with per-field inline edit + quick-fill panel, and update preparePdf to look up values by field ID directly.


Standard Stack

Core (all already installed — no new dependencies)

Library Version Purpose Why Standard
React 19 19.2.4 State management, event handling Project standard
Next.js 16.2.0 App Router, server components Project standard
@dnd-kit/core 6.3.1 Drag-and-drop already used in FieldPlacer Project standard
@cantoo/pdf-lib 2.6.3 PDF text drawing in preparePdf Project standard
TypeScript 5.x Type safety for field ID keying Project standard

No New Dependencies Needed

This phase is pure UI and data model refactoring within the existing stack. No new npm packages required.

Installation:

# No new packages — all dependencies already present

Architecture Patterns

Current Architecture (What Exists)

page.tsx (server component)
└── DocumentPageClient.tsx (client — holds previewToken, handleFieldsChanged)
    ├── PdfViewerWrapper → PdfViewer → FieldPlacer
    │   └── TextFieldBox overlays (current: grab, resize, delete only)
    └── PreparePanel
        └── TextFillForm (current: generic label/value rows, no field linkage)

Current data flow:

  • textFillData: Record<string, string> — keys are human-typed labels (e.g. "PropertyAddress")
  • preparePdf assigns values positionally: sort text fields by page/y, assign textFillData entries in order
  • No linkage between a specific field box (by ID) and a specific value

Target Architecture (What Phase 12.1 Builds)

page.tsx (server component)
└── DocumentPageClient.tsx (client — holds previewToken, selectedFieldId, textFillData, handleFieldsChanged)
    ├── PdfViewerWrapper → PdfViewer → FieldPlacer
    │   ├── props: selectedFieldId, textFillData (per-field), onFieldSelect, onFieldValueChange
    │   └── TextFieldBox overlays: click to select, inline input when selected, show value label
    └── PreparePanel
        ├── props: selectedFieldId, onQuickFill (fieldId, value) => void
        └── QuickFillPanel: Client Name / Property Address / Client Email buttons
            (visible only when a text field is selected)

Target data flow:

  • textFillData: Record<fieldId, string> — keys are SignatureFieldData.id UUIDs
  • preparePdf looks up textFields[field.id] directly — no positional assignment needed
  • selectedFieldId shared between FieldPlacer and PreparePanel via DocumentPageClient
  • Quick-fill inserts textFillData[selectedFieldId] = clientValue
  • Every value change calls onPreviewTokenChange(null) to invalidate Send button

Pattern 1: State Lifting for Cross-Sibling Communication

What: DocumentPageClient already bridges previewToken between FieldPlacer (left) and PreparePanel (right). Phase 12.1 adds two more lifted state variables: selectedFieldId and textFillData.

When to use: When two sibling client components (rendered by a server component) need to share state — established in Phase 12, confirmed correct pattern for this codebase.

Example (extending DocumentPageClient):

// Source: DocumentPageClient.tsx (Phase 12 pattern, extending for 12.1)
'use client';
import { useState, useCallback } from 'react';

export function DocumentPageClient({ docId, clientName, clientEmail, clientPropertyAddress, ... }) {
  const [previewToken, setPreviewToken] = useState<string | null>(null);
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
  const [textFillData, setTextFillData] = useState<Record<string, string>>({});

  const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
    setTextFillData(prev => ({ ...prev, [fieldId]: value }));
    setPreviewToken(null); // TXTF-03: reset staleness on any change
  }, []);

  const handleQuickFill = useCallback((fieldId: string, value: string) => {
    setTextFillData(prev => ({ ...prev, [fieldId]: value }));
    setPreviewToken(null);
  }, []);

  // ...pass selectedFieldId, textFillData, onFieldSelect, onFieldValueChange to FieldPlacer chain
  // ...pass selectedFieldId, clientName, clientPropertyAddress, defaultEmail, onQuickFill to PreparePanel
}

Pattern 2: Inline Input Overlay on Field Box Click

What: When agent clicks a text field box in FieldPlacer, the box renders an <input> element instead of just a label. The input is positioned absolutely inside the field box div.

When to use: text-type fields only (getFieldType(field) === 'text'). Non-text fields (signature, checkbox, date, etc.) are unaffected.

Key implementation detail: The field box already uses onPointerDown for move initiation. A click (no movement) on a text field needs to select it WITHOUT triggering the move handler. The existing pattern uses data-no-move attribute and e.stopPropagation() for the delete button — the same mechanism works for an input element inside the field box.

// Source: FieldPlacer.tsx pattern — data-no-move stops move handler
{fieldType === 'text' && selectedFieldId === field.id ? (
  <input
    data-no-move
    autoFocus
    value={textFillData[field.id] ?? ''}
    onChange={(e) => onFieldValueChange(field.id, e.target.value)}
    onPointerDown={(e) => e.stopPropagation()}
    style={{ /* fill field box, no border styling, transparent bg */ }}
  />
) : (
  // Show the current value as a label, or the field type label if empty
  <span>{textFillData[field.id] || fieldLabel}</span>
)}

Field selection trigger: A simple click (no drag) on a text field box should set selectedFieldId. This requires distinguishing clicks from drags. The existing pointer handling already tracks drag start — if e.clientX/Y delta is minimal on pointerup (or zero), it was a click. Simplest approach: add an onClick handler to text field boxes that calls onFieldSelect(field.id).

Pattern 3: Quick-Fill Panel in PreparePanel

What: When selectedFieldId is non-null (a text field is selected), PreparePanel shows 3 quick-fill buttons. Each button calls onQuickFill(selectedFieldId, value) to insert the client data value.

When to use: Only rendered when selectedFieldId is non-null AND a text field is selected.

// Source: PreparePanel.tsx (new quick-fill section)
{selectedFieldId && (
  <div className="space-y-2">
    <p className="text-xs text-gray-500">Quick-fill selected field:</p>
    <button onClick={() => onQuickFill(selectedFieldId, clientName)} ...>
      Client Name  {clientName}
    </button>
    <button onClick={() => onQuickFill(selectedFieldId, clientPropertyAddress ?? '')} ...>
      Property Address  {clientPropertyAddress ?? 'not set'}
    </button>
    <button onClick={() => onQuickFill(selectedFieldId, clientEmail)} ...>
      Client Email  {clientEmail}
    </button>
  </div>
)}

Data sources already available: clientName, clientPropertyAddress, and defaultEmail (client email) are all already passed to DocumentPageClient from the server page — no new DB queries needed.

Pattern 4: preparePdf — Field-ID-Keyed Lookup

What: Replace the current positional assignment (sort fields, assign entries in order) with direct field ID lookup.

Current approach (broken — positional):

// prepare-document.ts — current positional assignment
const textFields_sorted = sigFields.filter(f => getFieldType(f) === 'text')
  .sort(...);
textFields_sorted.forEach((field, idx) => {
  const entry = remainingEntries[idx]; // positional — fragile
  page.drawText(entry.value, { x: field.x + 4, y: field.y + 4, ... });
});

New approach (field-ID-keyed — TXTF-03):

// prepare-document.ts — Phase 12.1 target
for (const field of sigFields) {
  if (getFieldType(field) !== 'text') continue;
  const value = textFields[field.id]; // direct lookup by UUID
  if (!value) continue;
  const page = pages[field.page - 1];
  if (!page) continue;
  const fontSize = Math.max(6, Math.min(11, field.height - 4));
  page.drawText(value, { x: field.x + 4, y: field.y + 4, size: fontSize, font: helvetica, color: rgb(0.05, 0.05, 0.05) });
}

This is simpler than the current code AND more correct. The fieldConsumedKeys tracking set, remainingEntries array, and the positional sort can all be removed. Strategy B (top-of-page fallback stamp) also becomes unnecessary since values are always at known field positions.

Pattern 5: Prop Threading Through PdfViewer Chain

What: New props (selectedFieldId, textFillData, onFieldSelect, onFieldValueChange) must flow from DocumentPageClient down through PdfViewerWrapper → PdfViewer → FieldPlacer, following the established Phase 12 chain pattern.

Important: Keep PdfViewerWrapper and PdfViewer prop changes minimal. Only thread what FieldPlacer needs. Use optional props with defaults where possible to avoid breaking existing usage (e.g., PreviewModal uses PdfViewer in readOnly mode and should not need these new props).

Anti-Patterns to Avoid

  • Keeping TextFillForm: The generic label/value form (TextFillForm.tsx) is the UX antipattern being replaced. Remove it from PreparePanel. The new per-field UI in FieldPlacer + QuickFill panel in PreparePanel replaces it entirely.
  • Keying by label instead of field ID: Do not use human-typed labels as textFillData keys. Keys MUST be UUID field IDs to guarantee correct mapping.
  • Strategy B fallback stamp: Once textFillData is keyed by field ID, the Strategy B top-of-page fallback is no longer needed. Remove it to avoid stamping confusing UUID-keyed entries at the top of page 1.
  • AcroForm Strategy A: Keep Strategy A (AcroForm field filling by name) as-is — it is a separate code path that handles PDFs with embedded form fields and does not conflict with the new approach. However, textFillData entries with UUID keys will never match AcroForm field names, so Strategy A will be a no-op for Phase 12.1 data (which is correct).
  • Deselect on page change: If agent navigates to a different PDF page, selectedFieldId should be cleared (or at least not show the quick-fill panel). The field boxes are only shown for the current page — a selectedFieldId from a different page is invisible.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Cross-sibling state Custom event bus or Context provider Lift state to DocumentPageClient Already established pattern in Phase 12; DocumentPageClient is minimal and correct
Input focus management Custom focus trap Native autoFocus on the inline input Sufficient for this use case; no modal/focus trap needed
Inline input positioning Absolute-positioned floating popup Input inside the field box div The field box is already absolutely positioned; input inside it is naturally in the right place

Key insight: The Phase 12 state bridge pattern (DocumentPageClient as shared state holder) is already the right solution for cross-sibling communication in this Next.js App Router architecture. Don't reach for React Context or a global store.


Common Pitfalls

Pitfall 1: Move Handler Intercepts Click on Text Field

What goes wrong: When agent tries to click a text field box to select it, the handleMoveStart fires on onPointerDown and treats the click as a drag initiation. The field box moves instead of being selected.

Why it happens: FieldPlacer uses onPointerDown to start move tracking on every field box. The data-no-move attribute + e.stopPropagation() pattern is used for the delete button and resize handles, but not yet for text selection/input.

How to avoid: For text type fields, either (a) use onClick for selection (fires after pointerdown+pointerup without movement) and rely on the existing minimum-distance drag constraint (MouseSensor has { distance: 5 } activation), or (b) add data-no-move + stopPropagation() to the input element inside the field box. Option (a) is simpler: a click (delta < 5px) won't trigger move, so onClick on the field box for text type fields works cleanly.

Warning signs: Field box visually jumps when agent clicks it to select; no input appears.

Pitfall 2: textFillData Keyed by UUID Breaks Strategy B

What goes wrong: If textFillData is now { "550e8400-e29b-...": "John Smith" } and Strategy B is not removed, the top-of-page fallback stamps UUID keys as text on page 1 (visible in the PDF as noise).

Why it happens: Strategy B is designed for human-readable label/value pairs but doesn't know about field IDs.

How to avoid: Remove Strategy B entirely when textFillData transitions to field-ID-keyed format. All text values are now rendered at field box positions — there is no fallback needed.

Warning signs: Preview PDF shows UUID strings near the top of page 1.

Pitfall 3: PreparePanel Receives clientEmail as a New Prop

What goes wrong: PreparePanel currently receives defaultEmail (the recipient email textarea pre-fill) but not a separate clientEmail for quick-fill purposes. If these are the same value, reuse defaultEmail. However, if they are meant to be structurally separate, a new prop must be threaded.

Why it happens: The server page passes docClient?.email as defaultEmail. The same email is the appropriate quick-fill value for "Client Email". Reuse defaultEmail — no new prop needed.

How to avoid: In PreparePanel, use the existing defaultEmail prop for the "Client Email" quick-fill button. No new data sourcing required.

Warning signs: Quick-fill "Client Email" button shows blank or wrong value.

Pitfall 4: selectedFieldId Points to a Non-text Field

What goes wrong: If FieldPlacer calls onFieldSelect(field.id) for any field type (not just text), PreparePanel shows the quick-fill panel for signatures, checkboxes, etc.

Why it happens: The onFieldSelect handler doesn't discriminate by field type.

How to avoid: Only set selectedFieldId when the clicked/selected field is type text. In FieldPlacer: if (getFieldType(field) === 'text') onFieldSelect?.(field.id).

Warning signs: Quick-fill panel appears when clicking a blue "Signature" field.

Pitfall 5: textFillData Schema Mismatch with Existing DB Records

What goes wrong: Existing documents in the database have textFillData as Record<label, string> (e.g., { "propertyAddress": "123 Main St" }). Phase 12.1 changes the client-side data model to Record<fieldId, string>. If existing documents are loaded, the old key format might be used.

Why it happens: The documents.textFillData JSONB column stores whatever was last passed to /api/documents/[id]/prepare. Old records have label keys; new records will have UUID keys.

How to avoid: Phase 12.1 is a fresh UX for new document preparation sessions. On page load, textFillData state in DocumentPageClient should always start as {} (empty) — do NOT seed it from doc.textFillData from the DB. The DB value is only used at prepare time (already the case). The UI is always a fresh editing session.

Warning signs: Old label-keyed values pre-fill into the new per-field inputs incorrectly.

Pitfall 6: PdfViewer Used in ReadOnly Mode (PreviewModal) Gets New Required Props

What goes wrong: PreviewModal renders a PdfViewer in readOnly mode. If new props are added as required to PdfViewer/PdfViewerWrapper, the PreviewModal usage breaks TypeScript compilation.

Why it happens: Prop threading requires updates to PdfViewer and PdfViewerWrapper signatures.

How to avoid: Make all new props optional with ?: typing and safe defaults. ReadOnly PdfViewer passes nothing for text-edit props; FieldPlacer receives undefined and handles gracefully.

Warning signs: TypeScript error Property 'X' is missing in PreviewModal or PdfViewer.


Code Examples

Extending DocumentPageClient for Phase 12.1

// Source: DocumentPageClient.tsx — Phase 12 base extended for Phase 12.1
'use client';
import { useState, useCallback } from 'react';
import { PdfViewerWrapper } from './PdfViewerWrapper';
import { PreparePanel } from './PreparePanel';

interface DocumentPageClientProps {
  docId: string;
  docStatus: string;
  defaultEmail: string;
  clientName: string;
  agentDownloadUrl?: string | null;
  signedAt?: Date | null;
  clientPropertyAddress?: string | null;
}

export function DocumentPageClient({
  docId, docStatus, defaultEmail, clientName,
  agentDownloadUrl, signedAt, clientPropertyAddress,
}: DocumentPageClientProps) {
  const [previewToken, setPreviewToken] = useState<string | null>(null);
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
  const [textFillData, setTextFillData] = useState<Record<string, string>>({});

  const handleFieldsChanged = useCallback(() => {
    setPreviewToken(null);
  }, []);

  const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
    setTextFillData(prev => ({ ...prev, [fieldId]: value }));
    setPreviewToken(null);
  }, []);

  const handleQuickFill = useCallback((fieldId: string, value: string) => {
    setTextFillData(prev => ({ ...prev, [fieldId]: value }));
    setPreviewToken(null);
  }, []);

  return (
    <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
      <div className="lg:col-span-2">
        <PdfViewerWrapper
          docId={docId}
          docStatus={docStatus}
          onFieldsChanged={handleFieldsChanged}
          selectedFieldId={selectedFieldId}
          textFillData={textFillData}
          onFieldSelect={setSelectedFieldId}
          onFieldValueChange={handleFieldValueChange}
        />
      </div>
      <div className="lg:col-span-1 lg:sticky lg:top-6 lg:self-start ...">
        <PreparePanel
          docId={docId}
          defaultEmail={defaultEmail}
          clientName={clientName}
          currentStatus={docStatus}
          agentDownloadUrl={agentDownloadUrl}
          signedAt={signedAt}
          clientPropertyAddress={clientPropertyAddress}
          previewToken={previewToken}
          onPreviewTokenChange={setPreviewToken}
          textFillData={textFillData}
          selectedFieldId={selectedFieldId}
          onQuickFill={handleQuickFill}
        />
      </div>
    </div>
  );
}

FieldPlacer Text Field Box — Click-to-Select with Inline Input

// Source: FieldPlacer.tsx renderFields() — text field rendering

// Inside renderFields() per-field .map():
const fieldType = getFieldType(field);
const isSelected = selectedFieldId === field.id;
const currentValue = textFillData?.[field.id] ?? '';

// Text fields: click to select, show inline input when selected
if (fieldType === 'text') {
  return (
    <div
      key={field.id}
      data-field-id={field.id}
      style={{ /* existing positioning styles */ border: `2px solid ${fieldColor}`, ... }}
      onClick={(e) => {
        // onClick fires after pointerdown+pointerup with no movement (< 5px — MouseSensor threshold)
        // This selects the field; move handler does not fire for clicks within threshold
        onFieldSelect?.(field.id);
      }}
      onPointerDown={(e) => {
        if ((e.target as HTMLElement).closest('[data-no-move]')) return;
        handleMoveStart(e, field.id);
      }}
    >
      {isSelected ? (
        <input
          data-no-move
          autoFocus
          value={currentValue}
          onChange={(e) => onFieldValueChange?.(field.id, e.target.value)}
          onPointerDown={(e) => e.stopPropagation()}
          style={{
            flex: 1,
            background: 'transparent',
            border: 'none',
            outline: 'none',
            fontSize: '10px',
            color: fieldColor,
            width: '100%',
            cursor: 'text',
          }}
          placeholder="Type value..."
        />
      ) : (
        <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {currentValue || fieldLabel}
        </span>
      )}
      {/* delete button and resize handles unchanged */}
    </div>
  );
}
// Non-text fields: existing rendering unchanged

preparePdf — Field-ID-Keyed Text Drawing

// Source: prepare-document.ts — replace positional assignment with ID lookup

// BEFORE Phase 12.1 (remove this entire block):
// const remainingEntries = ...
// const textFields_sorted = sigFields.filter(f => getFieldType(f) === 'text').sort(...)
// const fieldConsumedKeys = new Set<string>()
// textFields_sorted.forEach((field, idx) => { ... })

// AFTER Phase 12.1 (simple direct lookup):
for (const field of sigFields) {
  if (getFieldType(field) !== 'text') continue;
  const value = textFields[field.id]; // textFields is now Record<fieldId, string>
  if (!value) continue;
  const page = pages[field.page - 1];
  if (!page) continue;
  const fontSize = Math.max(6, Math.min(11, field.height - 4));
  page.drawText(value, {
    x: field.x + 4,
    y: field.y + 4,
    size: fontSize,
    font: helvetica,
    color: rgb(0.05, 0.05, 0.05),
  });
}

// Also remove Strategy B (unstampedEntries block) — no longer needed with field-ID keying

PreparePanel Quick-Fill Panel

// Source: PreparePanel.tsx — replace TextFillForm with quick-fill panel

// Props additions:
interface PreparePanelProps {
  // ... existing props ...
  textFillData: Record<string, string>;
  selectedFieldId: string | null;
  onQuickFill: (fieldId: string, value: string) => void;
}

// Replace the TextFillForm section with:
{selectedFieldId ? (
  <div className="space-y-2">
    <p className="text-sm font-medium text-gray-700">Quick-fill selected field</p>
    <p className="text-xs text-gray-400">
      Click a suggestion to insert into the selected text field.
    </p>
    {clientName && (
      <button
        type="button"
        onClick={() => onQuickFill(selectedFieldId, clientName)}
        className="w-full text-left px-3 py-2 text-sm border rounded bg-white hover:bg-blue-50 hover:border-blue-300"
      >
        <span className="text-xs text-gray-400 block">Client Name</span>
        {clientName}
      </button>
    )}
    {clientPropertyAddress && (
      <button
        type="button"
        onClick={() => onQuickFill(selectedFieldId, clientPropertyAddress)}
        className="w-full text-left px-3 py-2 text-sm border rounded bg-white hover:bg-blue-50 hover:border-blue-300"
      >
        <span className="text-xs text-gray-400 block">Property Address</span>
        {clientPropertyAddress}
      </button>
    )}
    <button
      type="button"
      onClick={() => onQuickFill(selectedFieldId, defaultEmail)}
      className="w-full text-left px-3 py-2 text-sm border rounded bg-white hover:bg-blue-50 hover:border-blue-300"
    >
      <span className="text-xs text-gray-400 block">Client Email</span>
      {defaultEmail}
    </button>
  </div>
) : (
  <p className="text-sm text-gray-400 italic">
    Click a text field on the document to edit its value or use quick-fill.
  </p>
)}

handlePreview and handlePrepare Pass textFillData to API

// Source: PreparePanel.tsx — handlePreview and handlePrepare now use textFillData prop

async function handlePreview() {
  // textFillData is now Record<fieldId, string> — passed directly to preview API
  const res = await fetch(`/api/documents/${docId}/preview`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ textFillData }), // textFillData is now a prop, not local state
  });
  // ... rest unchanged
}

async function handlePrepare() {
  const res = await fetch(`/api/documents/${docId}/prepare`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ textFillData, emailAddresses }), // same
  });
  // ... rest unchanged
}

State of the Art

Old Approach Current Approach When Changed Impact
Generic label/value TextFillForm rows Per-field click-to-edit on placed field boxes Phase 12.1 Agent knows exactly which field gets which value
textFillData: Record<label, string> textFillData: Record<fieldId, string> Phase 12.1 One-to-one, guaranteed mapping; no positional fragility
Positional sequential assignment in preparePdf Direct field-ID lookup in preparePdf Phase 12.1 Simpler code, correct for any number/order of fields
Strategy B top-of-page fallback stamp Removed Phase 12.1 No UUID noise at top of page 1

Deprecated/outdated:

  • TextFillForm.tsx component: Replaced by per-field inline editing in FieldPlacer + quick-fill in PreparePanel. Can be deleted after Phase 12.1.
  • textFillData local state in PreparePanel: Moved to DocumentPageClient as shared state. PreparePanel receives it as a prop.
  • fieldConsumedKeys / remainingEntries / textFields_sorted blocks in prepare-document.ts: Replaced by simple field-ID keyed loop.
  • Strategy B (unstampedEntries block) in prepare-document.ts: Removed — no longer needed.

Open Questions

  1. Deselect behavior when clicking outside a text field

    • What we know: There is no global click handler to deselect selectedFieldId.
    • What's unclear: Should clicking the PDF background, clicking a non-text field, or scrolling the page deselect? Clicking a non-text field probably should not show quick-fill buttons.
    • Recommendation: Add an onClick handler on the DroppableZone that calls onFieldSelect(null) when the click target is NOT a [data-field-id] element. This gives a natural "click off" deselect. Non-text field clicks should call onFieldSelect(null) as well.
  2. What happens to the existing clientPropertyAddress pre-seeding in PreparePanel

    • What we know: PreparePanel currently pre-seeds textFillData state with { propertyAddress: clientPropertyAddress }. This becomes moot when textFillData moves to DocumentPageClient and uses field IDs as keys.
    • What's unclear: Should DocumentPageClient pre-populate any field with the property address on load? There is no way to know which field ID the agent intends for property address until they place the field.
    • Recommendation: Do NOT pre-populate. The quick-fill panel makes it trivial to insert the value. Pre-populating with an unknown field ID would be incorrect. Remove the pre-seeding logic.
  3. Multiple text fields of the same visual "type"

    • What we know: A document may have 10 placed text field boxes for 10 different form fields (names, prices, dates-as-text, etc.). Each gets a unique UUID.
    • What's unclear: Agent needs to know which field is which — there are no labels on text field boxes beyond "Text".
    • Recommendation: Out of scope for Phase 12.1. The inline click-to-edit interaction provides context through position on the document. Labels/field naming is a future enhancement. Phase 13 AI pre-fill will address this more directly.

Files to Change

Based on the research, these are the exact files the planner should task:

File Change Type What Changes
DocumentPageClient.tsx Modify Add selectedFieldId, textFillData state; add handleFieldValueChange, handleQuickFill callbacks; thread new props to PdfViewerWrapper and PreparePanel
PdfViewerWrapper.tsx Modify Add selectedFieldId?, textFillData?, onFieldSelect?, onFieldValueChange? optional props; pass through to PdfViewer
PdfViewer.tsx Modify Same optional props as above; pass through to FieldPlacer
FieldPlacer.tsx Modify Add selectedFieldId?, textFillData?, onFieldSelect?, onFieldValueChange? optional props; change text field box rendering to click-to-select + inline input; call onFieldSelect(null) on DroppableZone background click
PreparePanel.tsx Modify Remove TextFillForm usage; add textFillData, selectedFieldId, onQuickFill props; add QuickFillPanel; textFillData is now a prop (not local state); handlePreview/handlePrepare use prop
TextFillForm.tsx Delete (or retain unused) No longer used in PreparePanel; can be deleted
prepare-document.ts Modify Replace positional text drawing with field-ID direct lookup; remove fieldConsumedKeys/remainingEntries/textFields_sorted; remove Strategy B; textFields param is now Record<fieldId, string>

Files NOT changing:

  • route.ts (prepare): Passes body.textFillData to preparePdf — already works with any Record<string, string>; no change needed
  • route.ts (preview): Same — no change needed
  • schema.ts: textFillData JSONB column type is already Record<string, string> — still correct
  • page.tsx: No change — DocumentPageClient props are unchanged at this level
  • Any signing/client routes: No change

Sources

Primary (HIGH confidence)

  • Direct code reading: FieldPlacer.tsx, PreparePanel.tsx, DocumentPageClient.tsx, TextFillForm.tsx, prepare-document.ts, schema.ts, prepare/route.ts, preview/route.ts — all read in full
  • 12-02-SUMMARY.md — Phase 12 decisions and patterns confirmed
  • STATE.md — Confirmed Phase 12.1 gap deferred from Phase 12
  • REQUIREMENTS.md — TXTF-01, TXTF-02, TXTF-03 requirements confirmed
  • package.json — Stack versions verified

Secondary (MEDIUM confidence)

  • Next.js 16 (App Router) patterns: verified against project's own AGENTS.md guidance and existing DocumentPageClient established pattern
  • @dnd-kit/core MouseSensor { distance: 5 } activation constraint (confirmed in FieldPlacer.tsx line 190) — ensures clicks do not trigger drag

Tertiary (LOW confidence)

  • None — all findings grounded in direct source code reading

Metadata

Confidence breakdown:

  • Standard stack: HIGH — no new dependencies; all from direct package.json read
  • Architecture: HIGH — all patterns derived from existing codebase; Phase 12 bridge pattern directly applicable
  • Pitfalls: HIGH — identified from direct analysis of current code paths; not hypothetical

Research date: 2026-03-21 Valid until: 2026-04-21 (stable codebase; only this phase changes these files)