Skip to content

"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.)

  1. 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.
  2. 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.
  3. Logical reading order and tab order are preserved or improved — never scrambled.
  4. Any /TU tooltip, alt text, or structure tag already present is preserved unless we are demonstrably improving it (and then it must be recorded as a change).
  5. 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 of FieldSpec-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.py fills 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 FormSpec JSON as the single source of truth for the conversation. Each turn sends system + current FormSpec + latest user message and the model returns updated 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.py with 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() in workers/api/src/services/cost-ledger.ts (not an ad-hoc write). Add a new 'form-interview' value to the COST_OPERATION_TYPES closed union and the matching DB CHECK constraint (new Supabase migration, following the lineage of 240/257 which added prior op types). Pricing comes from config/inference-rates.json via estimateScanCost / the ledger’s computeCosts().
  • 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-FormSpec state 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.ts is the SSE/streaming pattern to model POST /design on. Persist session + per-turn rows (new interview_sessions / interview_turns tables) 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 /TU that reads like its visible label, derived from the nearest label text by geometry (left-of or above, same visual group). Field name /T stays machine-shaped; /TU is 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 /Tabs to /S and 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 /T with 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: 1px slate-300 border, 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 /N appearance XObject via pikepdf — NOT via PyMuPDF fitz.Widget. place.py sets appearance through fitz.Widget (border width/color, fill) which cannot express corner radius, padding, or a font choice at all; /MK//BS can’t either — corner radius and padding exist only when drawn as vector content inside the /N stream. 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 … EMC marked-content block and only the variable text inside it — Acrobat regenerates only what’s inside the marker. Ship NeedAppearances=false with a /DA that matches the baked font + 16px size, and put the font in both /DR and the XObject’s own /Resources. Keep /MK BC/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. /D and /R are 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 on input:focus. Scope this op to the resting state + the pdf.js-CSS ring; don’t author /D//R and claim cross-viewer focus styling.
  • contrast_hardening — reuse the existing recolor op’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 medium slate-700, input value 16px regular slate-900, helper 13px slate-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: an enhance stage 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 docx2acroform service — a turn-based POST /design (or equivalent) that takes {form_spec, user_message} and returns {form_spec, assistant_message, ready, cost}. The client holds the canonical FormSpec between turns; the server is stateless per turn beyond ledgering cost. When the user is done, the approved FormSpec goes 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 FormSpec on 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 tooltips is 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_invariant runs 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 /AP regeneration — 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 BMC block survives the edit.
  • Preview-corruption guard. macOS Preview writes field values without updating /AP and without setting NeedAppearances, 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, /AP stale, NeedAppearances absent) 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 approved FormSpec (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.

  1. verify_enhance_invariant (field-inventory diff + text diff) + fixture corpus + revert-to-original plumbing. Safety net first — nothing else lands until this is green.
  2. 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.
  3. Mode C — generative-from-prompt on top of Mode A’s emitter: structured-output interview producing a FormSpec, canonical-state cost design, POST /design endpoint, split builder UI with direct-edit + live preview, cost caps/ledgering.
  4. Mode B semantic ops (tooltips, tab_order, radio_groups) — highest accessibility value, lowest visual risk, on the inherited-layout path.
  5. Mode B appearance ops (field_chrome, focus_appearance, contrast_hardening) reusing the Mode A implementations; verify across Acrobat/pdf.js/Preview (authored /AP).
  6. Mode B geometry ops (field_sizing, alignment_snap) with collision detection.
  7. 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.