@quillmark/wasm 0.104.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,111 @@
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
+
3
109
  ## v0.104.0 - 2026-08-13
4
110
 
5
111
  - feat(core,wasm): a quill declares, per body, the block constructs its plate
@@ -93,33 +199,6 @@
93
199
  fixture suite reached neither path. A fourth carries a `!must_fill` tag on two
94
200
  example-seeded cells through seed → store → load → conform. Refs #1234
95
201
 
96
- <!-- seed: commits since v0.103.0, confirm the entries above cover them, then delete this comment
97
- - Carry the thematic-break and unsupported-construct work into the guide
98
- - Dense-prose pass over the new comments and docs
99
- - Let a quill declare the constructs its plate does not typeset
100
- - Drain the memo's heading buffer instead of dropping it
101
- - Canonicalize a thematic break to `***`
102
- - Close the 0.104 release gaps: the migration guide, and four changelog entries
103
- - Retire the enum: modifier; project a field's domain by capability
104
- - Pin the render floor's two type-domain edges and the seeded fill tag
105
- - Raise the Node floor to 24
106
- - dense-prose pass over the added prose
107
- - docs: dense-prose pass on the text-type sections
108
- - docs: give the text-type choice a decision procedure
109
- - docs: state the backend seam's scope and reserve its namespaces
110
- - docs: drop the private intra-doc link from pathFor
111
- - fix(core): state the unspellable-tail conditions instead of linking a private fn
112
- - chore(wasm): density pass, and gate a core-build dead function
113
- - docs: dense-prose pass over the geometry-address translation
114
- - docs: dense-prose pass over the pathFor / cardPath prose
115
- - test(wasm): execute the static-export exemption instead of listing it
116
- - feat(wasm): mint a DocPath from an Addr (#1225)
117
- - fix(core)!: parse geometry addresses segment-wise (#1223)
118
- - docs: carry the 0.103 CLI break, and the init gate into the quickstart (#1222)
119
- - SCHEMAS.md: a declared type change rewrites stored values (#1221)
120
- -->
121
-
122
-
123
202
  ## v0.103.0 - 2026-08-09
124
203
 
125
204
  - docs: `docs/integration/operations.md`, carrying what the other integration
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`
@@ -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)