34 KiB
Phase 19: Template Editor UI — Research
Researched: 2026-04-06 Domain: Next.js App Router UI — component abstraction, React state, AI API route Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
FieldPlacer Abstraction
- D-01: Add
onPersist?: (fields: SignatureFieldData[]) => Promise<void> | voidprop to FieldPlacer. When provided, replaces the internalpersistFields(docId, fields)call. When absent (all existing consumers), falls back to existing internalpersistFieldsbehavior — fully backwards compatible. - D-02:
persistFieldsis called in 4 places in FieldPlacer. All 4 must be updated to callonPersist(fields)if provided, elsepersistFields(docId, fields). The internalpersistFieldsfunction stays for backwards compat. - D-03: FieldPlacer's
signersprop acceptsDocumentSigner[]whereemailcarries either a real email (document mode) OR a role label (template mode). No type change needed.
Role Labels in Template Editor
- D-04: Role labels are free-form strings. Preset list: "Buyer", "Co-Buyer", "Seller", "Co-Seller". Agent can type any string.
- D-05: Template editor starts with two default role labels pre-configured: "Buyer" (#6366f1 indigo) and "Seller" (#f43f5e rose). Agent can add, remove, or rename.
- D-06: Role colors:
['#6366f1', '#f43f5e', '#10b981', '#f59e0b']cycling by index. - D-07: Role labels stored in
documentTemplates.signatureFieldsviafield.signerEmail. Save callsPATCH /api/templates/[id]with fullsignatureFieldsarray. - D-08: "Active role" selector in template editor is identical in behavior to "Active signer" selector in document FieldPlacer — same component, different strings.
Template Editor Page
- D-09: Route:
/portal/templates/[id]— single page view + edit. No separate/editroute. - D-10: Templates list page at
/portal/templates— shows all active templates (name, form name, field count, last updated). Click row → open editor. - D-11: "Templates" appears in the portal top nav (alongside Dashboard, Clients, Profile).
- D-12: Template editor page layout: PDF viewer on the left (full FieldPlacer), slim right panel (TemplatePanel) with: template name (editable), role list (add/remove/rename), AI Auto-place button, Save button.
- D-13: TemplatePanel is a new component (separate from PreparePanel). PreparePanel is document-specific. TemplatePanel is template-specific (roles, save).
AI Auto-place for Templates
- D-14: New route
POST /api/templates/[id]/ai-prepare— readsformTemplate.filenameto locate PDF inseeds/forms/, callsextractBlanks(filePath)+classifyFieldsWithAI(blanks, null), writes result todocumentTemplates.signatureFieldsviaPATCH. - D-15: No client pre-fill in template AI mode —
classifyFieldsWithAIreceivesnullfor client context. - D-16: AI Auto-place button triggers
POST /api/templates/[id]/ai-prepare, then reloads FieldPlacer viaaiPlacementKeyincrement.
Navigation
- D-17: Portal nav: add "Templates" between "Clients" and "Profile".
- D-18: Templates list page is a server component that fetches from the database directly.
Claude's Discretion
- Exact TemplatePanel layout details (spacing, button order)
- Whether to show field count on the editor page header
- Loading states for AI auto-place and save operations
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| TMPL-05 | Agent can open a template in an editor and drag-drop fields onto the PDF (reuses existing FieldPlacer) | FieldPlacer onPersist abstraction + TemplatePageClient state owner + PdfViewerWrapper reuse |
| TMPL-06 | Agent can use AI auto-place to populate fields on a template | New POST /api/templates/[id]/ai-prepare route; copies pattern from /api/documents/[id]/ai-prepare |
| TMPL-07 | Template fields use signer role labels instead of specific email addresses | signerEmail field slot already accepts any string; no type change needed; no email validation at FieldPlacer level |
| TMPL-08 | Agent can set text hints on client-text fields in the template | Already implemented in FieldPlacer for client-text type via inline input; template mode inherits this |
| TMPL-09 | Agent can save the template — fields and role assignments are persisted | onPersist callback writes to PATCH /api/templates/[id]; Phase 18 PATCH route already accepts signatureFields |
| </phase_requirements> |
Summary
Phase 19 is a UI-construction phase with one new API route. The primary engineering challenge is the onPersist abstraction in FieldPlacer — 4 call sites need to switch from the internal persistFields(docId, fields) to onPersist(fields) when the prop is provided. Everything else is composition of existing patterns: TemplatePageClient mirrors DocumentPageClient, TemplatePanel mirrors PreparePanel in layout, and the new AI route mirrors /api/documents/[id]/ai-prepare with one simplification (no client context, PDF comes from seeds/forms/ instead of uploads/).
The second complexity is the role-label system. Because DocumentSigner.email is typed as string (not validated as an email address anywhere in FieldPlacer or its props chain), role labels like "Buyer" flow through without any changes to types or validation. The signers prop and the active-signer selector in FieldPlacer work purely by string matching — they display whatever is in signer.email. This means template mode requires zero type changes in FieldPlacer beyond the onPersist addition.
The PdfViewer/PdfViewerWrapper/FieldPlacer chain is built around a docId parameter used for two purposes: (1) fetching the PDF file at /api/documents/${docId}/file, and (2) fetching/saving fields at /api/documents/${docId}/fields. For template mode, neither of those endpoints applies. The template PDF comes from seeds/forms/ via the template's associated formTemplate.filename. This means TemplatePageClient must NOT pass the docId prop to PdfViewerWrapper/PdfViewer the same way — a new templateId + formFilename approach is needed, or a new PdfViewer variant for templates. This is the most significant design gap to resolve in planning.
Primary recommendation: Build a minimal TemplatePdfViewer (not a full rewrite — just a PdfViewer clone that accepts templateId and formFilename instead of docId, serving the PDF from /api/templates/[id]/file) to avoid mutating the existing viewer chain. Alternatively, add a fileUrl override prop to PdfViewerWrapper/PdfViewer so template mode provides the direct URL.
Standard Stack
Core (all pre-installed, no new dependencies)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| react-pdf / pdfjs-dist | installed | PDF rendering in browser | Already used throughout portal viewer |
| @dnd-kit/core | installed | Drag-and-drop field placement | FieldPlacer already uses this |
| drizzle-orm | installed | DB queries for templates list and editor load | All DB access uses Drizzle |
| next/navigation | built-in | useRouter for post-create redirect, usePathname for nav active state |
Existing PortalNav pattern |
No new packages needed
Phase 19 introduces zero new npm dependencies. All required libraries are already installed.
Installation: None required.
Architecture Patterns
Existing Patterns Being Reused
Pattern 1: Server Component Page → PageClient State Owner
page.tsx (async server component, fetches from DB)
→ PageClient.tsx (client component, owns all useState)
→ PdfViewerWrapper → PdfViewer → FieldPlacer (left column)
→ Panel component (right column)
Established in documents/[docId]/page.tsx → DocumentPageClient.tsx. Template editor follows exactly this structure.
Pattern 2: aiPlacementKey Increment-to-Reload
// TemplatePageClient.tsx
const [aiPlacementKey, setAiPlacementKey] = useState(0);
const handleAiAutoPlace = useCallback(async () => {
await fetch(`/api/templates/${templateId}/ai-prepare`, { method: 'POST' });
setAiPlacementKey(k => k + 1); // triggers FieldPlacer useEffect to re-fetch fields
}, [templateId]);
Source: DocumentPageClient.tsx lines 74–90. Identical pattern for template AI auto-place.
Pattern 3: FieldPlacer onPersist Abstraction
The persistFields function is called in exactly 4 places in FieldPlacer:
- Line ~324:
persistFields(docId, next)— after drag-drop of a new field - Line ~508:
persistFields(docId, next)— after move (pointer up) - Line ~575:
persistFields(docId, next)— after resize (pointer up) - Line ~745:
persistFields(docId, next)— after delete button click
Each call site becomes:
onPersist ? await onPersist(next) : await persistFields(docId, next);
The onPersist callback in TemplatePageClient:
const handlePersist = useCallback(async (fields: SignatureFieldData[]) => {
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatureFields: fields }),
});
}, [templateId]);
Pattern 4: PortalNav navLinks Array Extension
// PortalNav.tsx — current navLinks
const navLinks = [
{ href: "/portal/dashboard", label: "Dashboard" },
{ href: "/portal/clients", label: "Clients" },
{ href: "/portal/profile", label: "Profile" },
];
// Add "Templates" between Clients and Profile:
{ href: "/portal/templates", label: "Templates" },
No other changes to PortalNav.
Pattern 5: Server Component List Page (clients/page.tsx)
export default async function TemplatesPage() {
const rows = await db.select({ ... })
.from(documentTemplates)
.leftJoin(formTemplates, eq(documentTemplates.formTemplateId, formTemplates.id))
.where(isNull(documentTemplates.archivedAt));
return <TemplatesPageClient templates={rows} />;
}
Mirrors clients/page.tsx exactly. Server fetches; client component handles interaction.
Pattern 6: AI Route (documents/[id]/ai-prepare)
Template route is simpler: no doc.status !== 'Draft' guard, no textFillData return, PDF path from seeds/forms/${form.filename} instead of uploads/${doc.filePath}.
// /api/templates/[id]/ai-prepare/route.ts
const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms');
const filePath = path.join(SEEDS_FORMS_DIR, form.filename);
// path traversal guard
if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 });
const blanks = await extractBlanks(filePath);
const { fields } = await classifyFieldsWithAI(blanks, null); // null = no client context
// PATCH template's signatureFields
await db.update(documentTemplates).set({ signatureFields: fields }).where(eq(documentTemplates.id, id));
return Response.json({ fields });
Critical Design Gap: PDF File Serving for Templates
Problem: PdfViewer currently fetches the PDF from /api/documents/${docId}/file. Templates are not documents — their PDF lives in seeds/forms/${form.filename}.
Two options:
Option A — New API route GET /api/templates/[id]/file that reads seeds/forms/${form.filename} and streams it. PdfViewerWrapper accepts an optional fileUrl prop that overrides the default /api/documents/${docId}/file. TemplatePageClient passes /api/templates/${templateId}/file.
Option B — New TemplatePdfViewer component that clones PdfViewer but uses the template file URL. Higher duplication risk.
Recommendation: Option A — add fileUrl?: string and onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void as optional props to PdfViewerWrapper and PdfViewer. Both default to the existing document behavior when absent. This is one extra prop per component and avoids any duplication.
Fields fetch for templates: FieldPlacer fetches fields from /api/documents/${docId}/fields. In template mode, fields come from documentTemplates.signatureFields. Two sub-options:
- Sub-option A: FieldPlacer also accepts an optional
initialFields?: SignatureFieldData[]prop — TemplatePageClient passes the fields loaded from DB by the server component, bypassing the API fetch. FieldPlacer usesinitialFieldsas the initial state when provided, skipping theuseEffectfetch. - Sub-option B: Add a
GET /api/templates/[id]/fieldsroute that returnstemplate.signatureFields.
Recommendation: Sub-option A (initialFields prop) — the server component already fetches the template including its signatureFields. Passing them as a prop avoids an extra round-trip and keeps the component self-contained. The useEffect that fetches fields from /api/documents/${docId}/fields runs only when initialFields is absent (or aiPlacementKey increments). When aiPlacementKey increments in template mode, TemplatePageClient must re-fetch from the DB (or from the PATCH response) and update initialFields.
Revised approach for aiPlacementKey in template mode: After the AI route returns, TemplatePageClient receives the updated fields array in the response body and stores them in local state (const [fields, setFields] = useState<SignatureFieldData[]>(initialFields)). Incrementing aiPlacementKey alone would try to re-fetch from /api/documents/${docId}/fields — which doesn't apply. Instead, TemplatePageClient stores the fields state and passes it as initialFields; after AI auto-place, it updates fields from the response and resets aiPlacementKey to force FieldPlacer to re-initialize from the new initialFields.
This is a notable difference from DocumentPageClient — in document mode, the server-side PATCH writes the fields to DB and aiPlacementKey increment causes FieldPlacer to re-fetch from the same /api/documents/[id]/fields endpoint. In template mode, the route similarly writes to DB via PATCH, so the simpler approach is: after AI auto-place response, use setAiPlacementKey(k => k + 1) as-is — but FieldPlacer needs a fallback fields endpoint for templates. Simplest resolution: add GET /api/templates/[id]/fields that returns documentTemplates.signatureFields ?? []. Then aiPlacementKey increment triggers the existing useEffect as long as FieldPlacer knows to call /api/templates/${docId}/fields instead of /api/documents/${docId}/fields.
This suggests: docId in FieldPlacer should either remain as-is with the caller passing the route prefix, OR FieldPlacer gets a fieldsUrl prop. Cleanest solution: FieldPlacer gets a fieldsUrl?: string prop that defaults to /api/documents/${docId}/fields when absent, and callers in template mode pass /api/templates/${templateId}/fields. Similarly persistFields is replaced by onPersist — so no PUT /api/documents/${docId}/fields call happens in template mode.
Recommended Project Structure (new files)
src/app/portal/(protected)/templates/
├── page.tsx # server component: list page
├── [id]/
│ ├── page.tsx # server component: editor loader
│ └── _components/
│ ├── TemplatePageClient.tsx # state owner (mirrors DocumentPageClient)
│ └── TemplatePanel.tsx # right panel (roles, AI, save)
src/app/api/templates/[id]/
├── ai-prepare/
│ └── route.ts # POST — AI auto-place for templates
└── fields/
│ └── route.ts # GET — return signatureFields for aiPlacementKey reload
└── file/
└── route.ts # GET — stream PDF from seeds/forms/
Modified files:
src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx— addonPersist+fieldsUrlpropssrc/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx— addfileUrl+onPersist+fieldsUrlprop pass-throughssrc/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx— addfileUrl+onPersist+fieldsUrlprop pass-throughssrc/app/portal/_components/PortalNav.tsx— add "Templates" to navLinks
Anti-Patterns to Avoid
- Duplicating PdfViewer/FieldPlacer: Do not create
TemplatePdfViewer.tsxorTemplateFieldPlacer.tsx. The prop-extension approach (fileUrl,fieldsUrl,onPersist) keeps a single implementation. - Email validation on role labels: There is no email validation in FieldPlacer, PdfViewerWrapper, or PdfViewer — confirmed by grep. Role labels like "Buyer" flow through cleanly. Do NOT add email validation when adding the template mode.
- Storing hints in textFillData: TMPL-08 requires text hints on client-text fields. Hints are stored in
field.hinton theSignatureFieldDataobject, not intextFillData. The existing FieldPlacer client-text inline input already stores values viaonFieldValueChangewhich maps totextFillData[field.id]. In template mode, the agent types a hint — this must be saved asfield.hintwithin thesignatureFieldsJSONB, not as a text fill value. The existing client-text inline edit in FieldPlacer setstextFillData[field.id](the hint displayed on the signing page) but does NOT write tofield.hint. Resolution: In template mode, treat the client-text inline input value as the hint — whenonPersistis called, thefieldsarray should includefield.hintset from whatever is in the text input for that field. This requires FieldPlacer to propagate the inline input value into the field object before persisting — a small addition to the delete/persist logic.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| PDF rendering | Custom canvas renderer | react-pdf (already installed) |
Complex page/scale/text layer handling already solved |
| Drag and drop fields | Custom pointer events | @dnd-kit/core (already installed) |
Edge cases: touch, scroll, iframe, accessibility |
| Field coordinate math | Re-derive screen↔PDF | Existing screenToPdfCoords/pdfToScreenCoords in FieldPlacer |
Already handles Y-axis flip, scale, canvas offset |
| AI field extraction | New PDF parser | extractBlanks + classifyFieldsWithAI (already implemented) |
Phase 13 work — reused as-is |
| Role color cycling | Custom color picker | SIGNER_COLORS constant ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'] |
Same palette as document signers |
| Soft-delete confirmation | Custom dialog | ConfirmDialog component (already in portal/_components/) |
Existing pattern: phase 18 template delete, clients delete |
Common Pitfalls
Pitfall 1: FieldPlacer fields fetch URL hardcoded to documents
What goes wrong: FieldPlacer's useEffect calls /api/documents/${docId}/fields. In template mode, this 404s (no such document).
Why it happens: docId prop was originally always a document ID.
How to avoid: Add a fieldsUrl?: string prop to FieldPlacer with default value /api/documents/${docId}/fields. Template callers pass /api/templates/${templateId}/fields. This requires a new GET /api/templates/[id]/fields route.
Warning signs: 404 errors in browser console when opening the template editor.
Pitfall 2: onPersist dependency in handleDragEnd closure
What goes wrong: handleDragEnd in FieldPlacer is a useCallback with a dependency array that includes docId and onFieldsChanged. Adding onPersist changes the function signature — if onPersist is not included in the dependency array, the callback captures a stale closure (always sees the initial onPersist value).
Why it happens: React hook dependencies.
How to avoid: Add onPersist to the useCallback dependency array in all 4 call-site functions (handleDragEnd, handleZonePointerUp for move, handleZonePointerUp for resize, the delete button onClick). For the delete button (inline in renderFields), onPersist is already available in scope — ensure it's referenced.
Warning signs: Saving in template mode always calls the old internal persistFields despite onPersist being provided.
Pitfall 3: Template name rename updates DB immediately vs on Save
What goes wrong: If template name is an uncontrolled input that calls PATCH /api/templates/[id] on every keystroke (debounced or not), the save button behavior becomes confusing. If it's only saved on "Save Template" click, it feels decoupled from field saves.
Recommendation: Template name edits call PATCH /api/templates/[id] on blur (like a settings form). The "Save Template" button saves only signatureFields (field layout). These are two distinct PATCH calls — consistent with how PreparePanel works (signers are saved separately from fields).
Warning signs: Agent renames template, clicks Save, and the old name reappears.
Pitfall 4: Seeds/forms path in Docker container
What goes wrong: path.join(process.cwd(), 'seeds', 'forms') works in local dev. In Docker, cwd() is /app and the Dockerfile copies seeds/ to /app/seeds — so the path resolves correctly. This was already confirmed in Phase 17 research.
How to avoid: Use path.join(process.cwd(), 'seeds', 'forms', form.filename) — identical pattern to how forms are served elsewhere. No special Docker configuration needed.
Warning signs: 404 in AI auto-place saying "form PDF not found" — check process.cwd() in the container.
Pitfall 5: Default roles not persisted until agent saves
What goes wrong: TemplatePageClient initializes two default roles ("Buyer", "Seller") in local state. If the agent opens the editor, uses AI auto-place (which writes fields to DB), but never clicks Save — the role assignments in the fields' signerEmail slots refer to "Buyer"/"Seller" which is fine (that's template mode). But if the template was created with 0 roles and the agent hasn't saved yet, the active-signer selector in FieldPlacer shows nothing and newly placed fields have no signerEmail.
How to avoid: Initialize signers state from the template's existing signatureFields — derive unique role labels from field.signerEmail values already stored in the DB. If no fields exist yet, default to ["Buyer", "Seller"]. This ensures refreshing the editor page restores the role list correctly.
Warning signs: After refresh, role pills disappear but fields still have signerEmail values.
Pitfall 6: hint vs textFillData confusion
What goes wrong: In document mode, textFillData[field.id] holds the agent-entered value that gets burned into the PDF. In template mode for client-text fields, the agent enters a hint (placeholder label for the signer). If the hint is stored only in textFillData (ephemeral state) and not in field.hint within signatureFields, it's lost when the template is applied to a document.
How to avoid: When onPersist is called (template mode), FieldPlacer should merge any textFillData[field.id] values for client-text fields into field.hint before persisting. Alternatively, TemplatePageClient sets onPersist to a wrapper that applies the merge before calling the PATCH. Either way, field.hint in the stored signatureFields must carry the hint text, NOT textFillData.
Warning signs: Template hints don't appear when template is applied to a document in Phase 20.
Code Examples
onPersist prop addition to FieldPlacer
// Source: FieldPlacer.tsx FieldPlacerProps interface — add one line
interface FieldPlacerProps {
docId: string;
pageInfo: PageInfo | null;
currentPage: number;
children: React.ReactNode;
readOnly?: boolean;
onFieldsChanged?: () => void;
selectedFieldId?: string | null;
textFillData?: Record<string, string>;
onFieldSelect?: (fieldId: string | null) => void;
onFieldValueChange?: (fieldId: string, value: string) => void;
aiPlacementKey?: number;
signers?: DocumentSigner[];
unassignedFieldIds?: Set<string>;
onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void; // NEW
fieldsUrl?: string; // NEW — defaults to /api/documents/${docId}/fields
}
Call site replacement (one of 4)
// Before (line ~324 in FieldPlacer, handleDragEnd)
persistFields(docId, next);
// After
if (onPersist) {
await onPersist(next);
} else {
await persistFields(docId, next);
}
TemplatePageClient onPersist (with hint merge)
const handlePersist = useCallback(async (rawFields: SignatureFieldData[]) => {
// Merge textFillData hints into client-text field.hint before saving to template
const fieldsWithHints = rawFields.map(f =>
f.type === 'client-text' && textFillData[f.id]
? { ...f, hint: textFillData[f.id] }
: f
);
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatureFields: fieldsWithHints }),
});
}, [templateId, textFillData]);
GET /api/templates/[id]/fields route
// src/app/api/templates/[id]/fields/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
});
if (!template) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(template.signatureFields ?? []);
}
GET /api/templates/[id]/file route
// src/app/api/templates/[id]/file/route.ts
const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms');
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
with: { formTemplate: true },
});
if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 });
const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename);
if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 });
const file = await fs.readFile(filePath);
return new Response(file, { headers: { 'Content-Type': 'application/pdf' } });
}
Deriving initial roles from existing signatureFields
// TemplatePageClient.tsx — derive roles from stored fields
const ROLE_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
const DEFAULT_ROLES: DocumentSigner[] = [
{ email: 'Buyer', color: ROLE_COLORS[0] },
{ email: 'Seller', color: ROLE_COLORS[1] },
];
function deriveRolesFromFields(fields: SignatureFieldData[]): DocumentSigner[] {
const seen = new Map<string, string>();
fields.forEach(f => {
if (f.signerEmail && !seen.has(f.signerEmail)) {
seen.set(f.signerEmail, ROLE_COLORS[seen.size % ROLE_COLORS.length]);
}
});
if (seen.size === 0) return DEFAULT_ROLES;
return Array.from(seen.entries()).map(([email, color]) => ({ email, color }));
}
Environment Availability
Step 2.6: SKIPPED — Phase 19 is UI/code changes only. No new external dependencies. All required tools (Node.js, pdfjs-dist, @dnd-kit/core, Drizzle, OpenAI SDK) confirmed present from prior phases.
Validation Architecture
workflow.nyquist_validationkey is absent from.planning/config.json— treating as enabled.
Test Framework
| Property | Value |
|---|---|
| Framework | None detected — no jest.config, vitest.config, or pytest.ini found in repo |
| Config file | None |
| Quick run command | Manual browser verification (established project pattern) |
| Full suite command | Manual E2E verification at phase gate |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| TMPL-05 | Agent opens template, drags field onto PDF | manual-only | browser: open /portal/templates/[id], drag a field | N/A |
| TMPL-06 | AI Auto-place populates fields | manual-only | browser: click "AI Auto-place Fields", verify fields appear | N/A |
| TMPL-07 | Role labels (not emails) in signerEmail slots | manual-only | browser: add "Buyer" role, place a field, verify signerEmail="Buyer" stored in DB | N/A |
| TMPL-08 | Text hint on client-text field survives save | manual-only | browser: place client-text, click it, type hint, save, refresh, verify hint persists | N/A |
| TMPL-09 | Save persists fields and roles | manual-only | browser: save, refresh, verify field positions match | N/A |
No automated test infrastructure exists in this project. All verification is manual E2E (established pattern from prior phases: Phase 11, 11.1, 12.1 all concluded with "Plan 03: pure human E2E verification").
Wave 0 Gaps
None — no test framework to install; project relies on manual verification.
Project Constraints (from CLAUDE.md)
These directives from ./teressa-copeland-homes/CLAUDE.md apply to all implementation work:
- Architecture inspection before planning — planner must inspect existing patterns before proposing task structure. This research document satisfies that requirement.
- Extend existing patterns by default — no new folder structures, no new workflow styles. Template editor follows
documents/[docId]/pattern exactly. - Required output sections before implementation — each plan must include: Existing Architecture, Similar Implementations Found, Reuse vs New Pattern Decision, Persona Findings, Synthesized Plan.
- No generic implementations — all solutions must reference real repo structure (enforced: all code examples above reference actual file paths and line numbers).
- Multi-persona research required — plans must apply Skeptical, Customer, QA, Security, and Implementation Engineer perspectives.
- Deviation must be justified — the
fieldsUrlprop addition andGET /api/templates/[id]/fieldsroute deviate from the CONTEXT.md explicit plan. Justification documented above (Pitfall 1 and Architecture Patterns sections). - Consistency is mandatory — inline styles, no shadcn, same color palette, same spacing scale (enforced by 19-UI-SPEC.md which was generated and confirmed before research).
Open Questions
-
hint persistence via onPersist vs separate save action
- What we know:
field.hintexists inSignatureFieldDataschema. FieldPlacer's client-text inline input currently writes totextFillData[field.id], not tofield.hint. - What's unclear: Should the hint be merged into
field.hintby TemplatePageClient'sonPersistwrapper (cleanest, no FieldPlacer change needed) or should FieldPlacer itself updatefield.hintin the fields array when the client-text input changes? - Recommendation: Merge in TemplatePageClient's
onPersistwrapper to avoid FieldPlacer changes beyond theonPersistprop addition. Document the pattern clearly in the plan.
- What we know:
-
New Template creation flow from the list page
- What we know: CONTEXT.md D-10 says "click a row to open editor". The UI-SPEC says "+ New Template" button opens a modal. Phase 18 CRUD API (POST /api/templates) already exists.
- What's unclear: Which modal component handles template creation? Reuse
AddDocumentModalpattern, or build a minimalAddTemplateModal? - Recommendation: Build
AddTemplateModalas a self-contained component inside the templates list page client component. It needs only: template name input, form selector (fromGET /api/formsor inline formTemplates query), and a Create button. The form selector can reuse the existing forms library query pattern.
-
fieldsUrlvsinitialFieldsfor template field loading- What we know: Two approaches were analyzed above.
fieldsUrlrequires a new API route but fits cleanly into FieldPlacer's existinguseEffectpattern.initialFieldsavoids the extra route but requires managing field state at two levels. - Recommendation:
fieldsUrlprop +GET /api/templates/[id]/fieldsroute. Simpler mental model, consistent with existing fetch pattern, and the route is trivially small (5 lines).
- What we know: Two approaches were analyzed above.
Sources
Primary (HIGH confidence)
- Direct source inspection:
FieldPlacer.tsx(822 lines, all 4persistFieldscall sites read) - Direct source inspection:
DocumentPageClient.tsx—aiPlacementKeypattern - Direct source inspection:
PdfViewer.tsx+PdfViewerWrapper.tsx— prop chain - Direct source inspection:
/api/documents/[id]/ai-prepare/route.ts— AI route pattern - Direct source inspection:
/api/templates/[id]/route.ts+/api/templates/route.ts— Phase 18 CRUD - Direct source inspection:
schema.ts—SignatureFieldData.hint,DocumentSigner,documentTemplatestable - Direct source inspection:
PortalNav.tsx— navLinks array structure - Direct source inspection:
clients/page.tsx— server component list page pattern - Direct source inspection:
19-UI-SPEC.md— visual and interaction contract (pre-approved)
Secondary (MEDIUM confidence)
STATE.mddecisions log — confirms no email validation in FieldPlacer, role-label strategyREQUIREMENTS.md— TMPL-05 through TMPL-09 requirement text
Tertiary (LOW confidence)
- None — all findings are from direct source inspection of the repo.
Metadata
Confidence breakdown:
- FieldPlacer modification approach: HIGH — all 4 call sites confirmed by reading source
- New route patterns: HIGH — copying confirmed working patterns from Phase 13 and Phase 18
- Template PDF serving: HIGH — seeds/forms dir confirmed present, Docker path confirmed by Phase 17
- Role-label flow: HIGH — no email validation found in FieldPlacer/PdfViewer/PdfViewerWrapper chain
- hint merge approach: MEDIUM —
field.hintexists in schema but current FieldPlacer does not write to it; the merge-in-onPersist approach is a design recommendation not yet verified by an implementer
Research date: 2026-04-06 Valid until: 2026-05-06 (stable codebase, no fast-moving dependencies)