# 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 (from CONTEXT.md) ### Locked Decisions **FieldPlacer Abstraction** - D-01: Add `onPersist?: (fields: SignatureFieldData[]) => Promise | void` prop to FieldPlacer. When provided, replaces the internal `persistFields(docId, fields)` call. When absent (all existing consumers), falls back to existing internal `persistFields` behavior — fully backwards compatible. - D-02: `persistFields` is called in 4 places in FieldPlacer. All 4 must be updated to call `onPersist(fields)` if provided, else `persistFields(docId, fields)`. The internal `persistFields` function stays for backwards compat. - D-03: FieldPlacer's `signers` prop accepts `DocumentSigner[]` where `email` carries 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.signatureFields` via `field.signerEmail`. Save calls `PATCH /api/templates/[id]` with full `signatureFields` array. - 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 `/edit` route. - 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` — reads `formTemplate.filename` to locate PDF in `seeds/forms/`, calls `extractBlanks(filePath)` + `classifyFieldsWithAI(blanks, null)`, writes result to `documentTemplates.signatureFields` via `PATCH`. - D-15: No client pre-fill in template AI mode — `classifyFieldsWithAI` receives `null` for client context. - D-16: AI Auto-place button triggers `POST /api/templates/[id]/ai-prepare`, then reloads FieldPlacer via `aiPlacementKey` increment. **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. --- ## 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` | --- ## 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** ```typescript // 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: 1. Line ~324: `persistFields(docId, next)` — after drag-drop of a new field 2. Line ~508: `persistFields(docId, next)` — after move (pointer up) 3. Line ~575: `persistFields(docId, next)` — after resize (pointer up) 4. Line ~745: `persistFields(docId, next)` — after delete button click Each call site becomes: ```typescript onPersist ? await onPersist(next) : await persistFields(docId, next); ``` The `onPersist` callback in TemplatePageClient: ```typescript 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** ```typescript // 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)** ```typescript export default async function TemplatesPage() { const rows = await db.select({ ... }) .from(documentTemplates) .leftJoin(formTemplates, eq(documentTemplates.formTemplateId, formTemplates.id)) .where(isNull(documentTemplates.archivedAt)); return ; } ``` 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}`. ```typescript // /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` 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 uses `initialFields` as the initial state when provided, skipping the `useEffect` fetch. - Sub-option B: Add a `GET /api/templates/[id]/fields` route that returns `template.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(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` — add `onPersist` + `fieldsUrl` props - `src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx` — add `fileUrl` + `onPersist` + `fieldsUrl` prop pass-throughs - `src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx` — add `fileUrl` + `onPersist` + `fieldsUrl` prop pass-throughs - `src/app/portal/_components/PortalNav.tsx` — add "Templates" to navLinks ### Anti-Patterns to Avoid - **Duplicating PdfViewer/FieldPlacer:** Do not create `TemplatePdfViewer.tsx` or `TemplateFieldPlacer.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.hint` on the `SignatureFieldData` object, not in `textFillData`. The existing FieldPlacer client-text inline input already stores values via `onFieldValueChange` which maps to `textFillData[field.id]`. In template mode, the agent types a hint — this must be saved as `field.hint` within the `signatureFields` JSONB, not as a text fill value. The existing client-text inline edit in FieldPlacer sets `textFillData[field.id]` (the hint displayed on the signing page) but does NOT write to `field.hint`. **Resolution:** In template mode, treat the client-text inline input value as the hint — when `onPersist` is called, the `fields` array should include `field.hint` set 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 ```typescript // 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; onFieldSelect?: (fieldId: string | null) => void; onFieldValueChange?: (fieldId: string, value: string) => void; aiPlacementKey?: number; signers?: DocumentSigner[]; unassignedFieldIds?: Set; onPersist?: (fields: SignatureFieldData[]) => Promise | void; // NEW fieldsUrl?: string; // NEW — defaults to /api/documents/${docId}/fields } ``` ### Call site replacement (one of 4) ```typescript // 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) ```typescript 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 ```typescript // 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 ```typescript // 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 ```typescript // 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(); 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_validation` key 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: 1. **Architecture inspection before planning** — planner must inspect existing patterns before proposing task structure. This research document satisfies that requirement. 2. **Extend existing patterns by default** — no new folder structures, no new workflow styles. Template editor follows `documents/[docId]/` pattern exactly. 3. **Required output sections before implementation** — each plan must include: Existing Architecture, Similar Implementations Found, Reuse vs New Pattern Decision, Persona Findings, Synthesized Plan. 4. **No generic implementations** — all solutions must reference real repo structure (enforced: all code examples above reference actual file paths and line numbers). 5. **Multi-persona research required** — plans must apply Skeptical, Customer, QA, Security, and Implementation Engineer perspectives. 6. **Deviation must be justified** — the `fieldsUrl` prop addition and `GET /api/templates/[id]/fields` route deviate from the CONTEXT.md explicit plan. Justification documented above (Pitfall 1 and Architecture Patterns sections). 7. **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 1. **hint persistence via onPersist vs separate save action** - What we know: `field.hint` exists in `SignatureFieldData` schema. FieldPlacer's client-text inline input currently writes to `textFillData[field.id]`, not to `field.hint`. - What's unclear: Should the hint be merged into `field.hint` by TemplatePageClient's `onPersist` wrapper (cleanest, no FieldPlacer change needed) or should FieldPlacer itself update `field.hint` in the fields array when the client-text input changes? - Recommendation: Merge in TemplatePageClient's `onPersist` wrapper to avoid FieldPlacer changes beyond the `onPersist` prop addition. Document the pattern clearly in the plan. 2. **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 `AddDocumentModal` pattern, or build a minimal `AddTemplateModal`? - Recommendation: Build `AddTemplateModal` as a self-contained component inside the templates list page client component. It needs only: template name input, form selector (from `GET /api/forms` or inline formTemplates query), and a Create button. The form selector can reuse the existing forms library query pattern. 3. **`fieldsUrl` vs `initialFields` for template field loading** - What we know: Two approaches were analyzed above. `fieldsUrl` requires a new API route but fits cleanly into FieldPlacer's existing `useEffect` pattern. `initialFields` avoids the extra route but requires managing field state at two levels. - Recommendation: `fieldsUrl` prop + `GET /api/templates/[id]/fields` route. Simpler mental model, consistent with existing fetch pattern, and the route is trivially small (5 lines). --- ## Sources ### Primary (HIGH confidence) - Direct source inspection: `FieldPlacer.tsx` (822 lines, all 4 `persistFields` call sites read) - Direct source inspection: `DocumentPageClient.tsx` — `aiPlacementKey` pattern - 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`, `documentTemplates` table - 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.md` decisions log — confirms no email validation in FieldPlacer, role-label strategy - `REQUIREMENTS.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.hint` exists 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)