0.106 → 0.107 — a declared type means the same thing at every depth¶
One change runs through this release: depth stops mattering. Every type nests at every position a schema admits, lowering dispatches on the schema node beside each value rather than on flat tables of top-level field names, and one address tree answers what a plate may address on either backend. Three load-time ceilings go with it.
Two breaks are the ones to read first, because neither is reported by a type checker and one of them stops a quill loading at all:
| Break | Surface | Action |
|---|---|---|
| A plate read of a typed-table row cell regions on the cell | consumers matching FieldRegion.field / ContentHit.field / RenderedRegion.field |
Match main.refs[0].org, not main.refs |
A nested richtext(inline) default: / example: spanning more than one paragraph now fails load |
quill authors | Fix the literal, or drop inline: true |
A container-shaped default: / example: on a variant-bearing enum now fails load |
quill authors | Move the literal to the discriminant spelling |
A date / datetime field lowers to a native datetime |
plates | Drop .value; (data.f.display)(..) → display("f", ..) |
A nested date / richtext / plaintext arrives as its declared type |
plates reading container cells | Delete the workaround and read the cell |
plaintext(field) is removed |
plates | No route; the helper had no callers |
Documents in storage are untouched, and a quill that declares no containers and no dates is unaffected at every surface.
Region addresses take the index step¶
This is the third consecutive release that the grammar under FieldRegion.field has moved — 0.104 respelled the array index, 0.106 added the property step, 0.107 adds the index step — and all three times the field stayed typed string. A consumer matching on it gets no compile error and no runtime error, just a match that stops firing.
A plate reading a typed table's row cell now regions on the cell:
// A plate reading `#data.refs.at(0).org`
// 0.106
region.field === 'main.refs' // the whole table — a wrong address, not a missing one
// 0.107
region.field === 'main.refs[0].org'
The old address was wrong rather than absent: a click on one org cell routed to the entire table. The span scan was the last of three components deriving a schema address still capped at one level, so after 0.106 lifted lowering and _qm-known-path to the row property, the three no longer agreed. Each step is now its own address:
region.field === 'main.refs[0]' // a whole-row read (`#data.refs.at(0)`)
region.field === 'main.tags[0]' // a primitive element (`#data.tags.at(0)`)
So a consumer that keyed on the array's address to find element ink sees the narrower address instead. Match a prefix on segment boundaries rather than widening a startsWith:
const addr = doc.pathFor({ field: 'refs' })
regions.filter(r => r.field === addr || r.field.startsWith(addr + '['))
A negative index and an undeclared row key mint nothing and fall back exactly as before, and explicit field-region / form-field claims are unchanged. .at(n) is the only spelling Typst has for an array index, so that is the only one scanned.
Two schema literals that now fail load¶
Both are literals a quill could declare before and that reached nothing at render. They fail at load now, which is the upgrade hazard in this release most likely to stop a build: the quill is rejected, not the document.
A nested richtext(inline) literal is checked. Importing a content literal into its companion cache is also what checks it, and the load pass walked only the card's field map — so an object property, a typed-table row property and a variant cell each kept their authored literal unchecked. The walk now recurses, and a nested literal spanning more than one paragraph draws validation::not_inline naming the leaf's declaration path:
dict:
type: object
properties:
tag:
type: richtext
inline: true
default: "one\n\ntwo" # 0.106: loads, renders blank. 0.107: validation::not_inline at `dict.tag`
Either make the literal one paragraph or drop inline: true. A card-level literal has always been checked this way; only the nested positions were exempt.
A container-shaped literal on a variant-bearing enum is refused. The container is the shape a document writes. As a schema literal it cached no content form and yielded no discriminant, so the field blank-filled in silence as if nothing were declared:
classification:
type: enum
values: [UNCLASSIFIED, CUI]
variants:
CUI:
poc: { type: string }
default: { value: CUI, poc: "" } # 0.107: quill::default_type_mismatch
# default: CUI # the discriminant spelling; a world's cells carry their own literals
quill::example_type_mismatch is the example: twin. Scalar literals are unaffected.
Dates are native, and display is the ink¶
A present date / datetime field lowers to a native Typst datetime rather than the (value:, display:) wrapper 0.95 introduced. A blank date is still none, so != none guards hold.
// 0.106
#data.issued.value.year()
#(data.issued.display)("[day padding:none] [month repr:long] [year]")
// 0.107
#data.issued.year()
#display("issued", "[day padding:none] [month repr:long] [year]")
Both old spellings are hard Typst compile errors, never a silent degrade: .value is an unknown field on a datetime, and the paren form calls a non-closure. Comparison, arithmetic, the .year() family and any datetime-consuming package are now ordinary Typst.
What the wrapper bought was click-to-edit — glyphs born at a generated node carrying the field's address. display(field, ..args) buys it back and takes the field's schema address rather than its value, so it is the one helper keyed by address:
#display("issued", "[year]") // rendered ink, regioned on `main.issued`
#display(card.at("$path") + "date", pattern) // a per-card region off one loop variable
The rule: want a value → data.<field>; want clickable ink → display("<field>", ..). The difference matters exactly when a package does the inking — a datetime handed to a package draws its glyphs wherever the package places them, and nothing ties them to your field, while display's ink keeps its address however deep it travels. The address is checked against the schema at compile time, the same check form-field(field:) and field-region carry, so a typo fails the render rather than dropping the date from it.
A date has no canonical rendering the way authored text does — every rendering of 2026-01-02 is a typographic decision the plate owns — which is why the value is the value and ink is reached by address.
A nested date or richtext arrives as its declared type¶
Lowering read tables of top-level field names, so ten of the twelve nested positions the schema admits degraded silently: a date inside an object or an array row reached the plate as a bare string, and a nested richtext or plaintext reached it as the internal canonical-content JSON, rendered as a Typst dict. Core coerced and validated both correctly, so nothing upstream had anything to report.
// 0.106 — `contact.note` is a dict of internal JSON; `rows.at(0).on` is a str
// 0.107
#data.contact.note // markup content
#data.contact.reply_by // datetime
#data.rows.at(0).on // datetime
If a plate reached into that JSON or parsed the string itself, delete the workaround: the cell is now the same type its card-level twin has always been. This is a render-output change wherever such a cell was placed.
plaintext(field) is removed¶
The helper and its _qm-plaintext table are gone. Shipped in 0.94 as the sanctioned content→str coercion, it never acquired a caller — no plate, no vendored package, no binding surface — and it carried a three-way name collision with the plaintext field type and that type's document-layer resting shape. A plate calling it gets an unknown-variable error at compile.
A plate that needs a str from a content field now has no route, which is the honest state of the requirement. Reinstating it is additive and cheap when a plate actually asks.
Widenings¶
Every quill that loaded before loads unchanged; these only admit more.
- Every type nests at every depth.
object<array<string>>,array<array<integer>>, a typed table whose row holds a typed dictionary, and a variant cell holding either.quill::nested_object_not_supportedandquill::nested_array_not_supportedare gone — a consumer matching either code will never see it again. - A variant cell carries any type a card field may, prose, dates and containers included.
quill::variant_field_typeis gone.variants:itself stays card-level (quill::variant_placement): a variant's shape is a function of the schema and the discriminant, and that gap holds at exactly one level. - A typed table's row property is a writable address.
form-field(field: "refs.0.org")andfield-region("refs.0.org")bind where they capped at one suffix step before, so one address binds on either backend — the claimPLATE_DATA.mdmakes andquillmark/tests/address_grammar.rsnow pins across both. blueprint()expands a container at every depth, where a nestedobjector typed table rendered askey: null # objectwith its own properties, markers and annotations absent.- A
default:reaches the plate from every position it can be declared in. A container's owndefault:, and a content leaf'sdefault:inside anobjectproperty, a row property or a variant cell, each reached nothing before; a document authoring only the container (dict: {},rows: [{}],c: {value: CUI}) rendered with the author's default missing. Such a document now renders with it — a render-output change for any quill that declared one. - A scalar read through a single-assignment
letalias keeps its address.#let c = data.classification…#c.pocsurfacesmain.classification.pocwhere it surfaced nothing. An alias holds only where the plate binds the name exactly once to one wholedatachain, and a wildcard import disqualifies every alias; laundering past that — a function parameter, a destructured binding, a per-card loop variable — is unchanged and still needs afield-regionclaim. VARIANT_DISCRIMINANT_KEYjoins the WASM runtime's static exports, besideMAIN_CARD_ADDR. 0.106 announced the constant but shipped it to Rust only, leaving a JS consumer to hardcode"value". The.d.tstypes it as the string literal, which keeps it narrowing an index into the container.
Not affected¶
Stored blobs, the document syntax, the content model, and the plate-JSON wire shape for any field that declares no container and no date. form.json is unchanged, so the pdfform flatten path and the canvas preview are untouched. A quill whose schema is flat and dateless projects identically at every surface.