@quillmark/wasm 0.115.0 → 0.117.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,289 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.117.0 - 2026-09-29
4
+
5
+ Upgrade path: [0.116 → 0.117](docs/migrations/0.116-to-0.117.md).
6
+
7
+ ### `example:` leaves the quill schema
8
+
9
+ - feat(core,wasm,python)!: **a `Quill.yaml` declaring `example:` fails to
10
+ load.** A slot declares one value, its `default:`. `example:` on a field at
11
+ any depth fails `quill::field_parse_error`, hinted to `description:`, and
12
+ `body.example` fails `quill::invalid_body`. The `quill::example_*` checks
13
+ and `quill::body_example_*` codes are deleted, and
14
+ `quill::richtext_example_import` is `quill::richtext_default_import`. The
15
+ blueprint drops its `# e.g.` and `# body e.g.` lines but a matrix's member
16
+ hint, `schema()` carries no `example`, and `FieldSchema::example`,
17
+ `BodyCardSchema::example`, WASM `QuillFieldSchema.example` and
18
+ `QuillCardBody.example` are deleted. (#1999)
19
+
20
+ ### The example document
21
+
22
+ - feat(core,cli,wasm,python)!: **`example.md` at the quill root is the
23
+ quill's example**, a filled-in page whose values are made up.
24
+ `Quill.yaml` declares nothing about it and the load never reads it.
25
+ `Quill::example_document()`, WASM `Quill.exampleDocument()` and Python
26
+ `Quill.example_document()` hand it out pinned to the quill's
27
+ `name@version`, or `None` without one; a `$quill` other than the quill's
28
+ bare name fails `quill::example_reference`. `quillmark` re-exports
29
+ `EXAMPLE_FILE`. `quillmark validate` reads, validates and renders it, and
30
+ fails the quill on any diagnostic it carries, a warning included
31
+ (`cli::example_not_clean`, `cli::example_render_failed`): a quill keeping a
32
+ root `example.md` for another purpose renames it. `status_report` and
33
+ `usaf_memo` ship one. (#2002, #2007, #2009)
34
+
35
+ ### Quill references
36
+
37
+ - refactor(core)!: **`$quill: name@latest` fails as
38
+ `parse::invalid_quill_reference`.** It was a second spelling of the bare
39
+ `name`, which matches the same versions. A selector is `MAJOR`,
40
+ `MAJOR.MINOR` or `MAJOR.MINOR.PATCH`, digits only, the grammar
41
+ `@quillmark/quiver`'s `parseQuillRef` enforces. `VersionSelector::Latest`
42
+ is `VersionSelector::Any`, and `QuillReference::latest` is deleted.
43
+ (#2000)
44
+ - refactor(core)!: `VersionSelector` has no `Display`: `Any` has no written
45
+ form outside a reference. `QuillReference`'s `Display` still writes `name`
46
+ or `name@selector`. (#2008)
47
+
48
+ ### Quill headers the load refuses
49
+
50
+ - fix(core)!: **`version: ""`, a tagged version such as `!!float 1.10`, and
51
+ a card kind named `main` fail to load**, as `quill::invalid_version` and
52
+ `quill::invalid_card_name`. None could hold what it seemed to. (#1994)
53
+ - fix(core)!: a `title` key on a matrix member in a document warns
54
+ `validation::unknown_field` at `<field>.<member>.title`: the member's title
55
+ is the quill's, and `quillmark check --strict` fails such a document.
56
+ (#1994)
57
+
58
+ ### Rendering
59
+
60
+ - fix(typst)!: **a quill shipping no fonts renders in Roboto**, where it
61
+ rendered in Figtree, which has no prime: a `"` or `'` after a digit in
62
+ richtext rendered as an empty box. The faces are variable Roboto, upright
63
+ and italic, weights 100 to 900. A font-less quill's text reflows, a plate
64
+ naming an unshipped `"Figtree"` warns `unknown font family`, and
65
+ `quillmark workspace` exports `fonts/Roboto.ttf` and
66
+ `fonts/Roboto-Italic.ttf`. (#2010)
67
+
68
+ ### The CLI
69
+
70
+ - fix(cli): `quillmark check` locates a diagnostic in the file it came from,
71
+ where every location read `input.md`; `quillmark` re-exports
72
+ `DOCUMENT_FILE`. (#1995)
73
+ - fix(cli): a missing path given to `quillmark check` draws
74
+ `cli::unreadable_document`, as an unreadable one does, where it stopped
75
+ the run; the remaining paths are still checked. (#1994)
76
+
77
+ ### Fixes
78
+
79
+ - fix(core): a field parse hint answers the key serde refused, at every
80
+ depth. (#1999)
81
+
82
+ ## v0.116.0 - 2026-09-26
83
+
84
+ Upgrade path: [0.115 → 0.116](docs/migrations/0.115-to-0.116.md).
85
+
86
+ ### What blocks a render
87
+
88
+ - feat(core)!: **a card no kind claims, and a body under `body.enabled: false`,
89
+ render and warn.** A render fails only where the engine would have to invent
90
+ what the author wrote: a value that will not read as its declared cell's
91
+ type, or markup the grammar cannot read. Input no declaration claims
92
+ displaces nothing, so it renders and warns; SCHEMAS.md § "What blocks a
93
+ render" sorts every validation code into incomplete, malformed and
94
+ unclaimed. `validation::unknown_card` and `validation::body_disabled` are
95
+ `Severity::Warning` from `Quill::validate`, `unknown_card` carries
96
+ `allowed`, and an unclaimed card keeps its place in `$cards` with its
97
+ fields verbatim. `ValidationError::UnknownCard` and `BodyDisabled` are
98
+ deleted. This is the release's silent break: a pipeline that relied on the
99
+ render failing catches nothing. (#1877)
100
+ - feat(core)!: **an undeclared key warns `validation::unknown_field`**, at
101
+ every depth of a claimed card: a card field, a typed dictionary's property,
102
+ an array element's, a matrix member's column, and a cell inside a variant
103
+ container no world declares. `args` carry `suggestion`, the closest declared
104
+ field the document leaves unwritten, or `container` and `variant` for a
105
+ variant cell written beside its discriminant rather than under it. (#1871)
106
+ - feat(quillmark,cli)!: **a one-shot render carries validate's warnings.**
107
+ `Quillmark::render`, WASM `engine.render` and Python `render` put every
108
+ `Quill::validate` warning on the result's `warnings` ahead of the compile's,
109
+ so the unclaimed input the page left out is on the result a pipeline reads.
110
+ A session's warnings stay its current compile's. (#1931)
111
+ - feat(core)!: **a card-yaml block with no `$kind` fails the parse** as
112
+ `parse::missing_kind`, located at its opener, with a hint naming both fixes:
113
+ a `$kind:` line, or a backtick fence. Such a block was a card missing its
114
+ kind line or tilde-fenced code, and reading it as either was a guess. Every
115
+ card now carries `$kind`: `ResolvedCard.kind` is `String` (`string` in
116
+ TypeScript), and a stored card with no `$kind` folds into the body above it
117
+ on load, its payload as a code block, then its body. (#1905)
118
+ - feat(core)!: **a card-yaml payload that is not a mapping fails at its
119
+ fence** as `parse::payload_not_mapping`, located at the opener, with `actual`
120
+ and `info` args and a hint naming the backtick fence, where it was an
121
+ unlocated `parse::invalid_structure` on a card and `parse::missing_quill` on
122
+ the root. (#1873)
123
+
124
+ ### `!must_fill` is removed
125
+
126
+ - refactor(core)!: **`!must_fill` and `validation::must_fill` are gone.** The
127
+ marker said a cell held a placeholder rather than an answer, and the schema
128
+ half of the warning read `default:`'s absence as an obligation. `default:`
129
+ is the value an unanswered cell renders, `?` moves the render floor, and
130
+ null ≡ absent holds on every surface. A value under the marker keeps its
131
+ value: in markdown the tag drops as any unknown tag does, warned
132
+ `parse::unsupported_yaml_tag` at the tagged node's rooted path (which that
133
+ code now carries), and a stored row of any earlier tag is read past its
134
+ `fill` keys. Deleted: `store_fill`,
135
+ `is_fill`, the `QuillValue` fill set, `FieldSchema::{must_fill,
136
+ example_content}`, `edit::fill_on_mapping`,
137
+ `parse::fill_marker_unsupported_position`, WASM `storeFill` / `isFill`, and
138
+ the card wire's `fill` / `nestedFills`. (#1906, #1933)
139
+ - refactor(core)!: **the storage tag is `quillmark/document@0.116.0`.** A
140
+ field item carries no `fill` or `nested_fills`, and a row carrying either,
141
+ or any key its type does not name at the document, card, payload or item
142
+ level, fails the load, as does a nested comment spanning lines, which
143
+ `to_markdown` would have written as bare YAML. Every earlier tag still
144
+ reads. A row's bytes move on its next save, so content hashes
145
+ recompute and a 0.115 build refuses the row from then on.
146
+ - feat(core,wasm,python)!: **a card-wire payload item refuses an unknown
147
+ key.** A 0.115 integration still sending `{value, fill: true}` committed the
148
+ placeholder as an answer, and an item typo vanished; `insertCard` throws and
149
+ `insert_card` raises `ValueError` instead. (#1933)
150
+ - feat(core): **a `?` on a cell's type renders it `none` when unanswered**,
151
+ so a plate can tell "0 votes against" from "nobody entered a tally". `?` and
152
+ `default:` are exclusive (`quill::optional_default`), a namespace takes no
153
+ `?` (`quill::optional_namespace`), and the transform schema projects
154
+ `type: [t, "null"]`. (#1880)
155
+
156
+ ### The blueprint and the seed
157
+
158
+ - feat(core)!: **seeding commits no `example:`.** An example documents a
159
+ cell's shape rather than answering it, and `body.example` is guide text
160
+ quills write as instructions. `seed_document`, `seed_main` and `seed_card`
161
+ commit the `$seed` overlay alone; every other body is empty and every other
162
+ field absent. (#1923)
163
+ - feat(core,wasm,python)!: **the blueprint answers nothing on the author's
164
+ behalf.** A defaultless cell is empty (`title: # string`) with its
165
+ `example:` on a `# e.g.` line above it; every body is empty, a
166
+ `body.example` riding a `# body e.g.` line that closes the payload and a
167
+ kind under `body.enabled: false` closing with `# no body`; a card's
168
+ description rides its `$kind` line; a `default: []` table is led by the
169
+ whole field commented out holding its row; and a matrix names its columns
170
+ in an `# e.g.` line, a column with no example showing its
171
+ `<type>[<format>]`. (#1874, #1942)
172
+ - feat(core,wasm,python)!: **`FORMAT_RULES` is `MARKDOWN_RULES`**, headed
173
+ `Quillmark Markdown rules:` (`markdownRules` / `markdown_rules`), and keeps
174
+ only what departs from plain Markdown and YAML. `blueprint_instruction` is
175
+ deleted in every binding: a prompt is the consumer's to write. (#1942)
176
+ - feat(core)!: **`to_markdown` writes a multi-line string as a `|` literal
177
+ block** (`|-` without a trailing newline), at every depth, where it wrote
178
+ one double-quoted line. A string a block would not hold verbatim stays
179
+ quoted. The value round-trips unchanged; the markdown bytes move. (#1883)
180
+
181
+ ### The quill authoring contract
182
+
183
+ - feat(core)!: **a field's and a card kind's label is a top-level `title`**,
184
+ beside `description`, which `schema()` emits and the blueprint prints as
185
+ `# <title> — <description>`. `ui.title` fails the load, a `{field}` token
186
+ in a `title` is `quill::title_template`, and a `title` on an array's `items`
187
+ is `quill::title_on_items`. (#1922)
188
+ - refactor(core,wasm)!: **`body.unsupported` is removed**, with
189
+ `plate::unsupported_construct`, `Quill::unsupported_constructs` and the WASM
190
+ `QuillBlockConstruct`. Nothing verified a declaration against the plate and
191
+ no editor read it; a `Quill.yaml` still declaring it fails
192
+ `quill::invalid_body`.
193
+ - fix(core)!: **an unquoted `quill.version` reads as written**: `version:
194
+ 1.10` is `1.10`, where it loaded as `1.1`, and `1.1e1` fails
195
+ `quill::invalid_version`.
196
+ - feat(core)!: **a `plaintext(inline)` value ending in a newline names `|-`
197
+ as the fix.** `validation::not_inline`, `edit::field_not_inline` and
198
+ `conform::field_not_inline` carry `trailingNewline: true`, and
199
+ `ValidationError::NotInline` / `EditError::FieldNotInline` gain
200
+ `trailing_newline`. (#1918)
201
+ - fix(core): `quill::invalid_name` suggests a name the loader accepts.
202
+
203
+ ### The render date
204
+
205
+ - feat(core)!: **the host supplies the render date; a `date` field may hold
206
+ `today`.** The engine reads no clock. `Quillmark::open` / `render`,
207
+ `Quill::compile_data` / `compile_checked` / `resolve`,
208
+ `QuillConfig::compile_data` / `compile_checked`, `TypedReader::resolve` and
209
+ `Backend::open` take a required `today: CalendarDate`; a plate's
210
+ `datetime.today()` returns it whatever its `offset:`. The CLI (`--today`)
211
+ and the WASM and Python bindings default to the local date. A `date` value
212
+ is `YYYY-MM-DD` or `today`, and the blueprint annotates `date<YYYY-MM-DD |
213
+ today>`. `CalendarDate`'s parse fails with `ParseDateError`, which tells a
214
+ day the calendar lacks from a string that is no date. (#1900, #1930)
215
+ - feat(fixtures)!: `usaf_memo`'s blank `date` renders a fill-in field where it
216
+ rendered today's date.
217
+
218
+ ### Plates and the Typst backend
219
+
220
+ - feat(typst)!: **a dictionary carries the `$ink` twin its fields print
221
+ through.** A scalar printed off `data` kept its click target only as a
222
+ direct read in the plate. `data`, each card, and each typed dictionary or
223
+ table row carry `$ink` and `$path` wherever a field has ink: compute with the
224
+ dictionary, print its ink (`ink(row).org`). A date's ink prints its default
225
+ display, and `display(row, "due", "[year]")` formats one by dictionary and
226
+ key. The keys are visible to `keys()`, `pairs()`, `len()`, `==` and a
227
+ spread. (#1868)
228
+ - feat(typst)!: **a plate imports the quill's `.typ` files by Typst's path
229
+ rules.** The plate loads at its `plate_file` path and every `.typ` file
230
+ outside `packages/` beside it, so a bare path resolves from the importing
231
+ file and a `/`-rooted one from the quill root. Plate diagnostics name the
232
+ declared `plate_file`, where they named `main.typ`, and a vendored package's
233
+ file under its spec (`@local/p:0.1.0/lib.typ`), apart from a quill module
234
+ of the same path. (#1869)
235
+ - feat(typst): **a `typst:` key the backend does not read warns
236
+ `typst::unknown_key`**, `typst.packages` included; a package is vendored
237
+ under `packages/`, never fetched. A package manifest without `version` or
238
+ `entrypoint` is skipped at load.
239
+ - refactor(typst): the `when` / `value-or` plate helpers are dropped; a plate
240
+ reads an optional cell with `!= none`.
241
+ - fix(typst): a negative scalar's ink lexes, and region queries stay linear.
242
+
243
+ ### Rendering, the CLI and the bindings
244
+
245
+ - feat(core)!: **one raster ceiling, 16384 px a side**, `MAX_RASTER_SIDE`,
246
+ owned by core, where core refused past 16384² px of area and the WASM
247
+ painter clamped by side on its own. A PNG export long on one axis that fit
248
+ by area now fails `backend::invalid_raster_scale`.
249
+ - feat(wasm)!: **`paint(ctx, page, scale)` takes one scale and returns
250
+ nothing**; `PaintOptions` and `PaintResult` are deleted, and a missing or
251
+ non-finite scale throws `backend::invalid_raster_scale`.
252
+ - feat(cli): **`quillmark workspace` exports a plate for Typst's own
253
+ tooling**: the generated helper for one document, every vendored package
254
+ and the backend's fonts, with the `typst watch` command over them. A rerun
255
+ replaces the `packages/` and `fonts/` an earlier export wrote. The facade
256
+ re-exports it as `quillmark::typst_workspace`. (#1869)
257
+ - feat(cli): **`quillmark check` prints a document's every diagnostic**, and
258
+ `--strict` exits `1` on a warning, for a CI gate over a repository of
259
+ documents; `render` prints the warnings for what the page leaves out.
260
+ (#1872)
261
+ - fix(cli)!: `render` takes its format from an `-o` extension naming one, and
262
+ a `-f` the extension contradicts exits `1` before writing.
263
+ - feat(core,wasm,python): `Quill.emptyDocument()` / `Quill.empty_document()`
264
+ reach the bindings, and WASM `isInline` / `isPlain` export the content
265
+ predicates. (#1919)
266
+
267
+ ### Fixes
268
+
269
+ - fix(core): the YAML prescan keeps comments, tags and anchors with the value
270
+ they sit on across continuation lines, dash lines, block scalars, multi-line
271
+ flow collections, a value below its key, nested keys with an inner `:` or a
272
+ leading `-`/`?`, and a removed item's trailing comment; a `$seed` overlay edit keeps the comment on
273
+ its `$seed` line.
274
+ - fix(core): `to_markdown` projects a content object at every depth of a
275
+ field's value, and a container under a sequence item's first key
276
+ round-trips.
277
+ - fix(core): a folded island past `isl-{u64::MAX}` mints a fresh id, and a
278
+ folded card's `$ext` stays out of the body.
279
+ - fix(core): the unknown-key walk stays linear in a mapping's keys.
280
+ - fix(core): a `$seed.<kind>.$body` for a kind under `body.enabled: false`
281
+ warns `validation::seed_unknown_field`, where the seed dropped it silently.
282
+ - fix(cli): `validate -v` reports a backend load warning once, where each of
283
+ the three canonical renders repeated it.
284
+ - fix(quillmark): the facade re-exports `ParseDateError`.
285
+
286
+
3
287
  ## v0.115.0 - 2026-09-22
4
288
 
5
289
  Upgrade path: [0.114 → 0.115](docs/migrations/0.114-to-0.115.md).
package/README.md CHANGED
@@ -150,8 +150,7 @@ document was built. Parse warnings and the `conform::*` warnings both ride
150
150
  other way (`fromStored`, a stored row), returning the `conform::*` `Diagnostic[]`
151
151
  (`[]` when everything rested). It is idempotent and a byte no-op on an
152
152
  already-canonical document, YAML comments included, so calling it on every load
153
- is safe. A `!must_fill` marker anywhere in a field's value skips that field; a
154
- value the strict write refuses stays as authored under a warning. Both throw
153
+ is safe. A value the strict write refuses stays as authored under a warning. Both throw
155
154
  when the document declares a `$quill` this quill does not answer to, before any
156
155
  mutation.
157
156
 
@@ -175,9 +174,9 @@ const doc = Document.storageVersionOf(content)
175
174
  : Document.fromMarkdown(content);
176
175
  ```
177
176
 
178
- The `schema` value (`quillmark/document@0.115.0`) is the **model version**,
177
+ The `schema` value (`quillmark/document@0.116.0`) is the **model version**,
179
178
  not the running crate version. It is a hand-set constant, bumped only when
180
- the `Document` model itself changes, so every `0.115.x` patch release reads
179
+ the `Document` model itself changes, so every `0.116.x` patch release reads
181
180
  and writes that same value.
182
181
 
183
182
  - **Upgrading is safe.** A newer build reads documents an older build's
@@ -220,8 +219,13 @@ To render a form editor, read field definitions from `quill.schema` (walk
220
219
  values from the `Document` payload: there is no separate form-view projection.
221
220
  `quill.validate(doc)` scores it without invoking the backend.
222
221
 
223
- `quill.seedDocument()` returns a starter document with each field's `example:`
224
- committed; `quill.seedMain()` and `quill.seedCard(kind)` seed one card. All
222
+ `quill.exampleDocument()` returns the quill's example, a filled-in page whose
223
+ values are made up, pinned to the quill's `name@version`; `undefined` when
224
+ the quill has no `example.md` at its root.
225
+
226
+ `quill.seedDocument()` returns a starter document: one card per kind, each body
227
+ empty, every field absent;
228
+ `quill.seedMain()` and `quill.seedCard(kind)` seed one card. All
225
229
  return the read `Card` shape of `doc.main` / `doc.cards`, which `doc.insertCard`
226
230
  accepts directly:
227
231
 
@@ -248,7 +252,7 @@ card is an object literal: one `{ type: "field", key, value }` per field in
248
252
  `{ card?, field? }`, absent `card` = main, absent `field` = body, and a bare
249
253
  string is shorthand for `{ field }`. So `doc.storeField("qty", 3)` targets the
250
254
  main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
251
- card's. Reads are total over the field axis (`getStored` → `undefined`, `isFill` → `false` for
255
+ card's. Reads are total over the field axis (`getStored` → `undefined` for
252
256
  an absent field; only an out-of-range card throws); field writes throw on a body
253
257
  address. `getStored` is the verbatim transport read, distinct from the interpreted
254
258
  `quill.reader(doc).get`; `bodyMarkdown` is the body markdown read (a `CardAddr`; a field's
@@ -289,7 +293,7 @@ on `Document` itself (**store** = verbatim, **set** = typed):
289
293
  `_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
290
294
 
291
295
  - **`store*`: the deliberate quill-free primitive.** `doc.storeField(addr, value)`
292
- / `doc.storeFields(cardAddr, {...})` (and `storeFill`) validate only the field
296
+ / `doc.storeFields(cardAddr, {...})` validate only the field
293
297
  name/depth/kind and store the value verbatim, no quill required. Reach for it
294
298
  on purpose when you *want* the opaque store: quill-agnostic storage/migration
295
299
  infra that has no bundle and must write regardless of a drifted schema;
@@ -354,43 +358,47 @@ tells you which pages to repaint (`dirty ∩ visible`). `update` is transactiona
354
358
  on throw, every read keeps serving the last-good compile. Don't open a session
355
359
  per export, and don't re-open per edit: `update` instead.
356
360
 
361
+ Both take the render date as an optional last argument (`YYYY-MM-DD`, default
362
+ the local date): what a `today` date field and a plate's `datetime.today()`
363
+ render as. A session keeps the date it opened with, so a preview left open past
364
+ midnight renders yesterday's until it is reopened.
365
+
357
366
  A document that compiles to zero pages still produces a valid session
358
- (`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw. Branch on
367
+ (`pageCount === 0`); `paint(ctx, 0, scale)` and `pageSize(0)` then throw. Branch on
359
368
  `pageCount === 0` to render a "no pages to preview" UI rather than relying on
360
369
  the throw.
361
370
 
362
371
  Their `warnings` differ in reach. `engine.render` returns one list for the
363
- whole pipeline: `doc.warnings` (parse, `conform::*`,
364
- `plate::unsupported_construct`) ahead of the compile's own. A session outlives
372
+ whole pipeline: `doc.warnings` (parse and `conform::*`), then every
373
+ `quill.validate(doc)` warning, then the compile's own. A session outlives
365
374
  the document it opened from, so `session.render` and `session.warnings` carry
366
- the compile half alone — read `doc.warnings` beside them.
375
+ the compile's alone — read `doc.warnings` and `quill.validate(doc)` beside them.
367
376
 
368
377
  ### Canvas Preview
369
378
 
370
- `session.paint(ctx, page, opts?)` rasterizes a page directly into a
379
+ `session.paint(ctx, page, scale)` rasterizes a page directly into a
371
380
  `CanvasRenderingContext2D` (main thread) or
372
381
  `OffscreenCanvasRenderingContext2D` (Worker), skipping PNG/SVG byte
373
382
  round-trips.
374
383
 
375
- The painter owns `canvas.width` / `canvas.height`: it sizes the backing
376
- store itself. Consumers own `canvas.style.*` (or the layout system that
377
- sets them) and read `layoutWidth` / `layoutHeight` from the returned
378
- `PaintResult`.
384
+ `scale` is backing-store pixels per point. The painter owns `canvas.width` /
385
+ `canvas.height`, reducing `scale` where it must so neither exceeds 16384 px;
386
+ consumers own `canvas.style.*`. A canvas styled to fill its page box
387
+ needs nothing back from the paint:
379
388
 
380
389
  ```ts
381
- const result = session.paint(canvas.getContext("2d"), 0, {
382
- layoutScale: 1, // layout px per pt (page geometry unit)
383
- densityScale: window.devicePixelRatio, // backing-store density
384
- });
385
-
386
- canvas.style.width = `${result.layoutWidth}px`;
387
- canvas.style.height = `${result.layoutHeight}px`;
390
+ canvas.style.width = "100%"; // the box sets the display size
391
+ const cssPxPerPt = canvas.clientWidth / session.pageSize(0).widthPt;
392
+ if (cssPxPerPt > 0) { // 0 while the canvas has no layout box
393
+ session.paint(canvas.getContext("2d")!, 0, cssPxPerPt * window.devicePixelRatio);
394
+ }
388
395
  ```
389
396
 
390
- - `layoutScale` sets the display-box size (`layoutWidth = widthPt * layoutScale`);
391
- fold `devicePixelRatio`, in-app zoom, and `visualViewport.scale` into
392
- `densityScale`. Their product is the rasterization scale, clamped at 16384 px
393
- per side (`result.clamped`, `result.effectiveDensityScale`).
397
+ - Fold `devicePixelRatio`, in-app zoom, and `visualViewport.scale` into `scale`.
398
+ - A canvas with no layout box (`display: none` on it or an ancestor, detached,
399
+ in a zero-width container) has `clientWidth` 0, and a 0 scale throws
400
+ `backend::invalid_raster_scale`: skip the paint, and paint when a
401
+ `ResizeObserver` reports a width.
394
402
  - `paint` writes the whole backing store with `putImageData`, which ignores the
395
403
  2D context transform, `globalAlpha`, and clip. Give each visible page its own
396
404
  `<canvas>`: no compositing, sub-rect, or transform reaches through `paint`.
@@ -402,33 +410,24 @@ canvas.style.height = `${result.layoutHeight}px`;
402
410
  cache them between committed `update`s only. After one, the count is
403
411
  `ChangeSet.pageCount`, and every page in `ChangeSet.dirtyPages` needs its
404
412
  `pageSize` re-read.
405
- - In a Worker, pass an `OffscreenCanvasRenderingContext2D`; the layout
406
- dimensions are informational there. Loading the WASM module inside the Worker
407
- is the host's responsibility.
413
+ - In a Worker, pass an `OffscreenCanvasRenderingContext2D`. An
414
+ `OffscreenCanvas` has no layout box to measure, so the main thread posts the
415
+ scale. Loading the WASM module inside the Worker is the host's
416
+ responsibility.
408
417
  - `paint` / `pageSize` throw on a page the compile does not have, a zero-page
409
418
  compile included, naming the index and the `pageCount` that excludes it. That
410
419
  throw is the whole contract: open the session and handle it.
411
420
 
412
421
  ### Schema model
413
422
 
414
- A field carries two independent axes, and no `required` one.
415
-
416
- **Value** — what the cell holds. With a `default:`, `quill.blueprint` renders
417
- that value under a type-only `# <type>` annotation and the render path uses it
418
- when the document omits the field. Without one, an `example` takes the cell as
419
- a suggested value, and an absent field blank-fills.
423
+ A field declares no `required` key: nothing is required.
420
424
 
421
- **Obligation** — whether a human must author the field, read off `default:`'s
422
- absence: a defaulted field asks nobody, a defaultless one asks. An obliged
423
- field carries the `!must_fill` marker in `quill.blueprint`, and
424
- `quill.validate(doc)` emits the non-fatal `validation::must_fill` warning while
425
- the document leaves it unauthored — from either of two triggers, named by the
426
- diagnostic's `trigger` arg: `marker` for a marker the document still carries,
427
- `unauthored` for a cell the schema obliges and the document never filled.
428
- Authoring the field's blank discharges the obligation; clearing the key does
429
- not.
425
+ **`default:`** — what an unanswered field renders. With one, `quill.blueprint`
426
+ renders that value under a type-only `# <type>` annotation and the render path
427
+ uses it when the document omits the field. Without one, the blueprint leaves the
428
+ cell empty and an absent field blank-fills.
430
429
 
431
- Neither axis gates render. Partial documents are accepted, and
430
+ An unanswered field draws no diagnostic. Partial documents are accepted, and
432
431
  `engine.render(quill, doc)` throws only for malformed input.
433
432
 
434
433
  ### Errors
@@ -516,7 +515,7 @@ in a browser that hasn't shipped it:
516
515
  const session = await engine.open(quill, doc);
517
516
  try {
518
517
  for (let p = 0; p < session.pageCount; p++) {
519
- session.paint(ctx, p);
518
+ session.paint(canvases[p].getContext("2d")!, p, scale);
520
519
  }
521
520
  } finally {
522
521
  session.free();