"Enhance This Form" — build prompt
Paste the block below into Claude Code at the repo root.
THE PROMPT
We ship two form products:
services/docx2acroform— Word form → fillable AcroForm PDF (Flask,POST /convert, returns{exit_code, report, pdf_b64, stderr}). We generate the layout here.services/flyer-tagger— in-place PDF remediation via an edit-op engine (formfields.py,structedit.py,altedit.py,recolor.py, pikepdf, positional identity, veraPDF-verified). We do NOT own the layout here — we inherit it.
Build an Enhance feature (“Make it beautiful”) that takes a correct-but-ugly form and returns a form that looks professionally designed — without changing a single word of visible text and without adding, removing, renaming, or reordering a single form field.
The invariant (non-negotiable)
For the two enhance modes (A and B below), an enhanced form must be semantically identical to its input. (Mode C is generative — it has no input form; its invariant is stated in its own section.)
- Every field in the input exists in the output with the same
/T(field name), same field type, same/V, same export values, and same radio grouping. - Every glyph of visible text present in the input is present in the output, with the same string content. Fonts, sizes, colors, and positions may change. Words may not.
- Logical reading order and tab order are preserved or improved — never scrambled.
- Any
/TUtooltip, alt text, or structure tag already present is preserved unless we are demonstrably improving it (and then it must be recorded as a change). - Enhance is always reversible: the pre-enhance PDF is retained and a one-click “Revert to original” restores it byte-for-byte.
Write the invariant as an executable check — verify_enhance_invariant(before_pdf, after_pdf) — that diffs field inventory and extracted text and fails loudly. Run it in
CI over the full fixture corpus. If the invariant can’t be verified, the enhance is
rejected and the original is returned; never ship a “best effort” mangled PDF.
Both halves of this check are net-new — flyer_tagger/verify.py already has
pixel_diff, walk_structure, orphaned_mcids, qpdf_check, and verapdf_check, but
no field-inventory diff and no text-extraction diff. Build them alongside those existing
gates and in the same style (the field-inventory walk should key fields by /T + type, not
object number, so it survives a pikepdf save-renumber — the same positional-identity
discipline the edit-ops already follow).
pixel_diff is NOT the gate for Mode B. The existing verify.py pixel-fidelity
check guards the in-place tagging pipeline, whose guarantee is “appearance streams are
never touched.” Mode B appearance and geometry ops change pixels by design, so
pixel_diff will fail on them and must not gate them. The field-inventory diff and the
extracted-text diff are the sole safety net for Mode B — build them first and make them
airtight. pixel_diff still applies to semantic-only ops (tooltips, tab order,
structure) that touch no visible pixels.
Three modes — do not conflate them
Mode A — Generative enhance (docx2acroform). We own the layout, so we can do real
design. Introduce a layout/theme stage between field extraction and PDF emission. This is
where a form becomes genuinely great, and it is the lead mode: it carries the least
fidelity risk (no inherited page, no /AP regeneration fights beyond what we author) and
the biggest visual payoff. Build it before Mode B.
Mode B — In-place enhance (flyer-tagger). We inherit an arbitrary page. Re-flowing
it would destroy it. Restrict to safe, local operations that never move visible text:
field chrome, widget geometry nudges within a tolerance, alignment snapping, color, focus
appearance, tooltips, tab order, and structure. Anything requiring text reflow is
out of scope for Mode B — surface it as a suggestion (“this form would benefit from a
full rebuild from source”) rather than attempting it.
Implement Mode B as new edit-ops in the existing engine, following the established edit-op architecture and positional-identity conventions. Do not fork a parallel pipeline.
Mode C — Generative from prompt (new stage on top of Mode A). A customer describes the
form they want in natural language; we interview them conversationally to fill the gaps and
produce a beautiful, accessible PDF form from scratch. This is the cleanest mode
architecturally, because the emitter already consumes a well-defined structured spec —
FieldSpec in tools/docx2acroform/docx2acroform/extract.py (field_type, name,
tooltip, default, options, checked, required, date_format, editable, …).
So the design is a hard split:
- The AI’s only job is to produce a validated
FormSpec— a list ofFieldSpec-shaped objects — via structured output (a tool/JSON schema). It never generates a PDF, never touches layout, never emits prose to be parsed. Placement fields (page,rect,placed) are NOT the model’s job —place.pyfills them. - The deterministic Mode A pipeline renders the spec — layout, theme, contrast hardening, structure tags, PDF/UA-1 gate — at $0 AI cost. Every accessibility and contrast guarantee Mode A already enforces applies unchanged.
Mode C’s invariant: the output form’s field inventory (/T, type, /V, export values,
radio grouping) must equal the FormSpec the user approved — nothing silently added,
dropped, renamed, or reordered by the emitter. “Reversibility” here is different from Modes
A/B: the FormSpec is the durable source of truth, the PDF is a render of it. The user
can always return to the spec, edit it, and re-emit; persist the spec alongside every
generated PDF.
Cost design (Mode C) — non-negotiable, because the interview is where cost hides
Users will take many turns. Naively resending a growing chat transcript to an expensive model is the failure mode. Do NOT do that. Instead:
- Canonical state, not a transcript. Keep the
FormSpecJSON as the single source of truth for the conversation. Each turn sendssystem + current FormSpec + latest user messageand the model returnsupdated FormSpec + next question(or “ready”). Per-turn token cost stays flat regardless of turn count — it does not accumulate. - Cheap model by default. Route the interview to Haiku (or Gemini Flash), per the family’s “high-volume low-stakes → cheap model” rule. Escalate to Sonnet only when a request is genuinely ambiguous, and record that escalation in the change report.
- Prompt caching on the system prompt + few-shot spec examples; only the per-turn delta is uncached.
- Direct manipulation cuts turns hardest. The builder UI must let the user add, rename,
reorder, retype, and delete fields by hand on the live
FormSpec. Use the AI for “describe what you want” and the follow-up interview; use clicks for tweaks. Most edits should cost $0 because they never call the model. - Hard caps + cost tracking. Enforce max turns per session, max fields, and a per-session
token budget; rate-limit. Record every inference call in
cost_tracking/devcost.pywith op type, tokens, model, and estimated cost. When a cap is hit, degrade to direct-edit-only, don’t silently keep spending. - Keep the model on rails. The interview model is bound to a single tool that can only
mutate the
FormSpec. No free-form generation — this both bounds cost and stops the feature from being used as a general chatbot.
Reuse the existing cost/AI infra — do not reinvent it:
- Record each turn via the canonical
recordCost()inworkers/api/src/services/cost-ledger.ts(not an ad-hoc write). Add a new'form-interview'value to theCOST_OPERATION_TYPESclosed union and the matching DBCHECKconstraint (new Supabase migration, following the lineage of 240/257 which added prior op types). Pricing comes fromconfig/inference-rates.jsonviaestimateScanCost/ the ledger’scomputeCosts(). - Gate every turn with
checkUsageAffordable()(workers/api/src/services/credits.ts) — the same pre-flight pattern the forms route already uses — so budget/turn caps fail closed. - For conversation-size safety, reuse the message-budget trimming already written in
services/opus-harness(preserve the cached prefix + last N turns, trim the middle). With canonical-FormSpecstate you shouldn’t need it often, but it’s the backstop. - Model routing follows the existing
FORM_VISION_MODEL-style constant + Bedrock Haiku default (us.anthropic.claude-haiku-4-5-...); escalation to Sonnet is user-gated and ledgered. - There is no existing multi-turn endpoint to copy, but
workers/api/src/routes/convert-stream.tsis the SSE/streaming pattern to modelPOST /designon. Persist session + per-turn rows (newinterview_sessions/interview_turnstables) so cost and the evolving spec are durable.
What “enhance” actually does
Model it as an ordered list of named, individually-togglable enhancement ops, each one auditable and each one reporting what it changed. Roughly in order of value:
Structure & semantics (both modes)
tooltips— every widget gets a/TUthat reads like its visible label, derived from the nearest label text by geometry (left-of or above, same visual group). Field name/Tstays machine-shaped;/TUis the human sentence. Append “(required)” when the field is marked required. Never invent a label where none can be found with confidence — flag it for human review instead.tab_order— set every page’s/Tabsto/Sand order widgets in the structure tree by visual reading order (column-aware, top-to-bottom within a column).radio_groups— verify radios that are visually one question share a/Twith distinct export values; report (do not silently rewrite) any that don’t.required_in_text— required-ness must be conveyed in the tooltip text, not by color or a bare asterisk alone.
Appearance (both modes, geometry-safe)
field_chrome— replace default field appearance with the design system: 1pxslate-300border, 6px corner radius, quiet fill, 12px/14px internal padding, 16px input text. No bevels, no inset 3D borders, no drop shadows. Author this as a baked/AP /Nappearance XObject via pikepdf — NOT via PyMuPDFfitz.Widget.place.pysets appearance throughfitz.Widget(border width/color, fill) which cannot express corner radius, padding, or a font choice at all;/MK//BScan’t either — corner radius and padding exist only when drawn as vector content inside the/Nstream. Do the chrome in the same pikepdf appearance-authoring layer flyer-tagger already uses. Durability recipe (required, or Acrobat destroys it on the first edit): put the decorative chrome (border, radius, fill, padding frame) outside the/Tx BMC … EMCmarked-content block and only the variable text inside it — Acrobat regenerates only what’s inside the marker. ShipNeedAppearances=falsewith a/DAthat matches the baked font + 16px size, and put the font in both/DRand the XObject’s own/Resources. Keep/MKBC/BG approximately consistent with the baked look so any forced regeneration degrades to a plain rectangle rather than flipping colors.focus_appearance— a portable focus ring is NOT expressible in the PDF appearance model, so do not promise one everywhere./Dand/Rare transient mouse states, not keyboard focus: pdf.js renders fields as HTML inputs whose focus outline is browser CSS, Acrobat draws its own system highlight and ignores author focus appearances for text fields, and Preview honors neither. So: bake the resting chrome beautifully (that is the real highest-leverage change), accept each viewer’s native focus indicator, and only where we control the viewer (the pdf.js annotation layer in our own preview UI) add a 2px accent ring via CSS oninput:focus. Scope this op to the resting state + the pdf.js-CSS ring; don’t author/D//Rand claim cross-viewer focus styling.contrast_hardening— reuse the existingrecolorop’s logic. Every text/background pair ≥ 4.5:1 (3:1 large), every field border ≥ 3:1 non-text contrast. Contrast overrides every aesthetic rule in this spec — if a themed color fails AA, pick a different shade in the same hue or fall back to a neutral.field_sizing— widget height normalized to a single value (min 44px touch target); width snapped to a small set of semantic widths keyed off expected content (zip, state, phone, name, address, multiline). Only resize within a tolerance that cannot cause the widget to collide with or occlude existing visible text — collision detection required.alignment_snap— snap widget left edges and baselines to a shared grid so a column of fields is actually a column. Bounded nudge only (default ≤ 6pt, configurable); anything larger is reported, not applied.
Layout (Mode A only — requires owning the layout)
spacing_rhythm— 8px base unit. Label→input 8, input→helper 4, field→field 24, group→group 40. The gap between groups must be visibly larger than the gap within a group; this proximity hierarchy is what makes a form read as designed.label_placement— labels above or left of their input, never inside as placeholder.single_column— one column top-to-bottom, except genuinely paired data (City / State / ZIP). Measure-limited column width.typographic_scale— three distinct roles: label 14px mediumslate-700, input value 16px regularslate-900, helper 13pxslate-500.section_headers— promote detected group boundaries to real headings with structure tags, so the form is navigable by heading in a screen reader as well as scannable by eye.
Theme
One accent color, used almost nowhere — focus ring, section rules, and nothing else.
Everything else neutral grayscale. Default accent is Gevell blue #003da5. Themes are
data (a JSON theme file), not hardcoded values, so a customer can supply their own brand
color — and the theme loader must run the contrast check and reject or auto-darken a
supplied color that can’t meet AA rather than shipping an inaccessible form.
API and UI
- Both enhance services get an enhance entry point.
docx2acroform: anenhancestage in the pipeline plus a?enhance=true|false/ header switch on/convert, defaulting to the current behavior until we’ve shipped confidence.flyer-tagger: a new edit-op set invocable individually and as a bundle. - Mode C gets a conversational endpoint on the
docx2acroformservice — a turn-basedPOST /design(or equivalent) that takes{form_spec, user_message}and returns{form_spec, assistant_message, ready, cost}. The client holds the canonicalFormSpecbetween turns; the server is stateless per turn beyond ledgering cost. When the user is done, the approvedFormSpecgoes straight into the Mode A emit path — no separate codepath. Enforce the turn/token/field caps server-side, not just in the UI. - Every enhance returns a change report: ops applied, per-op count of changes, per-field before/after for anything visual, invariant-check result, and any flags raised for human review. Persist it — this is what the user’s diff view renders.
- The enhance UI (Modes A/B) is a single primary button (“Enhance”) with a side-by-side before/after page preview and per-op toggles behind a “Customize” disclosure. Enhancing is a >2s operation, so it needs a real progress state. After enhancing, offer “Revert to original” prominently. Follow the project design system for the button and panel.
- The Mode C UI is a split builder: a conversational panel on one side (describe the
form, answer follow-up questions) and a live, directly-editable
FormSpecon the other (add/rename/reorder/retype/delete fields by hand), with a live rendered preview of the beautiful output. Direct edits must not call the model. Show a running per-session cost/turn indicator so the user (and we) can see spend accrue. When a cap is hit, keep the builder fully usable in direct-edit mode. - Record cost for any inference used (label inference in
tooltipsis the likely consumer). Prefer deterministic geometry over a model call — only escalate to inference when geometric label matching is ambiguous, and say so in the report.
Testing
- Golden fixtures: a corpus of ugly-but-valid forms, each with a committed expected change report. Add fixtures from both real Word conversions and real scanned/exported PDFs.
verify_enhance_invariantruns on every fixture, both modes.- veraPDF PDF/UA-1 conformance must not regress on any fixture — an enhance that improves aesthetics while breaking conformance is a failed enhance.
- Contrast assertions on every themed color pair, computed not eyeballed.
- Keyboard-only completion test and tab-order assertion per fixture.
- Render before/after to PNG for at least three fixtures and attach them to the PR so the
visual change is reviewable by a human. The test harness renders via PyMuPDF, which will
not reproduce Acrobat’s
/APregeneration — for any op that authors field chrome, additionally render the same fixtures in pdf.js (forms-enabled and flattened), a pdfium/mutool/Acrobat proxy, and macOS Preview, because that is where authored appearance streams actually break. - Render each chrome fixture at rest AND after a scripted field edit. Acrobat and Preview
only diverge from the at-rest render once regeneration is triggered, so an at-rest-only PNG
hides the exact failure — assert the border/radius/padding drawn outside the
/Tx BMCblock survives the edit. - Preview-corruption guard. macOS Preview writes field values without updating
/APand without settingNeedAppearances, leaving blank fields in compliant viewers plus a ghost layer. Add a check that rejects any user-supplied input PDF exhibiting that signature (value present,/APstale,NeedAppearancesabsent) rather than enhancing a Preview-mangled file. - Mode C: a suite of prompt→spec fixtures (a natural-language request + the expected
FormSpec) asserting the model produces the right field set, types, and required flags; an assertion that the emitted PDF’s field inventory equals the approvedFormSpec(Mode C invariant); and a per-turn token-budget assertion proving conversation cost stays flat as turns grow (i.e. canonical-state, not transcript-growth). Run the interview against the cheap model in CI.
Build order
Ship in this sequence, each independently mergeable. Mode A leads — it owns the layout, carries the least fidelity risk, and delivers the biggest visual payoff; Mode B (which fights the AcroForm widget model) and Mode C (which builds on Mode A’s emitter) come after.
verify_enhance_invariant(field-inventory diff + text diff) + fixture corpus + revert-to-original plumbing. Safety net first — nothing else lands until this is green.- Mode A layout/theme ops in
docx2acroform(spacing_rhythm,label_placement,single_column,typographic_scale,section_headers) plus the shared appearance ops (field_chrome,focus_appearance,contrast_hardening) — the genuinely-great path. - Mode C — generative-from-prompt on top of Mode A’s emitter: structured-output
interview producing a
FormSpec, canonical-state cost design,POST /designendpoint, split builder UI with direct-edit + live preview, cost caps/ledgering. - Mode B semantic ops (
tooltips,tab_order,radio_groups) — highest accessibility value, lowest visual risk, on the inherited-layout path. - Mode B appearance ops (
field_chrome,focus_appearance,contrast_hardening) reusing the Mode A implementations; verify across Acrobat/pdf.js/Preview (authored/AP). - Mode B geometry ops (
field_sizing,alignment_snap) with collision detection. - UI polish: enhance button, before/after preview, per-op toggles, change report view.
Start by reading the existing edit-op implementations in services/flyer-tagger/ and the
emit pipeline in tools/docx2acroform/ to match their conventions exactly, then show me the
plan for step 1 before writing code.