Skip to content

0.94 → 0.95 — the write surface reshapes: store* verbs, one address, a schema-checked revise

The headline of 0.95 is a coordinated reshaping of the mutation surface across core and both bindings (#955, #957, #960), landed as one sweep so nothing is written under a name it immediately loses. Alongside it: the WASM card surface loses a redundant verb, the core Payload map API grows a validation floor, parse warnings stop living on Document, and the content-model genus renames off its codec's name (#976). All breaking, all pre-1.0 cleanups; the rest of the release is additive.

The content genus renames off RichText (#976)

RichText (the type) named the model after one of its two codecs, so "a plaintext field holds a RichText" was the contradiction the code kept having to narrate. The genus renames to Content — the token the Typst backend already forks on (content vs str, emit.rs), and the token the schema already uses for the category ("content field"). richtext and plaintext stay as the two schema codecs into Content; a plaintext field still holds a Content (constrained mark-free and island-free), just imported through the literal codec instead of markdown.

Mechanical renames:

0.94 0.95
crate quillmark-richtext quillmark-content (pre-1.0; old versions remain on crates.io under the old name)
type RichText Content
type RichTextLine / RichTextContainer / RichTextMark / RichTextIsland (WASM .d.ts / Python) ContentLine / ContentContainer / ContentMark / ContentIsland
const RICHTEXT_MEDIA_TYPE CONTENT_MEDIA_TYPE
media type string application/quillmark-richtext+json application/quillmark-content+json (consumer-visible in transform schema contentMediaType, PLATE_DATA.md, SCHEMAS.md)
struct field FieldSchema::default_corpus / example_corpus (#[serde(skip)] caches) default_content / example_content
SegmentMap.corpus: Range<usize> (Typst emitter source map) SegmentMap.content: Range<usize>
EmittedContent { markup, segments } (Typst emitter output) Emission — it is not a Typst content value; renaming it avoids an active ambiguity next to the Content genus and pairs with emit_*.
type CorpusHit (core region.rs; position-hit in WASM .d.ts / Python) ContentHit — the informal noun in a public type name (residual sweep, #982).
EditError::CorpusApply EditError::ContentApply (message CorpusApplyContentApply).
RichtextDecodeError::NotCorpus variant NotContent — the error type stays RichtextDecodeError (codec-specific, the error of Card::field_richtext); only the variant carried the informal noun.
storage DTO CanonicalRichText (dto.rs, the body field's type) CanonicalContent — a Content wrapper, not a codec; the name still spelled the old model. Serde is unchanged, so no wire migration.
Typst emitter emit_richtext / emit_richtext_inline emit_content / emit_content_inline — model-generic (they lower any Content, richtext or plaintext), so codec-named misdescribed them.
informal noun "the corpus" / "the RichText corpus" "the content" / "a Content value" — retires the code/prose split.

Kept — these are genuinely codec-specific, not model-generic:

  • Schema type tokens richtext / plaintext (YAML type: richtext).
  • FieldType::RichText { inline } / FieldType::PlainText { inline } enum variants (they name schema codecs, not the model).
  • Card::field_richtext / Card::apply_field_richtext_change, EditError::FieldRichtextDecode / FieldRichtextNotInline, richtext(inline) blueprint syntax — the richtext-codec API surface.
  • Test fixtures whose codec is richtext (richtext_form/, etc.).

Canonical wire JSON ({text, lines, marks, islands}) is nameless — no data migration on stored bodies or transform-schema instances. The contentMediaType string in transform schemas changes; consumers pinned to application/quillmark-richtext+json update to the new spelling in one line.

Rust import changes are one substitution across a project:

// 0.94
use quillmark_richtext::RichText;
use quillmark_richtext::{from_markdown, to_markdown};

// 0.95
use quillmark_content::Content;
use quillmark_content::{from_markdown, to_markdown};

Cargo.toml:

# 0.94
quillmark-richtext = "0.94"

# 0.95
quillmark-content = "0.95"

The opaque store renames set*store* (#960)

The write surface has three lanes; the verb now carries which one. store = verbatim (quill-free, coercion deferred to render), set = typed (the writer, strict commit at the write), install / revise / apply = content (identity-aware). One sentence in BINDINGS.md states it; the names stop needing per-verb disambiguation. remove_* stays — removal has no lane.

Lane 0.94 0.95
Core Card set_field / set_fields / set_fill / set_ext / set_ext_namespace / set_seed_namespace store_field / store_fields / store_fill / store_ext / store_ext_namespace / store_seed_namespace
WASM Document setField / setFields / setFill / setExt / setExtNamespace / setSeedNamespace storeField / storeFields / storeFill / storeExt / storeExtNamespace / storeSeedNamespace (and re-addressed — see below)
Python Document set_field / set_fields / set_ext / set_card_field / set_card_ext / … store_field / store_fields / store_ext / store_card_field / store_card_ext / …

The typed writer (set / set_all / set_body) keeps its names — the typed lane. Its wasm ABI is underscored to _commitField / _commitFields and hidden (#966, below). set_card_kind (a $kind write, structural, not a field store) is unchanged. This makes the call #913 deferred for the Python mirror, globally.

WASM Document unifies on one address (#955)

The WASM Document class encoded "main vs card at index" as verb duplication — 22 methods for 11 concepts (setField / setCardField, get / getCardField, …). They collapse onto the Addr the content lane (install / revise / applyChange) already spoke: { card?, field? }, absent card = main, absent field = body. A bare string is Addr shorthand for { field }, so the common case stays terse.

0.94                                      0.95
doc.setField("qty", 3)                    doc.storeField("qty", 3)
doc.setCardField(2, "qty", 3)             doc.storeField({ card: 2, field: "qty" }, 3)
doc.setFields(fields)                     doc.storeFields({}, fields)
doc.setCardFields(2, fields)              doc.storeFields({ card: 2 }, fields)
doc.get("qty")                            doc.get("qty")            // unchanged (string ⇒ {field})
doc.getCardField(2, "qty")               doc.get({ card: 2, field: "qty" })
doc.getMarkdown("intro")                  doc.getMarkdown("intro")  // unchanged
doc.getCardMarkdown(2, "intro")           doc.getMarkdown({ card: 2, field: "intro" })
doc.removeCardField(2, "qty")             doc.removeField({ card: 2, field: "qty" })
doc.setExt(map)                           doc.storeExt({}, map)
doc.setCardExt(2, map)                    doc.storeExt({ card: 2 }, map)
doc.setExtNamespace(ns, v)                doc.storeExtNamespace({}, ns, v)
doc.removeExtNamespace(ns)                doc.removeExtNamespace({}, ns)
doc.removeCardExt(2)                      doc.removeExt({ card: 2 })
doc.commitField(quill, "qty", v)          writer.set("qty", v)               // typed writer; ABI is _commitField (#966)
doc.commitCardField(quill, 2, "qty", v)   writer.card(2).set("qty", v)
doc.commitFields(quill, fields)           writer.setAll(fields)
doc.commitCardFields(quill, 2, fields)    writer.card(2).setAll(fields)

Three rules govern the surface:

  • Reads are total over the field axis. get / getMarkdown / isFill return the value / projection / fill-flag for a present field, and undefined / undefined / false for an absent one — only an out-of-range card throws. getMarkdown now returns undefined, not "", for an absent field (absence vs empty is real signal for must-fill / seeding UIs); a body address reads the body content (get) or its markdown (getMarkdown). One carve-out lands the same release: getMarkdown throws FieldRichtextDecode for a present field that does not decode as richtext — a type mismatch is not absence (#968, its own section below).
  • Field writes require a field. storeField / storeFill / removeField / _commitField throw on a body address (no field) — a body is never opaque and has no field schema; write it with revise / install / writer.setBody.
  • Card-scoped verbs take a CardAddr ({ card? }) first and throw on a present field. storeFields({}, fields), storeExt({ card: 2 }, map), _commitFields(quill, {}, fields). The address is first and never shape-overloaded, because card is a legal field name (an optional-address storeFields({ card: 2 }) would be ambiguous with "set field card"). The main card is {}; MAIN_CARD_ADDR (exported from @quillmark/wasm/runtime) is a frozen, CardAddr-typed alias for it, so the common case reads as intent — storeFields(MAIN_CARD_ADDR, fields) — while {} and undefined stay equally valid. An address rejects unknown keys: a stray key — a typo, or the fields object handed where the address belongs (storeFields(fields, {})) — throws instead of silently reading as the empty main-card address and writing an empty batch.

New reach the twins never had: getExt(addr?) and getExtNamespace(addr, ns) (fine-grained, non-destructive $ext reads), isFill(addr), and a card-capable storeFill.

Removed (folded into the addressed verbs): getCardField, getCardMarkdown, setCardField, setCardFields, setCardExt, removeCardExt, setCardExtNamespace, removeCardExtNamespace, removeCardField, commitCardField, commitCardFields. install / revise / applyChange widen to accept the bare-string shorthand.

Python does not take the address — it stays name-keyed, its store_card_* twins renamed from set_card_*. Core keeps borrow navigation (main_mut() / card_mut(i)); the Addr lives only in the bindings.

A typed, anchor-preserving field revise (#957, #966)

Two writes took markdown into a richtext field with opposite identity semantics: writer.set_body(md) (and revise) rebased surviving anchors, while writer.set("subject", md)commit_field cold-imported and destroyed them. No write was both typed and anchor-preserving. New:

  • Core Card::revise_field_checked(name, md, schema) -> Delta — the primitive: diff the markdown against the field's current content so surviving anchors rebase (as revise_field), then enforce the field schema on the diffed result through the same typed-conform path commit_field runs, so a richtext(inline) field rejects a multi-block result with FieldRichtextNotInline (the error surface unchanged) while anchors survive.
  • Core TypedWriter::revise_field(name, md) -> Delta and CardWriter::revise_field(..) — the schema-bound wrapper: resolves the field's schema from the bound quill (an undeclared name is UnknownField) and calls the primitive.
  • WASM writer.reviseField(name, md) / writer.card(i).reviseField(name, md) — the schema-bound verb on the writer, where the schema is.

The verb needs a schema, so it lives on the writer; Document stays quill-free. revise / revise_field stay schema-blind; commit_field / install_field are unchanged.

The writer is the one schema-bound door (#966)

Document's quill-taking methods become the hidden ABI under the writer, not public verbs — the visible Document class then carries zero quill-taking methods.

  • WASM commitField / commitFields / addCard_commitField / _commitFields / _addCard, dropped from the .d.ts. The runtime writer.set / setAll / addCard delegate to them; the writer surface is unchanged.
  • WASM doc.reviseChecked(quill, addr, md) is removed; use writer.reviseField(name, md) (above). It had no runtime consumer.
  • EditError::BodyImportEditError::Import, message body import failed:markdown import failed:. The variant also fires on field-path imports (revise_field), where "body" was a misnomer; bindings surface the prefix as [EditError::Import].

WASM: pushCard folds into insertCard(card, at?); replaceBody is deleted (#961)

One insertion verb per lane. pushCard is gone and insertCard now takes the card first and an optional position second — absent at appends, a number inserts at that index (0..=cardCount, out of range throws IndexOutOfRange).

0.94                                   0.95
doc.pushCard(card)                     doc.insertCard(card)
doc.insertCard(2, card)                doc.insertCard(card, 2)

The deprecated replaceBody alias is removed. Use the content-lane revise or the writer:

0.94                                   0.95
doc.replaceBody(md)                    doc.revise({}, md)
                                       // or  quill.writer(doc).setBody(md)

removeCard / moveCard / makeCard are unchanged. Every Card a document returns is still a valid CardInput for insertCard.

Positioned insert, removeCard, and a cursor kind — additive (#961)

No action; these only add reach.

  • writer.addCard(kind, fields?, body?, at?) (WASM) and TypedWriter::add_card(kind, fields, body, at) (core) take a position, so a positioned typed insert is one atomic call instead of addCard + moveCard.
  • TypedWriter::remove_card(i) now exists in core, mirroring the JS writer.removeCard(i) sugar.
  • The JS CardWriter gains a kind getter (reads through doc.card(i)), mirroring core CardWriter::kind().

core: Payload::insert / insert_fill validate and return Result (#958)

The direct Payload map API — reachable through the public Card::payload_mut() — validated nothing, so payload_mut().insert(bad, v) could seat an invalid field in a document that "cannot be invalid." Both now enforce the field-name / value-depth invariant at the boundary and return Result<Option<QuillValue>, FieldViolation>.

// 0.94
card.payload_mut().insert("qty".to_string(), v);          // -> Option<QuillValue>

// 0.95
card.payload_mut().insert("qty".to_string(), v)?;         // -> Result<_, FieldViolation>

Only direct Payload callers are affected — the Card / writer mutators (set_field, set_fields, commit_field, set / set_all) are unchanged in signature and behavior; they already validated. A caller that has already validated the exact stored value (and wants to skip the re-check) uses the new pub(crate) insert_unchecked / insert_fill_unchecked.

core: parse warnings live only on ParseOutput (#959)

Warnings were bookkept in three places. They are now owned solely by ParseOutput; Document no longer carries them.

  • Document::warnings()removed. Read ParseOutput::warnings from Document::from_markdown_with_warnings; the binding wrappers (doc.warnings in WASM / Python) are unchanged, as they always kept their own copy.
  • Document::from_main_and_cards(main, cards, warnings)from_main_and_cards(main, cards) — the warnings parameter is dropped.
  • Document's PartialEq is now a plain derive (it only ever excluded warnings); equality is unchanged (structural: main + cards).

from_markdown and from_markdown_with_warnings are collapsed into a single Document::parse in the same release — see the next section (#964).

core: one parse entry — Document::parse returns Parsed (#964)

The two parse functions become one. Document::from_markdown (discarded the warnings) and Document::from_markdown_with_warnings (returned them) are both removed; the single entry is

Document::parse(md) -> Result<Parsed, ParseError>

and ParseOutput is renamed to Parsed (its fields are unchanged: { document, warnings }).

  • from_markdown_with_warnings(md)parse(md) — the returned value has the same .document / .warnings fields.
  • from_markdown(md) where you wanted only the document → parse(md)?.document (or .unwrap().document / .expect(..).document).
  • parse carries #[doc(alias = "from_markdown")], so rustdoc search for the old name still lands on it.

parse returns a Parsed, not a Document — the trade is deliberate: one door, warnings surfaced by default instead of hidden behind a longer method a caller might never discover, and a compile error (not silent warning loss) when a caller forgets .document.

Bindings are unaffected in surface: the WASM Document.fromMarkdown and Python Document.from_markdown keep their names and their doc.warnings getter — the wrapper is a session object fusing Parsed + Document (see the prose/canon/BINDINGS.md parity table). Only the two call sites into core were renamed.

Additive reads — no action (#956)

New reads that avoid whole-document serialization; nothing to migrate.

  • doc.card(i) (WASM) / Document::card(i) (core) — one whole card by index without materializing the cards array (throws / None out of range).
  • doc.cardIndexById(id) (WASM) / Document::find_card(id) (core) — resolve a $id to its index without a hand-rolled scan ($id is non-unique; first match wins).
  • doc.seedOverlay(kind) (WASM) — the main card's $seed[kind] overlay, fed straight into quill.seedCard(kind, overlay), without serializing the whole main card.

The markdown projection drops its trailing newline (#965)

to_markdown projects a content value, not a file, so it no longer appends a final \n. Every projection inherits this: core field_markdown / body_markdown, WASM getMarkdown / exportMarkdown, Python export_markdown / get_markdown. A field read-back stops growing a newline — writer.set("subject", "Hello") now reads back as "Hello", not "Hello\n", so a controlled <input> bound to getMarkdown no longer fights the caret and value === getMarkdown(addr) holds.

Action: drop any .trimEnd() / .rstrip("\n") you added to work around the old newline; delete the trailing \n from assertions that pinned it.

The content fixed point is unchanged: import is newline-insensitive, so a re-parse of either form yields the same content.

getMarkdown throws on a present non-richtext field (#968)

The markdown projection stops conflating absent with present-but-not-richtext. Before, getMarkdown / get_markdown returned the absent shape (undefined / "") for both, so a scalar / array / object a storeField wrote — exactly the values an opaque store or a non-richtext schema field holds — rendered as blank UI indistinguishable from a missing field. Now:

  • absentundefined (WASM) / "" (Python), unchanged;
  • present, decodes as richtext (a content, or a markdown string) → the markdown, unchanged;
  • present, does not decodethrows FieldRichtextDecode, naming the field.

The rule in one line: absence returns; mismatch raises. It refines #955's totality (absence stays routine and total) rather than breaking it — a type mismatch was never routine, it was a consumer bug the flattening turned into silently blank UI. Read the raw value with get(addr) / get(name) when you want the stored scalar rather than a projection.

Core mirrors the shape: Card::field_markdown returns Option<Result<String, RichtextDecodeError>> (the projection twin of field_richtext) instead of Option<String>None absent, Some(Ok) markdown, Some(Err) mismatch. A core caller that unwrapped the old Option adds a second unwrap or handles the Err.

This absence-returns / mismatch-raises rule now lives on the schema-plane view.get (see #978 below): the binding getMarkdown's field projection — where this fix first landed — retired the same release, so the mismatch raise surfaces through quill.view(doc).get(name):

// present field that isn't richtext (a scalar store wrote)
doc.storeField({ field: 'qty' }, 3)
doc.get('qty')                    // 3      — the raw value still reads (transport)
quill.view(doc).get('qty')        // throws FieldRichtextDecode

The core field_markdown throw and the Python absent-"" case are unchanged; only the binding entry point for a field's markdown moved from getMarkdown to view.get.

A schema-bound read view; getMarkdown's field half retires (#978)

Breaking. The markdown projection was a schema-shaped question ("this field's richtext, as markdown") answered by a schema-free Document — the residue of which was #968's conflation. The projecting read now has a schema-bound home, the read twin of quill.writer(doc), and its old home on Document retires:

quill.view(doc)
  .get(addr)   // richtext  → markdown
               // plaintext → literal text (marks verbatim, never interpreted)
               // every other type → canonical value
               // absent    → undefined / None
               // unknown field name → throws UnknownField
               // content field holding an undecodable value → throws FieldRichtextDecode

view.get interprets by the field's declared type, so it carries the authority getMarkdown lacks: an unknown field name throws instead of reading back the absent shape (the schema knows the field set). Core adds Quill::view(&doc) → TypedReader, whose get returns a ReadValue (Markdown(String) for richtext, Plaintext(String) for plaintext, Value(QuillValue) otherwise); view.card(i) is the card cursor.

getMarkdown is now body-only. Its field projection is retired — getMarkdown / get_markdown / get_card_markdown read a body's markdown and nothing else. On WASM getMarkdown takes an optional CardAddr and a present field throws ("body-only"); on Python get_markdown() / get_card_markdown(index) drop the name parameter. The body read stays on Document (a body's type is a format fact, not a schema fact); view.getBody() mirrors it. Migrating a field-projection call site is a quill-in-hand rewrite:

doc.getMarkdown('subject')          // was  (WASM: field addr; Python: get_markdown("subject"))
quill.view(doc).get('subject')      // now — throws on an unknown name

doc.getMarkdown({ card: 2, field: 'body' })       // was
quill.view(doc).card(2).get('body')               // now

plaintext fields, new to view.get, project through their literal codec (to_plaintext): a *hi* reads back as four characters, not emphasis — the same codec the write path uses, in reverse.

datetime splits into strict date and datetime types (#991)

Breaking. The one datetime type was a lexical union of three kinds — calendar date, wall-clock datetime, offset instant — that the render pipeline silently truncated to a date (#717, #799). It splits into two types, each with a strict grammar and no truncation in either direction:

Type Accepts Rejects
date YYYY-MM-DD any time component (not truncated)
datetime YYYY-MM-DDThh:mm[:ss] offsets (Z, ±HH:MM), the space separator, fractional seconds, a bare date

Seconds are the one concession — 2026-06-01T14:30 is how a person writes 2:30 PM; the Typst constructor zero-fills. Offsets are rejected, never dropped: the engine keeps wall-clock semantics end to end and does no zone math, so an offset-bearing value errors at the seam (convert to local time and remove the offset) rather than being reinterpreted as document-local — there is no special case for Z / +00:00. Storage stays the verbatim authored string.

Most datetime fields hold a bare date — those migrate to type: date, which is a rename, not a data change: the stored string is byte-identical and a date lowers to the same three-component datetime(year:, month:, day:) the old type emitted. A datetime field now carries its wall-clock time-of-day through to the six-component constructor instead of dropping it.

# 0.94
issued:
  type: datetime      # held "2026-06-01" — a date wearing a datetime type

# 0.95
issued:
  type: date          # same value, honest type, identical render

Action:

  • Rename every type: datetime field that holds a calendar date to type: date (the common case). No document changes.
  • A field that genuinely carries time-of-day stays type: datetime, but any value with an offset, a space separator, fractional seconds, or a bare date now fails coercion — convert offsets to local wall-clock time and drop them, switch the space separator to T, and give a bare-date value a type: date field.
  • Projections shift: the transform schema marks date as format: "date" (and keeps format: "date-time" for datetime), and the blueprint annotation reads date<YYYY-MM-DD> / datetime<YYYY-MM-DDThh:mm[:ss]>. A consumer keying on the old format: "date-time" stamp for every date field updates to also accept "date".

There is no deprecation alias: type: datetime rejecting a bare date is the decided end-state, and the offset/space/fraction forms break cleanly.

Dates lower to a click-to-edit value-object (#990)

Breaking for plates that call .display() on a date field. A present type: date / type: datetime field no longer arrives as a bare Typst datetime. It arrives as a two-key value-object whose display is a closure returning region-bearing content:

// generated, per present date cell
(value: datetime(year: 2026, month: 1, day: 2),
 display: (..args) => text(datetime(year: 2026, month: 1, day: 2).display(..args)))

This makes a date the first click-to-edit target in the system: because the glyphs are born at a generated text(..) node inside the helper, they carry a region keyed on the field's schema path — even when a vendored package places the date, and even when a card's date rides the shared loop variable that span tracking otherwise cannot chase. A blank date stays none, so != none guards are untouched.

The cost is that data.<field> is a dict, not a datetime. Three call shapes migrate:

Was (0.94) Now (0.95) Why
data.issued.display("…") (data.issued.display)("…") Typst rejects method sugar on a dict key; the stored closure needs the paren form
data.issued in datetime math / comparison / a datetime-consuming package data.issued.value .value is the native datetime
str(data.issued.display("…")) or other string use of the result data.issued.value.display("…") the closure returns content; .value.display is the native str
data.issued.year() (and .month(), .weekday(), …) data.issued.value.year() components live on the native datetime

One rule covers all of it: native anything → .value; region render → (…display)(…).

Vendored packages count. A package's own .display() on a date it was handed breaks the same way — the flagship tonguetoquill-* memo/letter quills format the date inside display-date, which now dispatches on type(date):

if type(date) == str { date }
else if type(date) == datetime { date.display(pattern) }   // the today() fallback stays native
else { (date.display)(pattern) }                            // the value-object dict

The datetime branch is load-bearing: these quills substitute datetime.today() for a blank date, so the same call site receives both a native datetime and a value-object — and grabbing .display off a native datetime (the dict's paren form) is a compile error, so the two shapes cannot share one branch.

Action: parenthesize every .display( call on a date field; route every native datetime operation (compare, subtract, .year(), datetime-consuming packages) through .value; audit vendored packages for internal .display() on a passed-in date and give them a type(date) dispatch if they also see a native datetime.

Python commits to Tier 1 + storage + render (#970)

Rather than mirror the reshaped store* / Addr / content-lane surface, the Python binding cuts to its lanes. Field I/O flows through quill.writer(doc) / quill.view(doc) exclusively; Document is quill-free data and structure. The opaque store and the anchor-preserving content lane are WASM-only by scope, not by lag — their audience (storage/migration tooling holding no quill, live editors preserving anchor identity) is not a Python audience. This resolves the half-mirror drift #970 priced: no lane that could drift remains in Python.

Removed. The opaque field store, the content lane, the quill-free field reads, and the document-free codec:

0.94 (Python) 0.95 replacement
doc.store_field(name, v) / doc.store_fields(m) / doc.store_fill(name, v) quill.writer(doc).set(name, v) / .set_all(m); a fill lands through a typed set (which clears the !must_fill marker)
doc.store_card_field(i, name, v) / doc.store_card_fields(i, m) quill.writer(doc).card(i).set(name, v) / .set_all(m)
doc.get(name) / doc.get_card_field(i, name) quill.view(doc).get(name) / quill.view(doc).card(i).get(name)
doc.get_markdown() / doc.get_card_markdown(i) quill.view(doc).get_body() / quill.view(doc).card(i).get_body()
doc.install(rt, …) / doc.revise(md, …) / doc.apply_change(b, …) none (WASM-only); the typed writer.revise_field is the anchor-preserving field write
import_markdown / export_markdown / rebase / map_pos (module fns) none (WASM-only); read body markdown via quill.view(doc).get_body(), and doc.body is the content dict ({text, lines, marks, islands})
doc.replace_body(md) / doc.update_card_body(i, md) (deprecated aliases) quill.writer(doc).set_body(md) / .card(i).set_body(md)

A field write without a loadable quill operates on the storage DTO directly (to_json / from_json), or through core / WASM / the CLI, where the store lane remains.

Changed — the composable-card twins fold onto one card= selector, and the insertion verbs fold to one:

0.94                                  0.95
doc.store_card_ext(i, v)              doc.store_ext(v, card=i)
doc.remove_card_ext(i)               doc.remove_ext(card=i)
doc.store_card_ext_namespace(i,n,v)  doc.store_ext_namespace(n, v, card=i)
doc.remove_card_ext_namespace(i,n)   doc.remove_ext_namespace(n, card=i)
doc.remove_card_field(i, name)       doc.remove_field(name, card=i)
doc.push_card(card)                  doc.insert_card(card)          # absent at appends
doc.insert_card(i, card)             doc.insert_card(card, at=i)

store_ext / remove_ext / store_ext_namespace / remove_ext_namespace and remove_field gain a trailing card=None (main) selector; the whole-map ext verbs and $seed namespace verbs are otherwise unchanged.

Added — the 0.95 additive surface, now mirrored:

  • quill.writer(doc).revise_field(name, md) / writer.card(i).revise_field(..) — the typed, anchor-preserving richtext field write (#957, #966). Python discards the Delta it returns in core / WASM (the position-mapping receipt is an editor concern, and that lane is WASM-only).
  • quill.writer(doc).add_card(kind, fields?, body?, at=None) — the positioned typed insert (#961); absent at appends.
  • writer.card(i).kind — the cursor kind getter (#961).
  • The typed handle classes Writer / CardWriter / View / CardView are exported from the quillmark package (for isinstance / typing).

Python's Payload is not exposed, so #958 does not affect it; its doc.warnings getter is unchanged by #959.