Skip to content

0.93 → 0.94 — richtext(inline) type token retires; use inline: true

Single-line richtext shape is still a schema validation concern, not a UI hint. Only the Quill.yaml spelling changes: type: richtext(inline) is a hard load error; declare inline: true on a richtext field instead.

Quill.yaml

0.93 (retired)                    0.94
subject:                          subject:
  type: richtext(inline)            type: richtext
  example: Memo subject             inline: true
                                   example: Memo subject

refs:                             refs:
  type: array                       type: array
  items:                            items:
    type: richtext(inline)            type: richtext
                                     inline: true
  • type: richtext(inline)quill::field_parse_error with a hint to use inline: true.
  • inline: true on a non-richtext field → quill::field_parse_error.
  • inline: false may be omitted (block richtext is the default).

Shape enforcement is unchanged: coercion, validation (richtext::not_inline), and load-time example: / default: import still require exactly one Para line with no container and no islands.

Projections (unchanged spelling)

Surface Inline field annotation
Blueprint # richtext(inline)<markdown> (derived from inline: true)
QuillConfig::schema() type: richtext + inline: true
build_transform_schema() contentMediaType: application/quillmark-richtext+json + quillmark:inline: true

Documents and corpus wire shapes are unaffected — only quill schema authoring changes.

Migrating a field to richtext: strcontent, and the plaintext helper

Converting an existing string/array field to richtext is not transparent to Typst plates. A string/array field reaches the plate as a Typst str; a richtext field reaches it as Typst content (its corpus lowers to a markup block). So any plate or vendored package that does a string operation on the value — str(value), value.trim(), value.starts-with(..), upper(value), or an assert(type(item) == str) guard (e.g. a create-auto-grid-style helper) — breaks at render time, not load time, with no schema-level or compile-time signal. Audit every plate/package consumer of a field before switching it to richtext.

Where the consumer genuinely needs a string, use the new plaintext(field) helper instead of forcing the field back to string:

#import "@local/quillmark-helper:0.1.0": data, plaintext

// data.subject is `content` — renders directly:
#data.subject

// plaintext("subject") is the field's `str` — the corpus text with island
// slots (tables/images) stripped and all formatting marks dropped:
#upper(plaintext("subject"))
#assert(plaintext("subject").starts-with("RE:"))

field is the schema address — a field name ("subject"), an array element ("refs.2"), or a card path composed from the card's $path prefix (plaintext(card.at("$path") + "$body")). It returns "" for a blank field or an address with no richtext content. This is the sanctioned richtext→str coercion (the same projection the pdfform backend lowers a richtext field to for a form-field value); do not repr/stringify the content block.

richtext(inline) fields no longer emit a parbreak warning

An inline: true field now lowers to pure inline content (no trailing paragraph break), so nesting it in an inline slot — par(..), a signature line, a grid cell, measure(..) — no longer triggers Typst's non-fatal "parbreak may not occur inside of a paragraph" warning. No plate change is needed; block (inline omitted) richtext is unaffected.

New field types: plaintext and enum (#938)

Both are additive — existing quills load unchanged. They complete the text-type 2×2: enum (closed data), string (open data), plaintext (plain content), richtext (formatted content).

plaintext

A content type over the same RichText corpus as richtext — same contentMediaType, so it lowers through the identical backend path and inherits preview↔editor navigation and per-field regions — but authored and projected through a literal codec, not markdown: *hi* is four literal characters, verbatim both ways, idempotent. Declared like richtext, inline: included:

subject:
  type: plaintext
  inline: true
  example: "RE: *not* italic — the asterisks are literal"

body:
  type: plaintext          # multi-paragraph plain prose

A lone \n is a within-paragraph break; \n\n is a paragraph boundary. The value carries no marks, islands, or block formatting; a wire corpus that does is rejected (plaintext::not_plain), not stripped — the inline precedent. The transform schema carries the richtext media type plus quillmark:plain: true (an editor hint backends ignore).

Distinct from the plaintext(field) Typst helper above: the helper projects any corpus to a str; the type declares a field plain at the source. A plaintext field still reaches the plate as content, so the str-consumer caveat above applies — use plaintext(field) where a plate needs the str.

enum

Promoted from a string modifier to a type token: type: enum + a required values: list.

0.93 (deprecated modifier)        0.94
color:                            color:
  type: string                      type: enum
  enum: [red, green, blue]          values: [red, green, blue]

It projects to JSON-Schema {type: "string", enum: [...]} — what backends already dispatch on — so no backend or plate change. The enum: modifier on type: string still works this release (same store, same validation) but is deprecated. Two edges tighten:

  • enum:/values: on any type other than string/enum is now a load error, not a silent no-op (the old modifier validated nothing on, e.g., an integer).
  • type: enum requires a non-empty values:; values: on a string is a load error.

v1 members are string-valued — an enum is a branching key; numeric domains are number range constraints, not enums.

string keeps its spelling but narrows to open scalar data: URLs, paths, identifiers, keys — values a template computes with (#link / #image, comparison, keying), not prose it lays out. Use plaintext for prose the author navigates, enum for a closed set.

Group registry: ui.groups, validated ui.group references (#941)

Groups gain a card-level registry. A group is no longer just the set of fields sharing a byte-identical ui.group string (where the display label doubled as identity and a typo silently split a section). Each card declares ui.groups — an ordered list of snake_case ids — and a field's ui.group becomes a reference into it.

0.93 (label is identity)          0.94 (id is identity, label derived)
main:                             main:
  fields:                           ui:
    memo_for:                         groups: [addressing, letterhead]
      ui: { group: Addressing }     fields:
    subject:                          memo_for:
      ui: { group: Addressing }         ui: { group: addressing }
                                      subject:
                                        ui: { group: addressing }
  • Registry keys are snake_case ids; declaration order is display order — the same contract fields follow (declaration order is field display order). Consumers derive the label from the id (addressing → "Addressing"), exactly as a field label is derived from its key; override it with the mapping form, letterhead: { title: "Letterhead & Seal" }. Renaming a label touches one line and never breaks a reference or persisted per-group editor state.
  • The registry has two interchangeable forms: a bare sequence of ids ([addressing, letterhead]) when no label needs overriding, or a mapping of id → attributes when one does. Both fold to the same ordered registry and re-emit as the mapping form in QuillConfig::schema().
  • ui.group is validated: a value with no matching registry key is quill::unknown_group; a registry id that is not snake_case is quill::invalid_group_id; a repeated id is quill::duplicate_group. Because a registry is authoritative when present, there is no implicit fallback to mix with — an undeclared reference simply fails.
  • Implicit groups are deprecated, not yet removed. A ui.group with no ui.groups registry keeps today's exact semantics — the value is the label and identity, groups order by first appearance — and now emits a quill::implicit_group warning. It becomes an error in a future release. Migrate by adding a ui.groups registry and lowercasing each ui.group to its id. (No slug is invented during the deprecation window: implicit identity stays the literal label, so nothing a consumer might persist changes shape before you choose explicit ids.)
  • The blueprint is unchanged in shape — it still clusters by group and emits no banner; only its group-ordering input switches from first-appearance to registry order, which for a migrated quill is identical.

The bundled quills (usaf_memo, cmu_letter, classic_resume) are migrated to the registry form as worked examples.

ui.group is card-level only; nested properties keep authored order (#941)

Two independent schema-authoring fixes to the ui.* block, landed ahead of the registry above. Both touch only Quill.yaml; documents, wire shapes, and the blueprint grammar are unchanged.

  • ui.group in a nested position is now a load error. Grouping clusters a card's top-level fields; the blueprint's grouping pass never descended into an object's properties or an array's items, so a group there was silently inert. It now fails with quill::nested_group_not_supported. A group on a card-level field (under main.fields or a card kind's fields) is unaffected — that is where grouping has always applied.
0.93 (silently ignored)           0.94 (load error)
address:                          address:
  type: object                      type: object
  properties:                       properties:
    street:                           street:
      type: string                      type: string
      ui: { group: Location }         # move the group up to the card-level
                                      # field, or drop it
  • Typed-dictionary and typed-table-row properties now render in declaration order. Nested properties previously lived in a BTreeMap and rendered alphabetically, discarding authored order one level down. Properties now preserve declaration order, the same rule top-level fields follow (see the ui.order removal below — declaration order is the one ordering contract at every depth). Blueprints and forms for a quill whose nested properties were authored out of alphabetical order will reorder to match the Quill.yaml.

ui.order retires; declaration order is the ordering contract (#941)

Field display order stops being a ui knob and becomes purely structural: the order fields (and nested properties) are declared in Quill.yaml is their display order, carried by the schema's ordered field maps through parsing, schema() emission, the blueprint, and seeding. There is no derived integer to read or author.

0.93 (stamped + overridable)      0.94 (declaration order)
main:                             main:
  fields:                           fields:
    title:                            # to move a field, move its block
      type: string                    special_field:
      ui: { order: 5 }                  type: string
  • ui.order is removed. An authored ui: { order: N } on any field — card-level or nested — is a load error (quill::field_parse_error) with a hint to reorder the fields in Quill.yaml. Reorder by moving the field's block.
  • The emitted schema no longer carries order:. QuillConfig::schema() (the surface editors and MCP/LLM consumers read) previously stamped an auto-generated ui.order integer onto every field; that key is gone. Read key order of the fields / properties objects instead — it is declaration order, preserved on the wire. A consumer that sorted by ui.order should walk the map in key order and drop the sort.
  • Card kinds emit in declaration order tooschema().card_kinds was alphabetized (a BTreeMap) and now preserves the Quill.yaml order.
  • A field with no authored ui: now has no ui block. Order stamping used to fabricate one (ui: { order: N }) even when the author wrote none; a field with only structural metadata now serializes without a ui key.

The addressed richtext write surface + corpus codec (#925)

The richtext write surface was a grid: every verb stamped out once per address (main / card-at-index), and the fix window for it was still open (nearly the whole write surface is unreleased). #925 collapses the grid into one codec layer plus a handful of addressed verbs, and — the field gap that motivated it — gives richtext fields the anchor-preserving diff_import writer they lacked. All of this is pre-release reshaping: only three released names churn (the deprecations below); everything else is added or retired outright.

Layer 1 — the document-free corpus codec

Four pure functions (no document, no handle) — wasm free functions / Python module functions:

Codec wasm Python
markdown → corpus importMarkdown(md): RichText import_markdown(md) -> dict
corpus → markdown exportMarkdown(rt): string export_markdown(rt) -> str
cold-import + diff rebase(base, md): { corpus, delta } rebase(base, md) -> dict
map a position mapPos(delta, pos, assoc): number map_pos(delta, pos, assoc) -> int

exportMarkdown(body) replaces the eager bodyMarkdown / fieldMarkdown / cardFieldMarkdown projections — dropped from the Card DTO and the always- emitted CardWire.body_markdown before either shipped. The precompute is no longer cheap (the delimiter-safety fix makes to_markdown re-parse every rendered line), so it is on-demand now. Delta is plain, structured-clone-able data ({ops: [{retain}|{insert}|{delete}]}) that stores in a change record and maps positions through with mapPos.

Layer 2 — the addressed content verbs

An address locates a richtext value: { card?, field? } in wasm (absent field = body, absent card = main); the same axis as kwargs in Python (card=, field=).

Verb wasm Python Semantics
install doc.install(addr, rt) doc.install(rt, card=, field=) value: store exactly this corpus (anchors of the old value gone)
revise doc.revise(addr, md) → Delta doc.revise(md, card=, field=) -> dict edit: diff_import, anchors rebase, returns the delta receipt
applyChange doc.applyChange(addr, bundle) doc.apply_change(bundle, card=, field=) editor splice: { delta?, lineOps?, markOps? }

(An earlier draft added a fourth addressed verb, commit(addr, value, quill), for typed field writes. #932 deleted it pre-release: typed writes are the typed writer's job, not the corpus lane's, and the addressed commit only duplicated commitField / commitCardField behind an Addr. The corpus lane is content-only — install / revise / applyChange, no schema in sight.)

The cold (anchor-losing) path is spelled at the call site — install(addr, importMarkdown(md)) — so anchor loss is visible in source; revise is the default for "here's new markdown." revise stays a fused verb (not rebase + install) for atomicity against interleaved mutation.

Retired pre-release (the grid the addressed verbs replace — no deprecation):

Retired Use instead
wasm setBody / setCardBody (corpus writes) install(addr, rt)
wasm replaceCardBody (markdown card body) revise({ card: i }, md)
wasm fieldMarkdown / cardFieldMarkdown exportMarkdown(fieldCorpus)
wasm Card.bodyMarkdown / CardWire.body_markdown exportMarkdown(card.body)
Python set_body / set_card_body install(rt, card=, field=)
Python replace_card_body revise(md, card=i)
Python field_markdown / card_field_markdown / body_markdown export_markdown(corpus)

Core mirrors the vocabulary, not the addressing (wasm-bindgen cannot export &mut Card, pyo3 classes carry no lifetimes, so only the bindings need the runtime Addr): Card::install_body(rt) / revise_body(md) -> Delta replace the set_body_corpus / replace_body / import_body_delta trio, and the field twins Card::install_field(name, rt) / revise_field(name, md) -> Delta (new — the field-level diff_import) close the gap where a richtext field had no anchor-preserving markdown writer. install/revise on a field are schema-blind (the corpus-writer stratum splices without the quill, like apply_field_richtext_change); the typed door that enforces richtext(inline) is the writer's commit_field (reached as writer.set in the bindings — WASM's commitField ABI, Python's direct core call), and a violation on the schema-blind path otherwise surfaces at validate/render.

The three released-API deprecations

Only these three shipped at v0.92.1, so only these get a one-cycle alias:

Deprecated (aliased for one cycle) Alias for
wasm replaceBody(md) revise({}, md) (delta discarded)
Python replace_body(md) revise(md) (delta discarded)
Python update_card_body(index, md) revise(md, card=index)

Typed field writes: commit_field and the schema-bound writer (#893)

The type a write commits belongs in the schema, not the method name — so there is one typed writer per address that dispatches on the field's FieldSchema and never grows a per-type sibling. An earlier iteration of this cycle shipped a richtext-specific writer (set_field_richtext / wasm setRichtextField / updateCardRichtextField); those were removed pre-release and folded into the one typed writer below.

Removed (pre-release) Use instead
Card::set_field_richtext(name, &json, inline) Card::commit_field(name, value, &field_schema)
wasm setRichtextField(name, value, inline?) wasm commitField(quill, name, value)
wasm updateCardRichtextField(index, name, value, inline?) wasm commitCardField(quill, index, name, value)
— (Python had no richtext field writer) Python quill.writer(doc).set(name, value) / .card(index).set(name, value) (the standalone Document.commit_field / commit_card_field this section first added were deleted by #932 — see below)

The corpus read twin is Card::field_richtext(name); the markdown projection is the on-demand exportMarkdown(fieldCorpus) codec (the eager field_markdown / wasm fieldMarkdown / cardFieldMarkdown projections retired in #925, above). The incremental splice is Card::apply_field_richtext_change(name, ..), exposed at the boundary as applyChange({ field }, bundle).

  • commit_field is not richtext-specific. It commits any field type: a richtext value imports/adopts the corpus (storing the canonical corpus object, so a corpus-only mark like underline and identity ids survive where a markdown projection would drop them), a scalar coerces to its declared type ("3"3), an array/object coerces element-wise. The write is strict — a mismatch the render floor would silently coerce (a bool into an integer, a non-object into an object) fails now with EditError::FieldConform; richtext fields keep the FieldRichtextDecode / FieldRichtextNotInline variants. null passes through (null ≡ absent) and reads back as the empty corpus.
  • The schema carries inline. A richtext FieldSchema with inline: true enforces richtext(inline) at the commit — no write-side flag.
  • The schema-bound writer is the front door. quill.writer(&mut doc) ([TypedWriter]) resolves each field's type itself, so callers issue one verb with no type token: w.set("qty", "3")?, w.card(2)?.set("desc", v)?, w.set_all([..])? (all-or-nothing). set strict-commits a schema field and rejects a name the schema does not declare with EditError::UnknownField — on the typed path an undeclared name is a typo, not a fallback, so it fails at the write rather than landing silently in the opaque store (#918). Opaque storage stays available on purpose through the raw set_field / setField / setCardField verbs. In the bindings the writer is a real object — quill.writer(doc) in WASM and Python alike — that re-borrows the quill + document per call, since wasm-bindgen / pyo3 objects carry no lifetime (#932). The quill owns the schema, so it is the factory on every surface. WASM keeps the per-call commitField verbs as the ABI its writer delegates to; Python's writer calls core directly, so its standalone commit_field was deleted (#932). Both return nothing on success and throw on an undeclared name.
  • set_field is unchanged. The opaque path (store verbatim, coerce at render) stays for keystroke-level state and DB-row batch generators; the split is set_field = coerce at render vs commit_field = canonicalize now, fail now. A richtext field authored as a markdown string in a hand-written .md file still imports at render — no file needs editing.

The wire and the storage DTO are unaffected.

[TypedWriter]: the writer module in quillmark-core.

Card bodies close the corpus write surface (#892, reshaped by #925)

The non-main card body — the one address commitCardField(quill, index, "$body", …) can never reach, since $body's reserved $-prefix fails the field-name check — is written through the addressed verbs like any other: install({ card: i }, rt) (corpus, value semantics) or revise({ card: i }, md) (markdown, edit semantics). A corpus-native editor writes every address — main body, main/card richtext fields, and card bodies — with no per-address method to forget. Card bodies read back as corpus via cards[i].body (markdown via exportMarkdown(cards[i].body)). (The intermediate setCardBody / replaceCardBody writers from an earlier draft of this cycle were retired pre-release into install / revise.)

On-disk identity is markdown-lossy by design

A corpus-valued field projects to a markdown string in emitted card-yaml (Document::to_markdown), keeping the human-authored surface markdown-clean rather than embedding a {text, lines, marks, islands} tree. So identity marks (anchors, island ids) and corpus-only formatting persist across compiles and the storage DTO (to_json/from_json — the lossless carrier), but not across a markdown save-and-reload, which re-imports a fresh corpus. Persistent field anchors (identity marks on corpus field content) are a compile / DTO guarantee, not a card-yaml one.

Card-write verbs are mechanical twins of their main-card names (#895)

Every card-indexed write now reads verb + Card + noun, sharing its verb with the main-card writer it twins — the naming rule is mechanical, no per-name memorization. Three older card writers used update where their main-card twins used set / replace; they are renamed (no deprecation alias — the surface is pre-release):

Renamed To Main-card twin
wasm updateCardField / py update_card_field setCardField / set_card_field setField / set_field
wasm updateCardFields / py update_card_fields setCardFields / set_card_fields setFields / set_fields

Behavior is unchanged — only the names move. setCardField stores opaquely (the card twin of setField). The card body writer this section originally renamed (updateCardBodyreplaceCardBody) was superseded by #925's addressed revise({ card: i }, md); Python keeps update_card_body as a one-cycle deprecated alias for it (it was a released name), while the unreleased replaceCardBody / replace_card_body were retired outright.

The opaque/typed field write-verb set stays small — set (store opaque), commit (typed, per #893), remove — with body/richtext writes handled by the addressed install / revise / applyChange verbs (#925). The already- mechanical twins (setCardExt, removeCardField, commitCardField) are unaffected.

wasm Card read/write split: body: RichText on read, CardInput on write (#917)

The wasm .d.ts Card shape typed body: RichText | string for both directions. A read is always canonical RichText (the runtime normalizes on the way out — never a lazy string), so the union only ever widened the read type: TS callers had to narrow card.body on every access, and JS callers got no signal at all. The read and write shapes are now two types:

Read (Card) Write (CardInput)
body RichText (always corpus) RichText \| string (markdown imports)
non-kind fields present optional (absent → default)
returned by main / cards / removeCard / seedMain / seedCard / makeCard
accepted by pushCard / insertCard
  • Read card.body is now RichText. No narrowing, no provenance guess. For the markdown projection call exportMarkdown(card.body) (the eager bodyMarkdown projection was dropped in #925).
  • pushCard / insertCard accept CardInput. body still takes a markdown string, and — new — every field but kind is optional, so a bare doc.pushCard({ kind: "note", body: "…" }) type-checks (it always ran; the old required-everything Card param just mistyped it).
  • Every Card is a valid CardInput. pushCard(removeCard(0)), pushCard(seedCard("note")), and pushCard(makeCard(…)) all still type-check — a card read from one document pushes straight into another.
  • Breaking for TS only. A variable annotated Card that held a freshly-built { kind, body: "md", payloadItems: [] } for pushCard should be re-annotated CardInput (or left inferred). Runtime behavior, the storage DTO, and the Python binding (dynamically typed; card.body already returns the corpus dict) are unchanged. (bodyMarkdown is no longer a Card field — #925.)

CardInput is not the pre-0.88 flat input DTO (a divergent shape, removed in 0.87 → 0.88): it is structurally Card with body widened and the read-only projections made optional, so the two shapes stay aligned.

Live field edits go through commitField + apply(doc) (#886)

There is no incremental field-delta verb. An earlier unreleased iteration added an experimental applyFieldDelta / mapFieldPos / revision surface — a per-field change log that mapped a captured position forward across edits; #886 removed it before release. Anchoring a caret or selection across edits is the editor's own transaction mapping (a ProseMirror / CodeMirror StepMap), not a parallel core-side position map: positionAt (point → corpus position) and locate (corpus position → caret rect) are exact inverses over the current compile and are the whole bidirectional cursor bridge, needing no forward map.

A live editor writes a field through the typed writer (quill.writer(doc).set(field, value), or the ABI commitField) — or the body with install / revise — and recompiles with apply(doc). Typst recompiles incrementally either way (Source::replace + comemo), so a whole-field write costs the same on the compile as a splice would have — the delta path bought no incrementality it did not already have. CorpusHit / FieldRegion carry no revision.

The two-tier binding surface (#932)

The bindings grew three overlapping write families (the commit* verbs, the addressed content verbs, the opaque set* primitive) with no stated default.

932 does not add a family — it names a default and promotes the verb that

already served it. One sentence decides where every verb lives:

Tier 1 speaks names, values, and markdown. Tier 2 speaks addresses, corpora, and receipts.

A consumer who never needs anchor identity never meets an Addr, a corpus object, or a Delta. A consumer who does crosses one marked door.

Tiers are strata, not a partition

Tier 1 is sugar over tier 2 and the typed-commit path, not a walled-off region: writer.setBody(md) is revise({}, md) with the receipt discarded, and writer.set is commitField with the quill bound once. Anything tier 1 writes, tier 2 can write with more control. So the decision tree picks a default, not a cage — a live editor legitimately writes fields through the typed writer and bodies/splices through the addressed verbs in the same interaction. The decision tree:

  • Have a quill? Use quill.writer(doc) — the documented default. Typed set / set_all, setBody, addCard, removeCard, card(i). Names in, markdown in, diagnostics out.
  • Corpus surgery with anchors? The addressed install / revise / applyChange verbs + the importMarkdown / exportMarkdown / rebase / mapPos codec — the corpus lane, unchanged and content-only.
  • No quill? The opaque setField / setCardField primitive — verbatim storage, coercion deferred to render.

What moved

Change Detail
quill.writer(doc) is the front door WASM patches writer(doc) onto the re-exported Quill prototype (the Quill === CoreQuill identity holds; the writer owns no wasm handle); Python adds Quill.writer(doc) -> Writer; core already had Quill::writer(&mut doc).
Writer gaps closed setBody / set_body, addCard / add_card (fused make + typed-commit + push, transactional), removeCard, card(i).setBody — mirrored into core's TypedWriter so the parity rows read identical.
Reads live on Document, quill-free get(name) / getMarkdown(name?) — reads need no schema, so they sit on Document, not the writer. getMarkdown re-coins, lazily and by name, the projection the eager fieldMarkdown / bodyMarkdown getters dropped in #925 (the drop was of the eager precompute, not the capability).
commit(addr, value, quill) deleted Unreleased; it only duplicated commitField / commitCardField behind an Addr, dragging a schema into the content-only corpus lane. The typed door is the writer.
commit* demoted to ABI commitField / commitFields / commitCardField(s) stay as the stable ABI under the writer's set / set_all, dropped from the documented surface.

The hand-written WASM runtime becomes the real API and the wasm class its ABI — committed to, not just tolerated. See BINDINGS.md for the parity table that now governs core-vs-bindings drift.

One idiom difference to know

The typed writer is the one shape pyo3 carries worst. Core's TypedWriter holds &mut Document under the borrow checker; WASM's DocumentWriter and Python's Writer cannot — they re-borrow per call (a captured quill + document, each write reconstructing the core writer). The guarantee does not cross the boundary: a held borrow becomes a convention (writers and card cursors are ephemeral — bind, write, discard). The parity table classes this row idiom, not identical.

pdfform's form.json slims to a binding layer — form@0.2.0 (#940)

form.json used to restate what the quill schema already carried: a bound field's type, choice options, and multiline flag duplicated the schema field's kind, enum values, and ui.multiline. Worst was the silent failure — a widget bound to a nonexistent schema_field rendered blank with no error.

form@0.2.0 removes the duplication. A bound field carries only where it sits and what it binds; its widget kind, options, multiline, and tooltip are derived from the resolved FieldSchema at load. Unbound widgets (a signer-filled signature) move to a separate widgets section and keep their own type, since they have no schema field to inherit from.

// form@0.1.0 (retired)              // form@0.2.0
{ "schema": "quillmark/form@0.1.0",  { "schema": "quillmark/form@0.2.0",
  "fields": [                          "fields": [
    { "name": "FavoriteColor",           { "name": "FavoriteColor",
      "schema_field": "favorite_color",    "schema_field": "favorite_color",
      "page": 0, "rect": { … },            "page": 0, "rect": { … } }
      "type": "choice",                  ],   // kind, options, multiline,
      "options": ["red","green","blue"]  "widgets": [   // tooltip all derived
    },                                     { "name": "Signature",
    { "name": "Signature",                   "type": "signature",
      "page": 0, "rect": { … },              "page": 0, "rect": { … } }
      "type": "signature" }              ]
  ] }                                  }

What to change in a form.json

  • Bump schema to quillmark/form@0.2.0. A form@0.1.0 file is rejected at load with pdfform::form_schema_version.
  • On each bound field (one with a schema_field), delete type, options, and multiline — they are derived. Keep name, schema_field, page, rect. tooltip is now an optional override; drop it to inherit the schema field's description.
  • Move every field without a schema_field (signatures, signer-filled boxes) into a new top-level widgets array. These keep type (and multiline / options where the type calls for it).

Widget kind is derived from the schema field's capability

The projection keys on capability, not the type token — so both type: enum and the deprecated string + enum: modifier yield a dropdown:

Resolved schema field Widget kind
has enum values (any spelling) choice, options = the enum values
boolean checkbox
string, number, integer, datetime, richtext, plaintext text
array of the above text (elements newline-joined)
object, or array of objects load error pdfform::unbindable_field

Two new load-time errors replace silent blanks

Binding now runs at Backend::open, so a bad reference fails loudly instead of rendering an empty widget:

  • pdfform::dangling_binding — a schema_field path that does not resolve against the schema (bad root, wrong-shape descent, missing key), naming the failing segment.
  • pdfform::unbindable_field — a path that resolves to a shape no widget can render (an object, or an array of objects).

$cards absolute-index addressing is removed

A bound field addressing a card must use $cards.<kind>.<i>.<field>. $cards.<i>.<field> (absolute index) is gone: a widget's kind must be derivable at load, and only the kind names which schema field the slot binds.