@quillmark/wasm 0.103.0 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,204 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.105.0 - 2026-08-14
4
+
5
+ - feat(core,wasm,python)!: a `Content` nested inside a composite field is
6
+ readable at its own codec. `TypedReader::get_content_at(name, path)` (and the
7
+ `CardReader` twin, `reader.getContentAt(addr, path)` in JS,
8
+ `reader.get_content_at(name, path)` in Python) walks a `PathSegment` path
9
+ through the field schema — `items` for an index, `properties` for a key — to
10
+ the leaf whose declared type names the codec, then decodes through the same
11
+ dispatch the whole-field read uses. So an `array<richtext>` element, an
12
+ `object`'s content property and a leaf under both each read back the same
13
+ `Content` whatever their resting form, where before every one of them
14
+ answered `FieldNotContent` and the consumer had to decide for itself what the
15
+ stored bytes meant (#1243). The empty path *is* `get_content`. A path naming
16
+ nothing in the stored value reads absent rather than throwing: an editor's
17
+ row index goes stale between derive and read, and that is the axis a repeater
18
+ mutates. `Addr` deliberately gains no element axis — the path is the read's
19
+ own argument, since `storeField` / `isFill` / `applyChange` could not answer
20
+ one. **Breaking**: `EditError::FieldDecode` and `EditError::FieldNotContent`
21
+ each gain an `at: Vec<PathSegment>` field carrying the in-field path, so the
22
+ diagnostic anchors at `main.paragraphs[1]` and parses back to those segments;
23
+ `field` stays a bare field name and `args` is unchanged. `FieldNotContent`
24
+ now names the type *reached*, so a `string[]` element reports `string` rather
25
+ than the field's `array`.
26
+ - feat(core,wasm)!: `must_fill:` on a field declares the **obligation** axis.
27
+ `default:` carried the fill value and the obligation signal on one bit, so
28
+ only that 2x2's diagonal was reachable; a safe value that still wants a
29
+ human's confirmation (`default: UNCLASSIFIED` with `must_fill: true`) and a
30
+ genuinely optional field with nothing to suggest (`must_fill: false`) now
31
+ each have a spelling. Left unset it derives `default.is_none()`, so no
32
+ existing quill's blueprint marker set changes. `Quill::validate` gains a
33
+ second trigger under the one `validation::must_fill` code, named by a
34
+ `trigger` arg: `marker` for a `!must_fill` tag the document carries, and
35
+ `unauthored` where the schema obliges a cell the document leaves absent or
36
+ present-null. The second closes a hole — `validate_fills` walked only the
37
+ payload, so a hand-written or programmatically built document drew no
38
+ completeness signal whatever `Quill.yaml` declared. **Breaking**: a merely
39
+ incomplete document no longer validates clean. Absence is still never
40
+ *malformed* and still never gates render, but a consumer reading "any
41
+ diagnostic ⇒ not done" now sees a warning per unauthored obliged cell on
42
+ documents that were silent. The obligation keys on cell presence rather than
43
+ the resolved source rung, so a must-fill leaf inside a container someone
44
+ touched still warns; a typed dict is never itself a cell and recurses to its
45
+ leaves, while an array is one cell, `[]` being a real answer. Authoring the
46
+ field's blank discharges it and `field: null` does not: null ≡ absent stays
47
+ unqualified on the value ladder, but obligation asks whether a human made a
48
+ call. Seeding stamps the marker on example-seeded obliged cells, so a fresh
49
+ seed and an empty document report the same cells, and the transform schema
50
+ carries `quillmark:must_fill` (#1255).
51
+ - fix(core,wasm,pdfform)!: a field's **blank** — its spelling of "explicitly
52
+ nothing" — is a property of the field rather than a member of its type's
53
+ domain, and an `enum`'s is `""`. The render floor for a defaultless enum
54
+ returned `values.first()`: a choice nobody made, indistinguishable at the
55
+ plate from a deliberate one and reachable from a cosmetic `values:` reorder.
56
+ An unanswered enum now renders `""`, so a reorder is render-safe for every
57
+ document and only removing or renaming a member breaks
58
+ ([VERSIONING.md](prose/canon/VERSIONING.md)). **The accepted domain widens to
59
+ `values ∪ blank` for *every* enum**, defaulted ones included: `format: ""`
60
+ was a fatal `EnumViolation` and now coerces, validates and reaches the plate.
61
+ **A plate must therefore branch exhaustively over `values ∪ blank`** — an
62
+ `else` fallback re-opens exactly the fabrication the blank closes, and a
63
+ downstream package that asserts membership fails the compile outright. Note
64
+ `data.at(key, default: X)` is not a guard here: blank-filled render makes
65
+ every declared key present, so its `default:` is dead code and the blank
66
+ flows through. **Breaking**: `zero_value` → `blank`, `FieldSource::Zero` →
67
+ `Blank` and its wire token `"zero"` → `"blank"`; `""` declared in `values:`
68
+ is a load error (`quill::enum_blank_member`), the engine supplying the blank
69
+ instead; and `date: ""` renders blank rather than falling back to a
70
+ `default:`, settling a three-way disagreement between coercion, validation
71
+ and the floor. `default: ""` stays valid and keeps its meaning — `values:`
72
+ enumerates choices, `default:` is a value, and the blank is a legal value
73
+ that is never a choice. Additive: `ui.blank_title` labels an enum's blank and
74
+ rides the transform schema as `quillmark:blank_title`; that schema's `enum:`
75
+ leads with the blank, so a standard JSON-Schema validator accepts what the
76
+ engine accepts, and pdfform Choice widgets lead their options with it too. A
77
+ consumer's picker must keep the blank selectable and re-selectable — returning
78
+ to it is how an author clears a cell back to unset.
79
+ `integer`, `number` and `boolean` keep `0` / `false` as their blank,
80
+ indistinguishable from an authored zero — a permanent seam, since a wire
81
+ `none` would cost the totality the floor exists to buy. Full guide:
82
+ [0.104 → 0.105](docs/migrations/0.104-to-0.105.md) (#1254).
83
+ - fix(fixtures,docs): the three fixture plates that dispatch on `$kind` read it
84
+ with a bare `card.at("$kind")`, which panics on a kindless card, and guarded
85
+ declared fields against an absence blank-filled render makes impossible.
86
+ Plate authors copy the fixtures rather than `PLATE_DATA.md`, so the fixtures
87
+ were teaching both the unsafe metadata read and a dead presence check.
88
+ `classic_resume` carried the live consequence: `url` is declared with no
89
+ `default:`, so the floor delivers `""`, `default: none` never fires, and the
90
+ package's `url != none` test always passes — an empty Courier element where
91
+ the block should have been skipped. Its `subheading-*` guard cost an empty
92
+ grid row the same way. Declared fields now guard their *value*, and
93
+ `docs/quills/typst-backend.md` states the rule as a table over the three key
94
+ kinds. `fixture_quills_render_test` renders every fixture quill's seed
95
+ document — the net that was missing, since `classic_resume`'s plate had no
96
+ test reaching it (#1256, #1257).
97
+ - test(typst): `plaintext` reaches regions and navigation by inheritance — the
98
+ render floor coerces its resting literal to a content object, the backend
99
+ classifies that object by `contentMediaType` alone, and the shared lowering
100
+ emits it with a segment map — and every step was load-bearing and untested,
101
+ with the classification predicates named for richtext so the sharing read as
102
+ a coincidence. Pinned at engine altitude, because a test driving the backend
103
+ directly hand-builds the content object, bypassing the floor's coercion, and
104
+ would stay green through a regression that silently empties `regions()`. The
105
+ predicates are `is_content_field` / `is_content_array_field` /
106
+ `is_inline_content_field`, and `PREVIEW.md` names plaintext beside richtext
107
+ in its producer list (#1247, #1250).
108
+
109
+ ## v0.104.0 - 2026-08-13
110
+
111
+ - feat(core,wasm): a quill declares, per body, the block constructs its plate
112
+ does not typeset (`main.body.unsupported`, `card_kinds.<k>.body.unsupported`;
113
+ names from `heading`, `rule`, `code`, `list`, `quote`, `table`, `image`).
114
+ A body holding one anyway draws the non-fatal `plate::unsupported_construct`,
115
+ a fifth warning family, on the pre-render walk `Quill::parse` runs beside
116
+ `conform`: one diagnostic per (body, construct) carrying the count in `args`
117
+ and the body's path, so occurrences collapse rather than scatter. The
118
+ declaration also rides `QuillConfig::schema()` to the editor, which is the
119
+ half a render-time warning could not serve: it answers before the gesture.
120
+ Nothing verifies a declaration — a plate that drops an undeclared construct
121
+ stays as silent as before. `usaf_memo` declares `rule`; empty everywhere
122
+ else, so no existing quill's schema or warnings change.
123
+ - fix(fixtures): `usaf_memo`'s `render-body` drained its heading buffer in the
124
+ three shapes that used to discard it. A heading with nothing after it (the
125
+ buffer died with the loop, taking a list item's bullet with it), a heading
126
+ whose next element opened a *different* list item (its text was delivered
127
+ into that item), and a heading following a heading (the assignment overwrote
128
+ the earlier one) each lost their text with nothing in the render to say so.
129
+ The run-in style is unchanged where it was right: a heading joins the next
130
+ block of its own item, or the next paragraph at top level.
131
+ - fix(content)!: `to_markdown` writes `***` for a thematic break, not `---`.
132
+ `- ` + `---` is four dashes separated by spaces, which re-imports as a
133
+ top-level break, so a rule as a bullet item's first block lost its item on
134
+ every markdown round-trip. The canonical spelling is now the one with the
135
+ fewest other readings (`---` is also a setext underline and the root-block
136
+ front-matter opener). Exported markdown changes for documents holding a
137
+ rule; the content model, wire data and rendered output do not.
138
+ - refactor(core,pdfform,cli,wasm)!: the `enum:` modifier on `type: string`
139
+ retires. `type: enum` with a `values:` list is the one spelling of a finite
140
+ string domain; `enum:` on any type is now `quill::field_parse_error`, whose
141
+ message names the replacement — it is the only diagnostic a quill written
142
+ against the modifier ever received, since the deprecation shipped in 0.94
143
+ with no warning code behind it. `QuillConfig::schema()` re-emits every
144
+ domain as `values:`, so a consumer reading `enum:` off the schema echo (the
145
+ wasm `QuillFieldSchema.enum`, dropped here) reads `values:` instead. The
146
+ `usaf_memo` and `sample_form` fixtures migrate; wire data and rendered
147
+ output are unchanged, the projections being domain-keyed already.
148
+ - fix(core): `build_transform_schema` keys a field's finite domain on the
149
+ domain itself rather than the `Enum` token, joining the render floor, the
150
+ pdfform widget kind and the blueprint annotation. Under the retired
151
+ spelling every `usaf_memo` enum — `classification`, `format`, `action` —
152
+ projected as a bare `{"type":"string"}`, so a consumer building a
153
+ JSON-Schema validator from the transform schema accepted
154
+ `classification: "banana"` while pdfform drew the six-option dropdown for
155
+ the same field and `QuillConfig` rejected the value at coercion (#1237)
156
+ - fix(core)!: geometry addresses parse segment-wise, so `locate` and
157
+ `fieldBoxes` answer for an address deeper than one segment. The translation
158
+ boundary folded a plate address's whole tail into one `Field`, so
159
+ `references.0` minted `main.references.0` — a string that reparses as a field
160
+ literally named `0`, and that the reverse direction refused outright. Both
161
+ spellings returned `None`, leaving caret placement and whole-field highlight
162
+ dead for **every** `array<richtext>` element (the flagship memo's
163
+ `references` among them) and for every nested key a pdfform widget binds
164
+ (`address.city`). `region.rs` now reads and renders a plate tail one segment
165
+ at a time: an all-digit segment is an array index, `$body` the body terminal,
166
+ anything else a field or map key.
167
+ - change(wasm, python)!: `RenderedRegion.field`, `FieldRegion.field` and
168
+ `ContentHit.field` spell an array element bracketed — `main.references.0`
169
+ becomes `main.references[0]` — on `regions()`, `fieldAt`, `positionAt` and
170
+ `RenderResult.regions`. This is the spelling schema validation already emits,
171
+ so a `Diagnostic.path` and the geometry address for one place are now the same
172
+ string. A consumer finding an address's children by prefix (`startsWith(`${field}.`)`)
173
+ needs the `[` opener too, and any heuristic reading a trailing all-digit field
174
+ name as a lost index is dead.
175
+ - feat(wasm): `doc.pathFor(addr)` mints an `Addr` as the canonical `DocPath`
176
+ string `Diagnostic.path` carries and `session.locate` / `session.fieldBoxes`
177
+ take; `doc.cardPath(i)` is the card's own root. `Document` computed the
178
+ kind-qualified root for every addressed write and did not hand it out, so a
179
+ consumer building a path restated the kind lookup, the `Addr` defaults and the
180
+ range guard — and a wrong-kind path is compared as a string, matching nothing
181
+ and drawing no highlight without throwing. Both are quill-free (the stored
182
+ `$kind` verbatim) and total on the index axis: a path is an anchor, not a
183
+ read, so a per-keystroke call needs no `try` (#1225)
184
+ - change(wasm)!: `@quillmark/wasm` declares `engines: { node: ">=24" }`, the
185
+ tier CI builds and tests the bindings on and the one both devcontainers hand a
186
+ contributor. Nothing in the package requires it at runtime, so a Node 22
187
+ install fails `engines` checking without failing at import.
188
+ - docs: `docs/migrations/0.103-to-0.104.md` carries the four breaks — the
189
+ retired `enum:` modifier, the bracketed index spelling, the `***` thematic
190
+ break and the Node floor — with the prefix-match, trailing-digit and stored
191
+ -markdown shapes a consumer has to fix, and the two additive surfaces
192
+ (`pathFor` / `cardPath`, and the `plate::unsupported_construct` family a
193
+ code-routing consumer gains an arm for).
194
+ - test(core): three characterization tests pin the render floor's two
195
+ type-domain edges (a defaultless enum, top-level and nested in a typed
196
+ dictionary) and an authored empty `date` beside an empty `string`, so the
197
+ coercion difference between the two is one test's diff. Every shipped quill
198
+ declares a `default:` on every enum and none authors an empty `date`, so the
199
+ fixture suite reached neither path. A fourth carries a `!must_fill` tag on two
200
+ example-seeded cells through seed → store → load → conform. Refs #1234
201
+
3
202
  ## v0.103.0 - 2026-08-09
4
203
 
5
204
  - docs: `docs/integration/operations.md`, carrying what the other integration
@@ -50,32 +249,6 @@
50
249
  documents and stored blobs are unaffected. See
51
250
  `docs/migrations/0.102-to-0.103.md`
52
251
 
53
- <!-- seed: commits since v0.102.0, confirm the entries above cover them, then delete this comment
54
- - ci: tag the merge commit, and serialize a release against itself
55
- - ci: drop the tagger GitHub App from the release workflows
56
- - Cut SECURITY.md, and operations.md down to what only it can say
57
- - Drop the audit gate; keep the reachability finding in ci.yml's note
58
- - Fix two wrong claims, a dead property, and the stdout bug the review found
59
- - dense-prose pass over the added prose, and read the fixture once
60
- - Close four production-readiness gaps: audit gate, ops docs, CLI tests, PDF fuzzing
61
- - Drop the README's claim about the deleted sentinel patch
62
- - dense-prose pass: cut the restatement the change introduced
63
- - docs: the 0.102 → 0.103 step, and correct the surface it describes
64
- - The gate is the only door to the core surface
65
- - docs(migration): a span guide for 0.92 → 0.102
66
- - Cut over-specified style rules; author preference wins
67
- - dense-prose: remove the em-dash ban
68
- - dense-prose: drop the line-budget clause
69
- - dense-prose: wrong is worse than missing; relax em-dash ban
70
- - docs: dense-prose pass over the conform-scope and date-grammar prose
71
- - docs: migration guides are era-stamped, not immutable
72
- - docs(migrations): give the datetime split its corpus audit step
73
- - docs: scope conform to content fields, state the date corpus precondition
74
- - Tighten the failure-delivery prose
75
- - Deliver init's conflict as a rejection, not a synchronous throw
76
- -->
77
-
78
-
79
252
  ## v0.102.0 - 2026-08-04
80
253
 
81
254
  The pre-1.0 vocabulary reset. Verbs, diagnostic codes, and two words that meant
package/README.md CHANGED
@@ -6,12 +6,11 @@ Maintained by [TTQ](https://tonguetoquill.com).
6
6
 
7
7
  ## Overview
8
8
 
9
- Use Quillmark in browsers/Node.js with explicit in-memory trees (`Map<string, Uint8Array>` / `Record<string, Uint8Array>`).
9
+ Quillmark in browsers and Node, over explicit in-memory trees
10
+ (`Map<string, Uint8Array>` / `Record<string, Uint8Array>`).
10
11
 
11
- The package exposes **one import surface**:
12
-
13
- - `@quillmark/wasm` (the root), the **canonical API**: `init`, resolving to
14
- `Quill` and `Document`, and an `Engine` that renders them.
12
+ The package has one import surface: `@quillmark/wasm`, whose `init` resolves to
13
+ `Quill` and `Document`, plus an `Engine` that renders them.
15
14
 
16
15
  `Quill` and `Document` are the internal Typst-less core build's own classes,
17
16
  handed out verbatim by `init`, so editor/validation code (`Quill.fromTree`,
@@ -101,11 +100,8 @@ the same value again is fine, so several entry points may each
101
100
 
102
101
  **Both failures reject.** `runtime::init_conflict` and `runtime::init_failed`
103
102
  alike ride the returned promise, so one `catch` around `await init(...)` covers
104
- the gate. See [Errors](#errors) for the rule this follows.
105
-
106
- **You cannot forget.** The core surface has no static export, so a call site
107
- that skips the await has no name to call. The precondition is structural rather
108
- than a convention: no load order can make one entry point pass and another fail.
103
+ the gate. The core surface has no static export, so a call site that skips the
104
+ await has no name to call.
109
105
 
110
106
  **Vite's dev server** pre-bundles dependencies, which moves the package away
111
107
  from its binary. Exclude it:
@@ -136,29 +132,14 @@ depend only on `quill.backendId`, and answer from the descriptor's required
136
132
  `formats`/`canvas` manifest: never loading the multi-MB backend binary and
137
133
  never cloning the quill. Use them as non-failing pre-render probes.
138
134
 
139
- ### `Quill.fromTree(tree)`
140
- Build + validate a `Quill` from an in-memory tree. Pure: the declared backend
141
- is resolved at render time, not here. Loads no backend binary.
142
-
143
- ### `new Document(quillRef)`
144
- A blank document: a main card carrying only `$quill`, an empty body, and no
145
- composable cards: the programmatic blank canvas. Absent fields resolve at
146
- render time (schema `default`, else type-empty zero), so nothing the caller
147
- did not set reaches the output. Build it up with `storeFields` / `insertCard`.
148
- For an example-filled starter use `quill.seedDocument()`. Throws on an
149
- invalid quill reference.
150
-
151
- ### `Document.fromMarkdown(markdown)`
152
- Parse markdown to a parsed document, quill-free: the **transport door**
153
- (migrations, `$ext` stamping, a quill that will not load, opening a document to
154
- fix its `$quill`). Throws a JS `Error` (with `.diagnostics` attached, see
155
- [Errors](#errors)) on any parse failure, including a missing root `$quill`
156
- metadata line, malformed YAML, and inputs over the 10 MiB
157
- `parse::input_too_large` limit. A content field rests as authored; `quill.parse`
158
- below is the bound door that lands it at its canonical rest.
159
-
160
- ### `quill.parse(markdown)` / `quill.conform(doc)`
161
- The **bound door**, and the primary ingestion path. `quill.parse` is
135
+ ### The two doors: `Document.fromMarkdown` vs `quill.parse` / `quill.conform`
136
+
137
+ `Document.fromMarkdown` is the quill-free **transport door** (migrations, `$ext`
138
+ stamping, a quill that will not load, opening a document to fix its `$quill`).
139
+ It needs a root `~~~` block carrying a `$quill` line, and a content field rests
140
+ as authored.
141
+
142
+ `quill.parse` is the **bound door**, and the primary ingestion path. It is
162
143
  `Document.fromMarkdown` followed by `conform`: the returned document's declared
163
144
  content fields rest at one form per codec (a `richtext` field as the canonical
164
145
  content object, a `plaintext` field as its literal string), so `getStored`
@@ -181,61 +162,17 @@ const stale = Document.fromJson(row);
181
162
  const diags = quill.conform(stale); // converges in place
182
163
  ```
183
164
 
184
- ### `doc.toMarkdown()`
185
- Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
186
- `Document.fromMarkdown(doc.toMarkdown())` returns a document equal to `doc`
187
- under [`doc.equals`](#docequalsother). The output is **not** guaranteed
188
- byte-equal to the original source: YAML quoting, key ordering, and
189
- whitespace are normalised. Use `equals` (not string comparison) to test
190
- semantic equality.
191
-
192
- ### `doc.toJson()`
193
- Serialize the document to a versioned storage DTO: a JSON **string**
194
- carrying a `schema` version. Use this (not `toMarkdown`) to persist a
195
- document across a process restart or crate upgrade: the wire format is
196
- frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
197
- `warnings` are not part of the DTO.
198
-
199
- The string is produced inside the module by `serde_json`; the JS `JSON`
200
- global is not involved. It is standard JSON text, so callers may
201
- `JSON.parse` it to inspect it, but it is intended as an opaque blob you
202
- persist and hand back.
203
-
204
- `toJson()` is **deterministic**: a `Document` that is `equals` to another
205
- serializes to a byte-identical string: across repeated calls, and across
206
- any crate upgrade that keeps the same `schema` version (every release does until
207
- the `Document` model changes; see [Storage compatibility](#storage-compatibility-across-versions)).
208
- Field order is fixed and object key order is preserved, so content hashes
209
- and string-equality dirty-checks over the output are stable.
210
-
211
- ### `Document.fromJson(json)`
212
- Reconstruct a `Document` from a storage DTO string produced by `toJson`.
213
- Round-trips losslessly:
214
-
215
- ```ts
216
- const stored = doc.toJson(); // persist this string
217
- const restored = Document.fromJson(stored);
218
- restored.equals(doc); // true
219
- ```
220
-
221
- Throws a JS `Error` on malformed JSON, an unknown `schema` version, or a
222
- malformed payload. The restored document has no parse-time `warnings`.
165
+ ### Storage compatibility across versions
223
166
 
224
- ### `Document.tryFromJson(json)`
225
- Like `fromJson`, but returns `undefined` instead of throwing when `json` is
226
- not a valid storage DTO. Use it to branch on format without a heuristic or
227
- `try`/`catch` as control flow:
167
+ Persist `doc.toJson()`, not `doc.toMarkdown()`: the DTO wire format is frozen
168
+ per `schema` version, whereas Markdown syntax evolves, and `toMarkdown` output
169
+ is normalised rather than byte-equal to the source. `Document.tryFromJson`
170
+ discriminates the two formats without exceptions as control flow:
228
171
 
229
172
  ```ts
230
- // "JSON canonical, Markdown fallback": no exceptions, no string sniffing
231
173
  const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
232
174
  ```
233
175
 
234
- `undefined` means only "not a storage DTO"; `fromMarkdown` still throws on
235
- genuinely malformed Markdown.
236
-
237
- ### Storage compatibility across versions
238
-
239
176
  The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
240
177
  not the running crate version. It is a hand-set constant, bumped only when
241
178
  the `Document` model itself changes, so every `0.93.x` patch release reads
@@ -269,53 +206,17 @@ In short: persist the `toJson` string, upgrade freely, never downgrade. The
269
206
  full design (including how migrations are added) is in
270
207
  `prose/canon/DOCUMENT_STORAGE.md`.
271
208
 
272
- ### `doc.equals(other)`
273
- Structural equality between two `Document` handles. Compares `main` and
274
- `cards` by value; parse-time `warnings` are intentionally excluded.
275
-
276
- Use this to debounce upstream prop updates: keep the last parsed `Document`
277
- and compare instead of re-parsing on every keystroke.
278
-
279
- ### `doc.cardCount`
280
- O(1) getter for the number of composable cards (excluding the main card).
281
- Use this to validate indices before calling card mutators (`removeCard`,
282
- `storeField({ card, field }, …)`, etc.) without allocating the full `cards` array.
283
-
284
- ### `quill.validate(doc)`
285
-
286
- Returns `Diagnostic[]`: the document validated against the quill schema,
287
- without invoking the backend. An empty array means the document is valid.
288
- Each diagnostic carries the canonical `validation::*` `code`, `path`, and
289
- `hint`. Includes the non-fatal `validation::must_fill` warning for each
290
- `!must_fill` marker left in the document (render zero-fills these rather
291
- than failing), so filter by `severity`/`code` for blockers vs. hints:
292
-
293
- ```ts
294
- const diagnostics = quill.validate(Document.fromMarkdown(markdown));
295
- const errors = diagnostics.filter(d => d.severity === "error");
296
- ```
209
+ ### Cards, seeds, and addresses
297
210
 
298
211
  To render a form editor, read field definitions from `quill.schema` (walk
299
212
  `fields` in key order: declaration order is display order) and the authored
300
213
  values from the `Document` payload: there is no separate form-view projection.
214
+ `quill.validate(doc)` scores it without invoking the backend.
301
215
 
302
- ### `quill.seedDocument()`
303
-
304
- Returns a starter `Document` seeded from the schema: each field's `example:`
305
- is committed and every other field is left absent (the render layer fills
306
- `default:` → type-empty zero). Illustration-first: a field with both an
307
- `example` and a `default` renders its example. Use as the initial state for a
308
- "new document" editor.
309
-
310
- ```ts
311
- const doc = quill.seedDocument();
312
- const markdown = doc.toMarkdown();
313
- ```
314
-
315
- For per-card seeding, `quill.seedMain()` returns just the `$kind: main` card
316
- and `quill.seedCard(kind)` returns a starter composable card (or `undefined`
317
- if the kind is not declared). Both return the read `Card` shape of
318
- `doc.main` / `doc.cards`, which `doc.insertCard` accepts directly:
216
+ `quill.seedDocument()` returns a starter document with each field's `example:`
217
+ committed; `quill.seedMain()` and `quill.seedCard(kind)` seed one card. All
218
+ return the read `Card` shape of `doc.main` / `doc.cards`, which `doc.insertCard`
219
+ accepts directly:
319
220
 
320
221
  ```ts
321
222
  doc.insertCard(quill.seedCard("note")); // seed → append
@@ -412,7 +313,7 @@ write.
412
313
 
413
314
  #### `DocumentReader` / `CardReader`: the read twin
414
315
 
415
- `quill.reader(doc)` carries the writer's ephemerality and its schema authority:
316
+ `quill.reader(doc)` carries the writer's ephemerality and schema authority:
416
317
 
417
318
  ```ts
418
319
  const v = quill.reader(doc);
@@ -439,24 +340,10 @@ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
439
340
  on throw, every read keeps serving the last-good compile. Don't open a session
440
341
  per export, and don't re-open per edit: `apply` instead.
441
342
 
442
- ### `engine.render(quill, parsed, opts?)`
443
- Render a pre-parsed `Document` against `quill`. Throws an
444
- `engine::backend_not_found` error if no registered backend matches the quill's
445
- declared backend.
446
-
447
- ### `engine.open(quill, parsed)` + `session.render(opts?)`
448
- Open once, render all or selected pages (`opts.pages`).
449
-
450
- The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
451
- `warnings` (non-fatal diagnostics of the current compile: set at `open`,
452
- refreshed by each committed `apply`),
453
- `apply(doc)` for in-place recompiles, `pageSize(page)`, and
454
- `paint(ctx, page, opts?)` for canvas previews. See below.
455
-
456
343
  A document that compiles to zero pages still produces a valid session
457
- (`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw
458
- `page index 0 out of range (pageCount=0)`. Branch on `pageCount === 0` to
459
- render a "no pages to preview" UI without relying on the throw.
344
+ (`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw. Branch on
345
+ `pageCount === 0` to render a "no pages to preview" UI rather than relying on
346
+ the throw.
460
347
 
461
348
  ### Canvas Preview
462
349
 
@@ -480,58 +367,47 @@ canvas.style.width = `${result.layoutWidth}px`;
480
367
  canvas.style.height = `${result.layoutHeight}px`;
481
368
  ```
482
369
 
483
- - `layoutScale` (default 1) sets the canvas's display-box size:
484
- `layoutWidth = widthPt * layoutScale`. For on-screen canvases this is
485
- CSS pixels per point. Defaults to 1 (one CSS pixel per pt).
486
- - `densityScale` (default 1) is the backing-store density multiplier.
487
- Fold `window.devicePixelRatio`, in-app zoom, and `visualViewport.scale`
488
- (pinch-zoom) into a single value here. Pass `devicePixelRatio` for
489
- crisp output on high-DPI displays.
490
- - The effective rasterization scale is `layoutScale * densityScale`. If
491
- that would exceed the safe maximum (16384 px per side), `densityScale`
492
- is clamped proportionally; `result.clamped` reports it and
493
- `result.effectiveDensityScale` is the density actually applied. A
494
- clamped page renders soft at the same `canvas.style` size.
495
- - `paint` writes the whole backing store with `putImageData`, which
496
- ignores the 2D context transform, `globalAlpha`, and clip. Give each
497
- visible page its own `<canvas>` element: you cannot composite two pages,
498
- a sub-rect, or a context transform through `paint`.
499
- - `paint` is always a full repaint: setting the backing-store width /
500
- height clears it. No `clearRect` required. Each call re-rasterizes from
501
- scratch (no per-page raster cache), so keep a page's canvas alive while
502
- it stays near the viewport rather than pooling one canvas across pages:
503
- an idle canvas retains its pixels for free, whereas reusing a canvas on
504
- scroll re-runs a full render.
505
- - `pageCount` and `pageSize(page)` are stable for the session's
506
- lifetime (immutable snapshot): cache them.
507
- - Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
508
- same call signature works. `layoutWidth` / `layoutHeight` are
509
- informational in that mode (no CSS layout box); fold everything into
510
- `densityScale`. Loading the WASM module inside a Worker is the host's
511
- responsibility.
512
- - Backend support: gated by `supportsCanvas`. Probe upfront with
513
- `engine.supportsCanvas(quill)` (or `session.supportsCanvas`) before mounting
514
- a canvas-based UI; the throw on `paint` / `pageSize` remains the
515
- enforcement contract and includes the resolved `backendId` for
516
- debugging.
370
+ - `layoutScale` sets the display-box size (`layoutWidth = widthPt * layoutScale`);
371
+ fold `devicePixelRatio`, in-app zoom, and `visualViewport.scale` into
372
+ `densityScale`. Their product is the rasterization scale, clamped at 16384 px
373
+ per side (`result.clamped`, `result.effectiveDensityScale`).
374
+ - `paint` writes the whole backing store with `putImageData`, which ignores the
375
+ 2D context transform, `globalAlpha`, and clip. Give each visible page its own
376
+ `<canvas>`: no compositing, sub-rect, or transform reaches through `paint`.
377
+ - `paint` is always a full repaint, and there is no per-page raster cache. Keep
378
+ a page's canvas alive while it stays near the viewport: an idle canvas retains
379
+ its pixels for free, whereas pooling one canvas across pages re-renders on
380
+ every scroll.
381
+ - `pageCount` and `pageSize(page)` are stable for the session's lifetime: cache
382
+ them.
383
+ - In a Worker, pass an `OffscreenCanvasRenderingContext2D`; the layout
384
+ dimensions are informational there. Loading the WASM module inside the Worker
385
+ is the host's responsibility.
386
+ - Backend support is gated by `supportsCanvas`. Probe upfront with
387
+ `engine.supportsCanvas(quill)`; the throw on `paint` / `pageSize` remains the
388
+ enforcement contract and names the resolved `backendId`.
517
389
 
518
390
  ### Schema model
519
391
 
520
- A field's *cell* is inferred from whether its schema declares a `default:`:
392
+ A field carries two independent axes, and no `required` one.
393
+
394
+ **Value** — what the cell holds. With a `default:`, `quill.blueprint` renders
395
+ that value under a type-only `# <type>` annotation and the render path uses it
396
+ when the document omits the field. Without one, an `example` takes the cell as
397
+ a suggested value, and an absent field blank-fills.
521
398
 
522
- - **Unendorsed** (no `default:`): `quill.blueprint` renders the
523
- `!must_fill` marker in the value cell (carrying the field's `example` as a
524
- suggested value when one exists). An absent Unendorsed field zero-fills
525
- silently. A `!must_fill` marker left in the document is non-fatal: it emits
526
- the `validation::must_fill` warning and still renders. Partial documents
527
- are accepted; `engine.render(quill, doc)` only throws for malformed
528
- input.
529
- - **Endorsed** (with `default:`): `quill.blueprint` renders the
530
- default value with a type-only `# <type>` annotation (shippable as-is),
531
- and the default is used when the document omits the field.
399
+ **Obligation** whether a human must author the field, declared by
400
+ `must_fill:` and deriving from `default:`'s absence when left unset. An obliged
401
+ field carries the `!must_fill` marker in `quill.blueprint`, and
402
+ `quill.validate(doc)` emits the non-fatal `validation::must_fill` warning while
403
+ the document leaves it unauthored from either of two triggers, named by the
404
+ diagnostic's `trigger` arg: `marker` for a marker the document still carries,
405
+ `unauthored` for a cell the schema obliges and the document never filled.
406
+ Authoring the field's blank discharges the obligation; clearing the key does
407
+ not.
532
408
 
533
- `QuillFieldSchema` has no `required` axis. A `!must_fill` marker left in the
534
- document emits the non-fatal `validation::must_fill` warning.
409
+ Neither axis gates render. Partial documents are accepted, and
410
+ `engine.render(quill, doc)` throws only for malformed input.
535
411
 
536
412
  ### Errors
537
413
 
@@ -562,16 +438,12 @@ guard.
562
438
 
563
439
  `QuillmarkError` is a **structural interface, not a class**: the WASM layer
564
440
  throws a real `Error` and attaches the property, so there is no constructor to
565
- `instanceof` against; narrow with `isQuillmarkError` (which also works on
566
- errors from any build or WASM instance in the page).
441
+ `instanceof` against. Narrow with `isQuillmarkError`, which also works on errors
442
+ from any build or WASM instance in the page.
567
443
 
568
444
  `diagnostics` is always non-empty: length 1 for most failures, length N for
569
- backend compilation errors. `message` is derived from `diagnostics`
570
- (`diagnostics[0].message` for single-diagnostic errors; an aggregate
571
- `"<N> error(s): <first.message>"` summary for compilation failures).
572
-
573
- Read `err.diagnostics[0]` for the primary diagnostic; iterate the array for
574
- compilation failures. The same shape applies to every throw site:
445
+ backend compilation errors, and `message` is derived from it. The same shape
446
+ applies to every throw site:
575
447
 
576
448
  - `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
577
449
  errors, `parse::input_too_large` for inputs > 10 MiB).
@@ -582,6 +454,9 @@ compilation failures. The same shape applies to every throw site:
582
454
  text.
583
455
  - `engine.render` / `session.render`: backend compilation failures and
584
456
  validation errors.
457
+ - `engine.render(quill, parsed)` against a quill whose *name* differs
458
+ (`quill::name_mismatch`) or whose *version* falls outside the document's
459
+ selector (`quill::version_mismatch`): a throw, never a warning.
585
460
  - Any method taking a `Quill` or `Document`: a handle from a *second* copy of
586
461
  `@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
587
462
  @quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
@@ -604,11 +479,11 @@ call returns: `try { return engine.render(quill, doc); } finally
604
479
  { doc.free(); }`: is safe even on the first render, while the backend
605
480
  binary is still loading.
606
481
 
607
- The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
482
+ The package floor is Node 24+ (`engines: { node: ">=24" }`) and current
608
483
  evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
609
- sugar ([explicit resource management][erm]) needs Node 24 and is optional.
610
- Where it hasn't landed, an explicit `try` / `finally` runs on the Node 22
611
- floor:
484
+ sugar ([explicit resource management][erm]) is on that floor and optional;
485
+ an explicit `try` / `finally` is the equivalent, and the form that also runs
486
+ in a browser that hasn't shipped it:
612
487
 
613
488
  ```ts
614
489
  const session = await engine.open(quill, doc);
@@ -623,15 +498,6 @@ try {
623
498
 
624
499
  [erm]: https://github.com/tc39/proposal-explicit-resource-management
625
500
 
626
- ## Notes
627
-
628
- - Parsed markdown requires a root `~~~` block (a bare three-tilde fence;
629
- `~~~card-yaml` is also accepted as a non-canonical alias)
630
- with a `$quill` system-metadata line. Empty input surfaces a dedicated
631
- "Empty markdown input cannot be parsed" message.
632
- - A `$quill` mismatch during `engine.render(quill, parsed)` is a thrown error, not a warning: rendering with a quill whose *name* differs (`quill::name_mismatch`) or whose *version* falls outside the selector (`quill::version_mismatch`) is rejected.
633
- - Output schema APIs live on `Quill`, not the engine.
634
-
635
501
  ## Changelog
636
502
 
637
503
  See the [changelog](https://github.com/borb-sh/quillmark/blob/main/CHANGELOG.md)