395 lines
16 KiB
Markdown
395 lines
16 KiB
Markdown
|
|
---
|
|||
|
|
phase: 05-pdf-fill-and-field-mapping
|
|||
|
|
plan: 02
|
|||
|
|
type: execute
|
|||
|
|
wave: 2
|
|||
|
|
depends_on: [05-01]
|
|||
|
|
files_modified:
|
|||
|
|
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
|
|||
|
|
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
|
|||
|
|
autonomous: true
|
|||
|
|
requirements: [DOC-04]
|
|||
|
|
|
|||
|
|
must_haves:
|
|||
|
|
truths:
|
|||
|
|
- "Agent sees a field palette on the document detail page with a draggable Signature Field token"
|
|||
|
|
- "Agent can drag the Signature Field token onto any part of any PDF page and release it to place a field"
|
|||
|
|
- "Placed fields appear as blue-bordered semi-transparent rectangles overlaid on the correct position of the PDF page"
|
|||
|
|
- "Stored fields persist across page reload (coordinates are saved to the server via PUT /api/documents/[id]/fields)"
|
|||
|
|
- "Fields placed at the top of a page have high PDF Y values (near originalHeight); fields placed at the bottom have low PDF Y values — Y-axis flip is correct"
|
|||
|
|
- "Placed fields can be deleted individually via a remove button"
|
|||
|
|
artifacts:
|
|||
|
|
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx"
|
|||
|
|
provides: "dnd-kit DndContext with draggable token palette and droppable PDF page overlay"
|
|||
|
|
min_lines: 80
|
|||
|
|
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx"
|
|||
|
|
provides: "Extended to accept and render placed fields as absolute-positioned overlays; exposes pageInfo via onPageLoad callback"
|
|||
|
|
key_links:
|
|||
|
|
- from: "FieldPlacer.tsx onDragEnd"
|
|||
|
|
to: "screenToPdfCoords() formula"
|
|||
|
|
via: "inline coordinate conversion using pageContainerRef.getBoundingClientRect()"
|
|||
|
|
pattern: "renderedH - screenY.*originalHeight"
|
|||
|
|
- from: "FieldPlacer.tsx"
|
|||
|
|
to: "PUT /api/documents/[id]/fields"
|
|||
|
|
via: "fetch PUT on every field add/remove"
|
|||
|
|
pattern: "fetch.*fields.*PUT"
|
|||
|
|
- from: "PdfViewer.tsx Page"
|
|||
|
|
to: "FieldPlacer.tsx pageInfo state"
|
|||
|
|
via: "onLoadSuccess callback sets pageInfo: { originalWidth, originalHeight, width, height, scale }"
|
|||
|
|
pattern: "onLoadSuccess.*originalWidth"
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
<objective>
|
|||
|
|
Extend the document detail page with a drag-and-drop field placer. The agent drags a Signature Field token from a palette onto any PDF page. On drop, screen coordinates are converted to PDF user-space coordinates (Y-axis flip formula), stored in state and persisted via PUT /api/documents/[id]/fields. Placed fields are rendered as blue rectangle overlays on the PDF. Fields load from the server on mount.
|
|||
|
|
|
|||
|
|
Purpose: Fulfills DOC-04 — agent can place signature fields on any page of a PDF and those coordinates survive for downstream PDF preparation and signing.
|
|||
|
|
|
|||
|
|
Output: FieldPlacer.tsx client component + extended PdfViewer.tsx.
|
|||
|
|
</objective>
|
|||
|
|
|
|||
|
|
<execution_context>
|
|||
|
|
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
|||
|
|
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
|||
|
|
</execution_context>
|
|||
|
|
|
|||
|
|
<context>
|
|||
|
|
@.planning/PROJECT.md
|
|||
|
|
@.planning/STATE.md
|
|||
|
|
@.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md
|
|||
|
|
@.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md
|
|||
|
|
|
|||
|
|
<interfaces>
|
|||
|
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
|||
|
|
|
|||
|
|
From teressa-copeland-homes/src/lib/db/schema.ts (after Plan 01):
|
|||
|
|
```typescript
|
|||
|
|
export interface SignatureFieldData {
|
|||
|
|
id: string;
|
|||
|
|
page: number; // 1-indexed
|
|||
|
|
x: number; // PDF user space, bottom-left origin, points
|
|||
|
|
y: number; // PDF user space, bottom-left origin, points
|
|||
|
|
width: number; // PDF points
|
|||
|
|
height: number; // PDF points
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
From teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx (current — MODIFY):
|
|||
|
|
```typescript
|
|||
|
|
'use client';
|
|||
|
|
import { useState } from 'react';
|
|||
|
|
import { Document, Page, pdfjs } from 'react-pdf';
|
|||
|
|
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
|||
|
|
import 'react-pdf/dist/Page/TextLayer.css';
|
|||
|
|
|
|||
|
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|||
|
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|||
|
|
import.meta.url,
|
|||
|
|
).toString();
|
|||
|
|
|
|||
|
|
export function PdfViewer({ docId }: { docId: string }) {
|
|||
|
|
const [numPages, setNumPages] = useState(0);
|
|||
|
|
const [pageNumber, setPageNumber] = useState(1);
|
|||
|
|
const [scale, setScale] = useState(1.0);
|
|||
|
|
// ...renders Document + Page with Prev/Next, Zoom In/Out, Download
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
API contract (from Plan 01):
|
|||
|
|
- GET /api/documents/[id]/fields → SignatureFieldData[] (returns [] if none)
|
|||
|
|
- PUT /api/documents/[id]/fields → body: SignatureFieldData[] → returns updated array
|
|||
|
|
|
|||
|
|
Coordinate conversion (CRITICAL — do not re-derive):
|
|||
|
|
```typescript
|
|||
|
|
// Screen (DOM) → PDF user space. Y-axis flip required.
|
|||
|
|
// DOM: Y=0 at top, increases downward
|
|||
|
|
// PDF: Y=0 at bottom, increases upward
|
|||
|
|
function screenToPdfCoords(screenX: number, screenY: number, pageInfo: PageInfo) {
|
|||
|
|
const pdfX = (screenX / pageInfo.width) * pageInfo.originalWidth;
|
|||
|
|
const pdfY = ((pageInfo.height - screenY) / pageInfo.height) * pageInfo.originalHeight;
|
|||
|
|
return { x: pdfX, y: pdfY };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PDF user space → screen (for rendering stored fields)
|
|||
|
|
function pdfToScreenCoords(pdfX: number, pdfY: number, pageInfo: PageInfo) {
|
|||
|
|
const left = (pdfX / pageInfo.originalWidth) * pageInfo.width;
|
|||
|
|
// top is measured from DOM top; pdfY is from PDF bottom — reverse flip
|
|||
|
|
const top = pageInfo.height - (pdfY / pageInfo.originalHeight) * pageInfo.height;
|
|||
|
|
return { left, top };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Pitfall guard for originalHeight:
|
|||
|
|
```typescript
|
|||
|
|
// Some PDFs have non-standard mediaBox ordering — use Math.max to handle both
|
|||
|
|
originalWidth: Math.max(page.view[0], page.view[2]),
|
|||
|
|
originalHeight: Math.max(page.view[1], page.view[3]),
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
dnd-kit drop position pattern (from research — MEDIUM confidence, verify during impl):
|
|||
|
|
```typescript
|
|||
|
|
// The draggable token is in the palette, not on the canvas.
|
|||
|
|
// Use DragOverlay for visual ghost + compute final position from
|
|||
|
|
// the mouse/touch coordinates at drop time relative to the container rect.
|
|||
|
|
// event.delta gives displacement from drag start position of the activator.
|
|||
|
|
// For an item dragged FROM the palette onto the PDF zone:
|
|||
|
|
// finalX = activatorClientX + event.delta.x - containerRect.left
|
|||
|
|
// finalY = activatorClientY + event.delta.y - containerRect.top
|
|||
|
|
// The activator coordinates come from event.activatorEvent (MouseEvent or TouchEvent).
|
|||
|
|
```
|
|||
|
|
</interfaces>
|
|||
|
|
</context>
|
|||
|
|
|
|||
|
|
<tasks>
|
|||
|
|
|
|||
|
|
<task type="auto">
|
|||
|
|
<name>Task 1: Install dnd-kit and create FieldPlacer component</name>
|
|||
|
|
<files>
|
|||
|
|
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
|
|||
|
|
</files>
|
|||
|
|
<action>
|
|||
|
|
**Step A — Install dnd-kit:**
|
|||
|
|
```bash
|
|||
|
|
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install @dnd-kit/core @dnd-kit/utilities
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step B — Create FieldPlacer.tsx.**
|
|||
|
|
|
|||
|
|
This is a client component. It:
|
|||
|
|
1. Fetches existing fields from GET /api/documents/[id]/fields on mount
|
|||
|
|
2. Renders a palette with a single draggable "Signature Field" token using `useDraggable`
|
|||
|
|
3. Renders the PDF page container as a `useDroppable` zone
|
|||
|
|
4. On drop, converts screen coordinates to PDF user-space using the Y-flip formula, adds the new field to state, persists via PUT /api/documents/[id]/fields
|
|||
|
|
5. Renders placed fields as absolute-positioned divs over the PDF page (using pdfToScreenCoords)
|
|||
|
|
6. Each placed field has an X button to delete it (removes from state, persists)
|
|||
|
|
|
|||
|
|
Key implementation details:
|
|||
|
|
- Accept props: `{ docId: string; pageInfo: PageInfo | null; currentPage: number; children: React.ReactNode }` where `children` is the `<Document>/<Page>` tree rendered by PdfViewer
|
|||
|
|
- The droppable zone wraps the children (PDF canvas) with `position: relative` so overlays position correctly
|
|||
|
|
- Default field size: 144 × 36 PDF points (2 inches × 0.5 inches at 72 DPI)
|
|||
|
|
- Use `DragOverlay` to show a ghost during drag (better UX than transform-based dragging)
|
|||
|
|
- The page container ref (`useRef<HTMLDivElement>`) is attached to the droppable wrapper div — use `getBoundingClientRect()` at drop time for current rendered dimensions (NOT stale pageInfo.width because zoom may have changed)
|
|||
|
|
- Persist via async fetch (fire-and-forget with error logging — don't block UI)
|
|||
|
|
- Fields state: `useState<SignatureFieldData[]>([])` loaded from server on mount
|
|||
|
|
|
|||
|
|
PageInfo interface (define locally in this file):
|
|||
|
|
```typescript
|
|||
|
|
interface PageInfo {
|
|||
|
|
originalWidth: number;
|
|||
|
|
originalHeight: number;
|
|||
|
|
width: number;
|
|||
|
|
height: number;
|
|||
|
|
scale: number;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Structure:
|
|||
|
|
```typescript
|
|||
|
|
'use client';
|
|||
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|||
|
|
import {
|
|||
|
|
DndContext,
|
|||
|
|
useDraggable,
|
|||
|
|
useDroppable,
|
|||
|
|
DragOverlay,
|
|||
|
|
type DragEndEvent,
|
|||
|
|
} from '@dnd-kit/core';
|
|||
|
|
import { CSS } from '@dnd-kit/utilities';
|
|||
|
|
import type { SignatureFieldData } from '@/lib/db/schema';
|
|||
|
|
|
|||
|
|
// ... DraggableToken sub-component using useDraggable
|
|||
|
|
// ... FieldPlacer main component
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Coordinate math — use EXACTLY this formula (do not re-derive):
|
|||
|
|
```typescript
|
|||
|
|
function screenToPdfCoords(screenX: number, screenY: number, containerRect: DOMRect, pageInfo: PageInfo) {
|
|||
|
|
// Use containerRect dimensions (current rendered size) not stale pageInfo
|
|||
|
|
const renderedW = containerRect.width;
|
|||
|
|
const renderedH = containerRect.height;
|
|||
|
|
const pdfX = (screenX / renderedW) * pageInfo.originalWidth;
|
|||
|
|
const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight;
|
|||
|
|
return { x: pdfX, y: pdfY };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pdfToScreenCoords(pdfX: number, pdfY: number, containerRect: DOMRect, pageInfo: PageInfo) {
|
|||
|
|
const renderedW = containerRect.width;
|
|||
|
|
const renderedH = containerRect.height;
|
|||
|
|
const left = (pdfX / pageInfo.originalWidth) * renderedW;
|
|||
|
|
const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH;
|
|||
|
|
return { left, top };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Persist helper (keep outside component to avoid re-creation):
|
|||
|
|
```typescript
|
|||
|
|
async function persistFields(docId: string, fields: SignatureFieldData[]) {
|
|||
|
|
try {
|
|||
|
|
await fetch(`/api/documents/${docId}/fields`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(fields),
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to persist fields:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Field overlay rendering — subtract height because PDF Y is at bottom-left, but DOM top is at top-left:
|
|||
|
|
```typescript
|
|||
|
|
{fields.filter(f => f.page === currentPage).map(field => {
|
|||
|
|
const containerRect = containerRef.current?.getBoundingClientRect();
|
|||
|
|
if (!containerRect || !pageInfo) return null;
|
|||
|
|
const { left, top } = pdfToScreenCoords(field.x, field.y, containerRect, pageInfo);
|
|||
|
|
const widthPx = (field.width / pageInfo.originalWidth) * containerRect.width;
|
|||
|
|
const heightPx = (field.height / pageInfo.originalHeight) * containerRect.height;
|
|||
|
|
return (
|
|||
|
|
<div key={field.id} style={{
|
|||
|
|
position: 'absolute',
|
|||
|
|
left,
|
|||
|
|
top: top - heightPx, // top is the y of the bottom-left corner; shift up by height for correct placement
|
|||
|
|
width: widthPx,
|
|||
|
|
height: heightPx,
|
|||
|
|
border: '2px solid #2563eb',
|
|||
|
|
background: 'rgba(37,99,235,0.1)',
|
|||
|
|
borderRadius: '2px',
|
|||
|
|
display: 'flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
justifyContent: 'space-between',
|
|||
|
|
padding: '0 4px',
|
|||
|
|
fontSize: '10px',
|
|||
|
|
color: '#2563eb',
|
|||
|
|
pointerEvents: 'all',
|
|||
|
|
}}>
|
|||
|
|
<span>Signature</span>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
const next = fields.filter(f => f.id !== field.id);
|
|||
|
|
setFields(next);
|
|||
|
|
persistFields(docId, next);
|
|||
|
|
}}
|
|||
|
|
style={{ cursor: 'pointer', background: 'none', border: 'none', color: '#ef4444', fontWeight: 'bold', padding: '0 2px' }}
|
|||
|
|
aria-label="Remove field"
|
|||
|
|
>×</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
```
|
|||
|
|
</action>
|
|||
|
|
<verify>
|
|||
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10</automated>
|
|||
|
|
</verify>
|
|||
|
|
<done>
|
|||
|
|
- FieldPlacer.tsx exists and exports FieldPlacer component
|
|||
|
|
- @dnd-kit/core and @dnd-kit/utilities in package.json
|
|||
|
|
- Component accepts docId, pageInfo, currentPage, children props
|
|||
|
|
- Uses exactly the Y-flip formula from research (no re-derivation)
|
|||
|
|
- npm run build compiles without TypeScript errors
|
|||
|
|
</done>
|
|||
|
|
</task>
|
|||
|
|
|
|||
|
|
<task type="auto">
|
|||
|
|
<name>Task 2: Extend PdfViewer to expose pageInfo and integrate FieldPlacer</name>
|
|||
|
|
<files>
|
|||
|
|
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
|
|||
|
|
</files>
|
|||
|
|
<action>
|
|||
|
|
Rewrite PdfViewer.tsx to:
|
|||
|
|
1. Add `pageInfo` state (`useState<PageInfo | null>(null)`) where PageInfo is `{ originalWidth, originalHeight, width, height, scale }`
|
|||
|
|
2. Update the `<Page>` component's `onLoadSuccess` callback to set pageInfo using the `Math.max` pattern for originalWidth/Height:
|
|||
|
|
```typescript
|
|||
|
|
onLoadSuccess={(page) => {
|
|||
|
|
setPageInfo({
|
|||
|
|
originalWidth: Math.max(page.view[0], page.view[2]),
|
|||
|
|
originalHeight: Math.max(page.view[1], page.view[3]),
|
|||
|
|
width: page.width,
|
|||
|
|
height: page.height,
|
|||
|
|
scale: page.scale,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
```
|
|||
|
|
3. Import and wrap the `<Document><Page /></Document>` tree inside `<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber}>`. FieldPlacer renders children (the PDF canvas) plus the droppable zone + field overlays.
|
|||
|
|
4. Add a `docId` prop to PdfViewer: `{ docId: string }` (already exists — no change needed)
|
|||
|
|
5. Keep all existing controls (Prev, Next, Zoom In, Zoom Out, Download) unchanged
|
|||
|
|
|
|||
|
|
Note on PdfViewerWrapper: PdfViewerWrapper.tsx (dynamic import wrapper) does NOT need to change — it already passes `docId` through to PdfViewer.
|
|||
|
|
|
|||
|
|
Note on `page.view`: For react-pdf v10 (installed), the `Page.onLoadSuccess` callback receives a `page` object where `page.view` is `[x1, y1, x2, y2]`. For standard US Letter PDFs this is `[0, 0, 612, 792]`. The `Math.max` pattern handles non-standard mediaBox ordering.
|
|||
|
|
|
|||
|
|
Do NOT use both `width` and `scale` props on `<Page>` — use only `scale`. Using both causes double scaling.
|
|||
|
|
|
|||
|
|
The final JSX structure should be:
|
|||
|
|
```tsx
|
|||
|
|
<div className="flex flex-col items-center gap-4">
|
|||
|
|
{/* controls toolbar */}
|
|||
|
|
<div className="flex items-center gap-3 text-sm">
|
|||
|
|
{/* Prev, page counter, Next, Zoom In, Zoom Out, Download — keep existing */}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* PDF + field overlay */}
|
|||
|
|
<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber}>
|
|||
|
|
<Document
|
|||
|
|
file={`/api/documents/${docId}/file`}
|
|||
|
|
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
|||
|
|
className="shadow-lg"
|
|||
|
|
>
|
|||
|
|
<Page
|
|||
|
|
pageNumber={pageNumber}
|
|||
|
|
scale={scale}
|
|||
|
|
onLoadSuccess={(page) => {
|
|||
|
|
setPageInfo({
|
|||
|
|
originalWidth: Math.max(page.view[0], page.view[2]),
|
|||
|
|
originalHeight: Math.max(page.view[1], page.view[3]),
|
|||
|
|
width: page.width,
|
|||
|
|
height: page.height,
|
|||
|
|
scale: page.scale,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</Document>
|
|||
|
|
</FieldPlacer>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
After modifying PdfViewer.tsx, run build to confirm no TypeScript errors:
|
|||
|
|
```bash
|
|||
|
|
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -15
|
|||
|
|
```
|
|||
|
|
</action>
|
|||
|
|
<verify>
|
|||
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10</automated>
|
|||
|
|
</verify>
|
|||
|
|
<done>
|
|||
|
|
- PdfViewer.tsx has pageInfo state with Math.max mediaBox pattern on onLoadSuccess
|
|||
|
|
- FieldPlacer is imported and wraps the Document/Page tree
|
|||
|
|
- Only scale prop used on Page (not both width + scale)
|
|||
|
|
- npm run build compiles without TypeScript errors
|
|||
|
|
- PdfViewerWrapper.tsx unchanged
|
|||
|
|
</done>
|
|||
|
|
</task>
|
|||
|
|
|
|||
|
|
</tasks>
|
|||
|
|
|
|||
|
|
<verification>
|
|||
|
|
After both tasks complete:
|
|||
|
|
1. `npm run build` in teressa-copeland-homes — clean compile
|
|||
|
|
2. Run `npm run dev` and navigate to any document detail page
|
|||
|
|
3. A "Signature Field" draggable token appears in a palette area above or beside the PDF
|
|||
|
|
4. Drag the token onto the PDF page — a blue rectangle appears at the drop location
|
|||
|
|
5. Refresh the page — the blue rectangle is still there (persisted to DB)
|
|||
|
|
6. Click the × button on a placed field — it disappears from the overlay and from DB
|
|||
|
|
7. Navigate to page 2 of a multi-page document — fields placed on page 1 don't appear on page 2
|
|||
|
|
</verification>
|
|||
|
|
|
|||
|
|
<success_criteria>
|
|||
|
|
- Agent can drag a Signature Field token onto any PDF page and see a blue rectangle overlay at the correct position
|
|||
|
|
- Coordinates stored in PDF user space (Y-flip applied) — placing a field at visual top of page stores high pdfY value
|
|||
|
|
- Fields persist across page reload (PUT /api/documents/[id]/fields called on every change)
|
|||
|
|
- Fields are page-scoped (field.page === currentPage filter applied)
|
|||
|
|
- npm run build is clean
|
|||
|
|
</success_criteria>
|
|||
|
|
|
|||
|
|
<output>
|
|||
|
|
After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-02-SUMMARY.md`
|
|||
|
|
</output>
|