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")preparePdfassigns values positionally: sort text fields by page/y, assigntextFillDataentries 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 areSignatureFieldData.idUUIDspreparePdflooks uptextFields[field.id]directly — no positional assignment neededselectedFieldIdshared 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.tsxcomponent: Replaced by per-field inline editing in FieldPlacer + quick-fill in PreparePanel. Can be deleted after Phase 12.1.textFillDatalocal state in PreparePanel: Moved to DocumentPageClient as shared state. PreparePanel receives it as a prop.fieldConsumedKeys/remainingEntries/textFields_sortedblocks inprepare-document.ts: Replaced by simple field-ID keyed loop.- Strategy B (
unstampedEntriesblock) inprepare-document.ts: Removed — no longer needed.
Open Questions
-
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
onClickhandler on the DroppableZone that callsonFieldSelect(null)when the click target is NOT a[data-field-id]element. This gives a natural "click off" deselect. Non-text field clicks should callonFieldSelect(null)as well.
- What we know: There is no global click handler to deselect
-
What happens to the existing clientPropertyAddress pre-seeding in PreparePanel
- What we know: PreparePanel currently pre-seeds
textFillDatastate 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.
- What we know: PreparePanel currently pre-seeds
-
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): Passesbody.textFillDatatopreparePdf— already works with anyRecord<string, string>; no change neededroute.ts(preview): Same — no change neededschema.ts:textFillDataJSONB column type is alreadyRecord<string, string>— still correctpage.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 confirmedSTATE.md— Confirmed Phase 12.1 gap deferred from Phase 12REQUIREMENTS.md— TXTF-01, TXTF-02, TXTF-03 requirements confirmedpackage.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/coreMouseSensor{ 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)