@quillmark/wasm 0.98.0 → 0.99.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,227 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.99.0 - 2026-08-01
4
+
5
+ The 1.0.0 API freeze lands ahead of the tag, and the content codec closes its
6
+ last open gaps. All breaking changes are covered by
7
+ `docs/migrations/0.98-to-0.99.md`. Stored documents are unaffected: a `0.98` blob
8
+ loads byte-identically and `0.99` writes the same bytes for the same content.
9
+
10
+ - refactor(core,content,pdf,pdfform)!: the public API opens. 75 public types take
11
+ `#[non_exhaustive]` — nothing in the workspace carried it before — so an
12
+ exhaustive `match` needs a `_` arm and a struct literal gives way to `new` plus
13
+ `with_*` setters. `Backend` is sealed, `OutputFormat::ALL` and
14
+ `Content::RESERVED_{MARK_TYPES,LINE_KINDS,CONTAINERS}` become slices, and
15
+ `RenderOptions { .., ..Default::default() }` becomes
16
+ `RenderOptions::default().with_output_format(fmt)`. Four stay exhaustive and
17
+ say so: the storage DTOs, frozen per schema version, plus
18
+ `quillmark_pdf::FieldType`, `KnownIslandType`, and `Fidelity`, where an
19
+ out-of-crate `_` arm is silent (a field that draws nothing, an island dropped
20
+ from the projection, a fidelity rung nothing warns about). The rules are
21
+ canonized in `prose/canon/COMPATIBILITY.md` (#1090, #1103). See
22
+ `docs/migrations/0.98-to-0.99.md`
23
+ - refactor(core)!: the YAML engine leaves the public API. `QuillValue::from_yaml_str`
24
+ and `QuillConfig::schema_yaml` return `quillmark_core::YamlError` instead of
25
+ `serde_saphyr` types, so a `0.0.x` dependency release is no longer a break to
26
+ `quillmark-core`. The message is sanitized (the engine's own Rust API names are
27
+ stripped), and `from_yaml_str` gains the `MAX_YAML_DEPTH` budget its siblings
28
+ already carried (#1099, #1101). See `docs/migrations/0.98-to-0.99.md`
29
+ - fix(content)!: the reserved-name rule reaches the wire. `attrs` beside a
30
+ built-in discriminator resolved to the built-in and dropped the payload in
31
+ silence; the authored lane now refuses it on all four axes, where a host writes
32
+ it. Reading never got stricter — a blob from before a promotion still opens.
33
+ A table cell keeps its own unknown keys too, canonicalization now rewriting it
34
+ in place rather than minting a fresh `{text, marks}` (#1084, #1085, #1086,
35
+ #1092). See `docs/migrations/0.98-to-0.99.md`
36
+ - fix(content)!: opaque payload depth is bounded at `MAX_JSON_DEPTH` (128) on the
37
+ `Value` lane, where an unbounded one took the WASM module down with a
38
+ stack-overflow trap rather than a catchable error. The WASM guard sits on the
39
+ JS side of the boundary, since `serde_wasm_bindgen` recurses while building the
40
+ value, and covers every door that takes opaque host JSON: `install` and
41
+ `applyChange`, plus `makeCard`'s field values and `insertCard`'s payload items
42
+ (#1093). See `docs/migrations/0.98-to-0.99.md`
43
+ - fix(content)!: island `loss` becomes the fifth open set. An unrecognized class
44
+ round-trips verbatim instead of being rewritten to `unrepresentable`, so merely
45
+ opening a document no longer moves its content hash. `Loss` opens on the island
46
+ `type` axis' terms rather than the block axes': it becomes an opaque string
47
+ wrapper with `LOSSLESS` / `DEGRADED` / `UNREPRESENTABLE` consts, one value per
48
+ wire string, so a built-in's name has no second spelling and needs no
49
+ reserved-name rule. `Fidelity` is the closed view `Loss::fidelity` returns, and
50
+ is where a consumer switches; `Loss` consequently loses its `Copy` derive
51
+ (#1091, #1142). See `docs/migrations/0.98-to-0.99.md`
52
+ - refactor(core,typst,pdf)!: workspace-internal seams leave the published
53
+ surface. `quillmark-pdf`'s `reader`/`writer` modules and `quillmark_typst::emit`
54
+ become `#[doc(hidden)]`; the op-wire encoders emit an unknown's `attrs` in
55
+ caller key order, the redundant per-encoder sort having been dropped (canonical
56
+ content bytes are unchanged, the terminal sort still running) (#1095). See
57
+ `docs/migrations/0.98-to-0.99.md`
58
+ - fix(wasm)!: a `Quill` or `Document` from a second copy of `@quillmark/wasm` is
59
+ refused everywhere, as a `QuillmarkError` coded `runtime::foreign_handle` that
60
+ names the cause and hints `npm ls @quillmark/wasm`. 0.98 half-worked there:
61
+ `Engine` was duck-typed, so a quill from copy A rendered on an engine from copy
62
+ B at a per-copy clone cache nobody could see, while `Document.equals`,
63
+ `Quill.validate`, `Quill.resolve` and the typed writer met wasm-bindgen's bare
64
+ `expected instance of Document` at a value that *is* a `Document`. The check
65
+ covers `Engine` (`render`, `open`, `supportedFormats`, `supportsCanvas`),
66
+ `LiveSession.apply`, the writer and reader binds, and the three by-reference
67
+ core methods; a value that is not a handle at all keeps its own
68
+ `runtime::not_a_document` / `runtime::not_a_quill`. Nothing changes for a
69
+ one-copy install (#1132, #1136). See `docs/migrations/0.98-to-0.99.md`
70
+ - feat(wasm): `isUnknownLine` / `isUnknownContainer` / `isUnknownMark` /
71
+ `isUnknownIsland` answer known-vs-unknown on each open set, so a consumer no
72
+ longer enumerates built-in names in its own source. `ContentLineKind` is
73
+ re-exported from the package entry point, so a `setKind` op type-checks without
74
+ a cast
75
+ - feat(python): the Tier-1 gaps close. `doc.card(i)`, `doc.card_index_by_id(id)`,
76
+ and `doc.seed_overlay(kind)` are the single-card, `$id`, and seed reads WASM
77
+ already had, and the wheel ships `py.typed` plus stubs, so mypy and Pyright see
78
+ real signatures where the surface used to resolve to `Any` (#1011)
79
+ - fix(typst): four quill-load defects — a skipped asset, an unparseable
80
+ `typst.toml`, a skipped package file, a declared-but-absent entrypoint —
81
+ become `RenderResult` warnings (`typst::path_skipped`,
82
+ `typst::package_manifest`, `typst::package_entrypoint_missing`) instead of
83
+ `eprintln!` that wasm32 has nowhere to print (#1102)
84
+ - fix(wasm): the npm package states the license the workspace actually grants.
85
+ `package.json` declared `MIT OR Apache-2.0` where every Rust crate, the
86
+ workspace manifest, and the only `LICENSE` file in the tree are `Apache-2.0`,
87
+ and the package shipped no license text at all: `build-wasm.sh` copied
88
+ `LICENSE-MIT` and `LICENSE-APACHE`, neither of which exists. It now copies
89
+ `LICENSE`, or refuses to produce a package
90
+ - ci: the release gates the tag actually needs. New `package` (builds every
91
+ publishable crate from its own archive and asserts each ships its `LICENSE`),
92
+ `msrv` (holds `rust-version` to something true), and `audit` (bare `cargo
93
+ audit` over the lockfile) jobs; the workspace moves to edition 2024 and
94
+ declares MSRV 1.92. The `semver` job is dropped — it compared the tree's
95
+ unbumped version against itself — and `COMPATIBILITY.md` names the writer and
96
+ reviewer as what holds the promise instead (#1105, #1106, #1107, #1108)
97
+ - ci: the rustdoc gate covers the whole workspace. A bare `cargo doc` walks
98
+ default-members and never lints a crate outside it — the blind spot that let
99
+ the `Delta` links rot on the WASM surface and four more in the published
100
+ `quillmark-content`. `--workspace` needs no `--exclude` and covers the next
101
+ such crate on the day it lands
102
+ - test(fuzz): the four JSON decode lanes the bindings expose gain coverage
103
+ (#1104)
104
+ - docs(canon): `COMPATIBILITY.md` states the crate-API promise — what
105
+ `#[non_exhaustive]` does and does not buy, when to mark an enum, and what no
106
+ attribute sweep catches
107
+ - docs(all): the em-dash leaves comments and prose, folded to a colon, comma,
108
+ semicolon, or parentheses across ~2900 sites. `dense-prose` banned it while
109
+ every exemplar it named used it; the corpus now matches the rule. A handful of
110
+ diagnostic and CLI message strings repunctuate with it (`edit::body_only`,
111
+ `validation::must_fill`, the pdfform bind errors, `--help`); codes, severities,
112
+ and paths are unchanged. The character stays where it is the subject rather
113
+ than punctuation: the WinAnsi encoding table, the YAML en/em-dash fixtures, and
114
+ `docs/migrations/` (#1135)
115
+ - chore(core): `serde-saphyr` moves to `1.0`. The two call sites that built
116
+ `Options`/`SerializerOptions` with struct-literal-plus-`..Default::default()`
117
+ now go through the crate's own `options!`/`ser_options!`/`budget!` macros,
118
+ which the 1.0 release requires since both structs are `#[non_exhaustive]`.
119
+ `serde_saphyr` types stay out of `quillmark-core`'s public API (see the YAML
120
+ engine entry above), so nothing downstream moves
121
+
122
+ <!-- seed: commits since v0.98.0, confirm the entries above cover them, then delete this comment
123
+ - chore(core): move the time format descriptions to parse_borrowed::<3>
124
+ - build(deps): bump lopdf from 0.42.0 to 0.44.0
125
+ - build(deps): bump thiserror in the cargo group across 1 directory
126
+ - build(deps): bump taiki-e/install-action
127
+ - chore(core): bump serde-saphyr to 1.0
128
+ - Split lint back out of test
129
+ - Link every shipped artifact in CI, and carry v0.99.0's notes forward
130
+ - dense-prose pass over the CI scope
131
+ - Cut CI to one job per shipped surface
132
+ - Stop the release from pushing to a branch that takes only PRs
133
+ - Fix three claims the independent review falsified
134
+ - Correct the release notes' exhaustive-type count
135
+ - dense-prose pass over the review's scope
136
+ - Close the two payload doors the depth guard missed
137
+ - Ship the license the workspace actually grants
138
+ - Correct the release docs against the tree they describe
139
+ - CLAUDE.md: tighten the Tests section to house voice
140
+ - CLAUDE.md: drop `uv run` from the Python binding flow
141
+ - CLAUDE.md: gate local binding builds, name the cheap profiles
142
+ - chore(release): v0.99.0
143
+ - Split serial.rs's fixed-point claim into the two it conflated
144
+ - Make Fidelity the one place a loss class is spelled
145
+ - Make an island's loss class injective instead of guarded
146
+ - dense-prose and simplify passes over the handle-check work
147
+ - Keep the colon out of embedded YAML values
148
+ - Sweep the em-dash out of comments and prose
149
+ - Refuse a core handle from a second copy, everywhere
150
+ - Replace the em-dash ban with the rule the exemplars follow
151
+ - dense-prose pass over the foreign-handle prose
152
+ - Extend the policy to the writer/reader lane, and draw the read/write line
153
+ - Keep the patched method names, and stop over-claiming in the warning
154
+ - Tolerate foreign core handles on the by-reference methods
155
+ - Correct the quill file-size cap doc and MiB units
156
+ - Collapse the rustdoc gate onto --workspace
157
+ - dense-prose pass over the release-readiness fixes
158
+ - Close the release-readiness gaps found against v0.98.0
159
+ - Drop the semver job
160
+ - Trim the Python typing work to its load-bearing parts
161
+ - Revert "Record KnownIslandType::ALL's slice shape in the 0.99 guide"
162
+ - Record KnownIslandType::ALL's slice shape in the 0.99 guide
163
+ - Close the Python Tier-1 gaps: single-card reads and a typed surface
164
+ - Drop the top-level-await plugin the vite 8 test config cannot load
165
+ - Shape the reserved-name lists as slices
166
+ - Shape the island ALL as a slice; list the 0.99 guide as published
167
+ - Prune rottable duplication from CLAUDE.md
168
+ - Adopt the editor's dense-prose rewrite; compress CLAUDE.md
169
+ - build(deps): bump the cargo group across 1 directory with 12 updates
170
+ - build(deps): bump the actions group across 1 directory with 11 updates
171
+ - build(deps): bump tsify from 0.4.5 to 0.5.6
172
+ - build(deps): bump toml from 0.8.23 to 1.1.3+spec-1.1.0
173
+ - build(deps): bump similar from 2.7.0 to 3.1.1
174
+ - build(deps): bump serde-saphyr from 0.0.23 to 0.0.29
175
+ - Run the semver job on release PRs, not on every one
176
+ - Drop the dead check-cfg rustflag that crashed the semver job
177
+ - docs: dense-prose pass over the new test comments
178
+ - dense-prose pass over the branch
179
+ - Gate the compatibility promise mechanically, and write it down
180
+ - test(bindings): cover the WASM and Python exports nothing exercised
181
+ - Close the struct half of the 1.0.0 non_exhaustive sweep, and seal Backend
182
+ - test(pdfform): drive unbound text/checkbox/choice through the fixture
183
+ - Move the workspace to edition 2024
184
+ - build(deps-dev): bump vitest in /crates/bindings/wasm
185
+ - build(deps-dev): bump typescript in /crates/bindings/wasm
186
+ - prose: drop the readiness proposal and SECURITY.md
187
+ - ci: drop the Windows matrix from the test job
188
+ - Finish the coerce_value_strict cleanup in the fuzz crate
189
+ - dense-prose pass across the code and docs
190
+ - Revert the wasm job to `npm install`
191
+ - Trim CI to the checks v1.0.0 actually needs
192
+ - Cut the two CI costs that buy nothing
193
+ - Clear the RUSTSEC vulnerabilities the audit job actually found
194
+ - Pin the tree to LF so the Windows runner reads what the test wrote
195
+ - Cleanup pass: cut the machinery the fix did not need
196
+ - prose: record what landed against the v1.0.0 readiness findings
197
+ - fuzz: cover the four JSON decode lanes the bindings expose
198
+ - Declare and hold an MSRV
199
+ - Ship the license text, verify the packages, scan the dependencies
200
+ - Open the engine crates' public enums ahead of the 1.0.0 freeze
201
+ - Hide the workspace-internal seams from the published surface
202
+ - typst: quill-load defects become session warnings, not stderr
203
+ - core: own the YAML boundary error, and budget the third entry point
204
+ - prose: index the v1.0.0 readiness findings against their issues
205
+ - prose: v1.0.0 production-readiness review
206
+ - Drop issue markers; dense-prose the unreleased 0.99 guide
207
+ - dense-prose pass over the PR 1097 scope
208
+ - docs: record the op-wire key-order change in the unreleased guide
209
+ - content: carry the `<u>` bit on the event, not in a shared set
210
+ - content: one key sorter for the canonical form
211
+ - content: bound opaque JSON payload depth on the Value lane
212
+ - content: make a vocabulary promotion carry its own legacy form
213
+ - content: simplify pass over the codec-gap changes
214
+ - content: house voice on the codec-gap comments
215
+ - content: close the three v1.0.0 codec gaps before the freeze
216
+ - content: cut the enumerated halves of the two open-set tests
217
+ - content: prune tests that assert what a neighbour already pins
218
+ - content: fold the authored-lane scan into the shared cell walk
219
+ - content: split the wire lanes on reserved-name reuse (#1084, #1085, #1086)
220
+ - docs: trim the migration index to a routing table; drop duplicate links
221
+ - refactor(typst): re-export span_scan/extract instead of forwarding
222
+ -->
223
+
224
+
3
225
  ## v0.98.0 - 2026-07-28
4
226
 
5
227
  Five breaking changes, all covered by `docs/migrations/0.97-to-0.98.md`.
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2025 Nibs
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
package/README.md CHANGED
@@ -10,16 +10,16 @@ Use Quillmark in browsers/Node.js with explicit in-memory trees (`Map<string, Ui
10
10
 
11
11
  The package exposes **one import surface**:
12
12
 
13
- - `@quillmark/wasm` (the root) the **canonical API**: `Quill`, `Document`, and
13
+ - `@quillmark/wasm` (the root), the **canonical API**: `Quill`, `Document`, and
14
14
  an `Engine` that renders them.
15
15
 
16
16
  `Quill` and `Document` are re-exported verbatim from the internal Typst-less
17
17
  core build, so editor/validation code (`Quill.fromTree`,
18
- `Document.fromMarkdown`) loads only that small core binary no backend is
18
+ `Document.fromMarkdown`) loads only that small core binary: no backend is
19
19
  loaded until you render. The `Engine` hides everything else: each backend
20
20
  (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
21
21
  memory, lazily loaded on the first render. The Engine clones a `Quill` /
22
- `Document` into the backend's memory as data and frees the clones you never
22
+ `Document` into the backend's memory as data and frees the clones: you never
23
23
  hold a backend object or cross a memory boundary yourself.
24
24
 
25
25
  ## Build
@@ -28,8 +28,8 @@ hold a backend object or cross a memory boundary yourself.
28
28
  bash scripts/build-wasm.sh
29
29
  ```
30
30
 
31
- The script builds three variants the core (no backend), the Typst backend
32
- (default features), and the Typst-free pdfform backend (`pdfform` feature)
31
+ The script builds three variants: the core (no backend), the Typst backend
32
+ (default features), and the Typst-free pdfform backend (`pdfform` feature):
33
33
  each with `--target bundler` and `--weak-refs` enabled (see
34
34
  [Lifecycle](#lifecycle)).
35
35
 
@@ -66,7 +66,7 @@ const result = await engine.render(quill, parsed, { format: "pdf" });
66
66
 
67
67
  ### `new Engine(options?)`
68
68
  Create the render dispatcher. Routes each quill to its backend by
69
- `quill.backendId`, lazily loads that backend binary, and renders cloning the
69
+ `quill.backendId`, lazily loads that backend binary, and renders: cloning the
70
70
  quill/document into the backend's memory and freeing the clones internally.
71
71
  `render`, `open`, `supportedFormats`, and `supportsCanvas` are **async** (the
72
72
  first call may load a backend). Pass `{ backends }` to register or override
@@ -78,16 +78,16 @@ the backend id.
78
78
 
79
79
  **Capability probes are always free.** `supportedFormats` and `supportsCanvas`
80
80
  depend only on `quill.backendId`, and answer from the descriptor's required
81
- `formats`/`canvas` manifest never loading the multi-MB backend binary and
81
+ `formats`/`canvas` manifest: never loading the multi-MB backend binary and
82
82
  never cloning the quill. Use them as non-failing pre-render probes.
83
83
 
84
84
  ### `Quill.fromTree(tree)`
85
- Build + validate a `Quill` from an in-memory tree. Pure the declared backend
85
+ Build + validate a `Quill` from an in-memory tree. Pure: the declared backend
86
86
  is resolved at render time, not here. Loads no backend binary.
87
87
 
88
88
  ### `new Document(quillRef)`
89
89
  A blank document: a main card carrying only `$quill`, an empty body, and no
90
- composable cards the programmatic blank canvas. Absent fields resolve at
90
+ composable cards: the programmatic blank canvas. Absent fields resolve at
91
91
  render time (schema `default`, else type-empty zero), so nothing the caller
92
92
  did not set reaches the output. Build it up with `storeFields` / `insertCard`.
93
93
  For an example-filled starter use `quill.seedDocument()`. Throws on an
@@ -96,19 +96,19 @@ invalid quill reference.
96
96
  ### `Document.fromMarkdown(markdown)`
97
97
  Parse markdown to a parsed document. Throws a JS `Error` (with `.diagnostics`
98
98
  attached, see [Errors](#errors)) on any parse failure, including a missing
99
- root `$quill` metadata line, malformed YAML, and inputs over the 10 MB
99
+ root `$quill` metadata line, malformed YAML, and inputs over the 10 MiB
100
100
  `parse::input_too_large` limit.
101
101
 
102
102
  ### `doc.toMarkdown()`
103
103
  Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
104
104
  `Document.fromMarkdown(doc.toMarkdown())` returns a document equal to `doc`
105
105
  under [`doc.equals`](#docequalsother). The output is **not** guaranteed
106
- byte-equal to the original source YAML quoting, key ordering, and
106
+ byte-equal to the original source: YAML quoting, key ordering, and
107
107
  whitespace are normalised. Use `equals` (not string comparison) to test
108
108
  semantic equality.
109
109
 
110
110
  ### `doc.toJson()`
111
- Serialize the document to a versioned storage DTO a JSON **string**
111
+ Serialize the document to a versioned storage DTO: a JSON **string**
112
112
  carrying a `schema` version. Use this (not `toMarkdown`) to persist a
113
113
  document across a process restart or crate upgrade: the wire format is
114
114
  frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
@@ -116,11 +116,11 @@ frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
116
116
 
117
117
  The string is produced inside the module by `serde_json`; the JS `JSON`
118
118
  global is not involved. It is standard JSON text, so callers may
119
- `JSON.parse` it to inspect it but it is intended as an opaque blob you
119
+ `JSON.parse` it to inspect it, but it is intended as an opaque blob you
120
120
  persist and hand back.
121
121
 
122
122
  `toJson()` is **deterministic**: a `Document` that is `equals` to another
123
- serializes to a byte-identical string across repeated calls, and across
123
+ serializes to a byte-identical string: across repeated calls, and across
124
124
  any crate upgrade that keeps the same `schema` version (every release does until
125
125
  the `Document` model changes; see [Storage compatibility](#storage-compatibility-across-versions)).
126
126
  Field order is fixed and object key order is preserved, so content hashes
@@ -145,7 +145,7 @@ not a valid storage DTO. Use it to branch on format without a heuristic or
145
145
  `try`/`catch` as control flow:
146
146
 
147
147
  ```ts
148
- // "JSON canonical, Markdown fallback" no exceptions, no string sniffing
148
+ // "JSON canonical, Markdown fallback": no exceptions, no string sniffing
149
149
  const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
150
150
  ```
151
151
 
@@ -156,14 +156,14 @@ genuinely malformed Markdown.
156
156
 
157
157
  The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
158
158
  not the running crate version. It is a hand-set constant, bumped only when
159
- the `Document` model itself changes so every `0.93.x` patch release reads
159
+ the `Document` model itself changes, so every `0.93.x` patch release reads
160
160
  and writes that same value.
161
161
 
162
162
  - **Upgrading is safe.** A newer build always reads documents written by an
163
163
  older one. Each schema version's wire format is frozen and never changes;
164
164
  when the model does change, the new build ships a migration that converts
165
165
  old payloads on `fromJson`. A document you commit as your canonical
166
- on-disk format keeps loading across crate upgrades there is no need to
166
+ on-disk format keeps loading across crate upgrades: there is no need to
167
167
  pin old wasm to read old data.
168
168
  - **Downgrading is not.** `fromJson` rejects an *unknown* (i.e. newer)
169
169
  `schema` version rather than guessing at a format it predates. Don't feed
@@ -178,13 +178,13 @@ if (v && v !== Document.currentSchemaVersion()) {
178
178
  }
179
179
  ```
180
180
 
181
- `schemaVersionOf` does not validate the payload it only reads the
181
+ `schemaVersionOf` does not validate the payload: it only reads the
182
182
  `schema` field, returning `undefined` for non-JSON, non-objects, or
183
183
  payloads that don't carry one. Use it to distinguish "wrong version" from
184
184
  "corrupt" when `fromJson` throws.
185
185
 
186
186
  In short: persist the `toJson` string, upgrade freely, never downgrade. The
187
- full design including how migrations are added is in
187
+ full design (including how migrations are added) is in
188
188
  `prose/canon/DOCUMENT_STORAGE.md`.
189
189
 
190
190
  ### `doc.equals(other)`
@@ -201,7 +201,7 @@ Use this to validate indices before calling card mutators (`removeCard`,
201
201
 
202
202
  ### `quill.validate(doc)`
203
203
 
204
- Returns `Diagnostic[]` the document validated against the quill schema,
204
+ Returns `Diagnostic[]`: the document validated against the quill schema,
205
205
  without invoking the backend. An empty array means the document is valid.
206
206
  Each diagnostic carries the canonical `validation::*` `code`, `path`, and
207
207
  `hint`. Includes the non-fatal `validation::must_fill` warning for each
@@ -214,14 +214,14 @@ const errors = diagnostics.filter(d => d.severity === "error");
214
214
  ```
215
215
 
216
216
  To render a form editor, read field definitions from `quill.schema` (walk
217
- `fields` in key order declaration order is display order) and the authored
218
- values from the `Document` payload there is no separate form-view projection.
217
+ `fields` in key order: declaration order is display order) and the authored
218
+ values from the `Document` payload: there is no separate form-view projection.
219
219
 
220
220
  ### `quill.seedDocument()`
221
221
 
222
222
  Returns a starter `Document` seeded from the schema: each field's `example:`
223
223
  is committed and every other field is left absent (the render layer fills
224
- `default:` → type-empty zero). Illustration-first a field with both an
224
+ `default:` → type-empty zero). Illustration-first: a field with both an
225
225
  `example` and a `default` renders its example. Use as the initial state for a
226
226
  "new document" editor.
227
227
 
@@ -243,7 +243,7 @@ doc.insertCard({ kind: "note" }, 0); // insert at index 0
243
243
  ```
244
244
 
245
245
  Reads and writes are two aligned shapes. A read `Card` always has `body:
246
- Content` (canonical content, never a raw string) no narrowing, no guessing
246
+ Content` (canonical content, never a raw string): no narrowing, no guessing
247
247
  whether the body was normalized. The write shape `CardInput` widens `body` to
248
248
  `Content | string` (a markdown string imports to the content) and makes every
249
249
  field but `kind` optional. Every `Card` is a valid `CardInput`, so `insertCard`
@@ -251,8 +251,8 @@ still takes exactly what `cards` / `removeCard` / `seedCard` return.
251
251
  Build a fresh card from a flat field map with
252
252
  `Document.makeCard(kind, fields?, body?)`.
253
253
 
254
- **One address for the whole surface.** Reads and writes navigate by an `Addr` —
255
- `{ card?, field? }`, absent `card` = main, absent `field` = body and a bare
254
+ **One address for the whole surface.** Reads and writes navigate by an `Addr`:
255
+ `{ card?, field? }`, absent `card` = main, absent `field` = body, and a bare
256
256
  string is shorthand for `{ field }`. So `doc.storeField("qty", 3)` targets the
257
257
  main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
258
258
  card's. Reads are total over the field axis (`getStored` → `undefined`, `isFill` → `false` for
@@ -263,10 +263,10 @@ markdown is read through `quill.reader(doc).get(field)`). Card-scoped verbs take
263
263
  `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
264
264
 
265
265
  Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
266
- apply a whole object atomically on any invalid field nothing is applied and
266
+ apply a whole object atomically: on any invalid field nothing is applied and
267
267
  the thrown error carries one diagnostic per offending field (`path` = field
268
268
  name). The address is first (never shape-overloaded, since `card` is a legal
269
- field name), and parses strictly a stray key throws rather than silently
269
+ field name), and parses strictly: a stray key throws rather than silently
270
270
  reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
271
271
  `@quillmark/wasm/runtime`), a frozen alias that spells the intent:
272
272
  `doc.storeFields(MAIN_CARD_ADDR, {...})`.
@@ -277,12 +277,12 @@ A `Document` holds only a `$quill` *reference*, not the resolved schema, so type
277
277
  writes go through the schema-bound writer while the quill-free opaque store sits
278
278
  on `Document` itself (**store** = verbatim, **set** = typed):
279
279
 
280
- - **`quill.writer(doc)` the typed door whenever a quill is in hand.** Bind the
280
+ - **`quill.writer(doc)`: the typed door whenever a quill is in hand.** Bind the
281
281
  schema once and issue bare `set` / `setAll` / `setBody` / `reviseField` /
282
282
  `addCard` / `card(i)`. Each resolves the field's schema `type`, coerces the
283
283
  value to its canonical form (`"3"` → `3`, a markdown string → a richtext
284
284
  content), and **fails now** on a mismatch instead of at render. A name the schema
285
- does not declare throws `UnknownField` rather than falling to the opaque store
285
+ does not declare throws `UnknownField` rather than falling to the opaque store:
286
286
  on the typed path an undeclared name is a typo, not a fallback. The batch form
287
287
  (`setAll`) is all-or-nothing: an undeclared name aborts the whole write and its
288
288
  per-field diagnostics name every offending field, so a whole-form submit
@@ -290,21 +290,21 @@ on `Document` itself (**store** = verbatim, **set** = typed):
290
290
  carries the quill-taking `_commitField` / `_commitFields` / `_addCard` /
291
291
  `_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
292
292
 
293
- - **`store*` the deliberate quill-free primitive.** `doc.storeField(addr, value)`
293
+ - **`store*`: the deliberate quill-free primitive.** `doc.storeField(addr, value)`
294
294
  / `doc.storeFields(cardAddr, {...})` (and `storeFill`) validate only the field
295
295
  name/depth/kind and store the value verbatim, no quill required. Reach for it
296
296
  on purpose when you *want* the opaque store: quill-agnostic storage/migration
297
297
  infra that has no bundle and must write regardless of a drifted schema;
298
298
  store-now-validate-later editors holding in-progress input that `commit`
299
299
  would reject; or verbatim passthrough of fields the schema doesn't own. It is
300
- the lower layer, not a lighter `commit` a typo'd field name stores silently
300
+ the lower layer, not a lighter `commit`: a typo'd field name stores silently
301
301
  and only surfaces at `quill.validate` / render.
302
302
 
303
303
  Per-keystroke cost is the same either way (both mutate the in-memory `Document`
304
304
  in place; no seam is crossed), so steering to the writer buys the type check for
305
305
  free.
306
306
 
307
- #### `DocumentWriter` / `CardWriter` bind the quill once
307
+ #### `DocumentWriter` / `CardWriter`: bind the quill once
308
308
 
309
309
  `quill.writer(doc)` binds the quill's schema to the document once, so a form
310
310
  editor or MCP writer that holds both issues bare verbs (the writer forwards to
@@ -315,25 +315,25 @@ const ed = quill.writer(doc); // Rust `quill.writer(doc)`
315
315
  ed.set("subject", "Q3 results"); // strict-committed to the schema type
316
316
  ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
317
317
  ed.reviseField("subject", "Q3 **results**"); // typed AND anchor-preserving; returns a Delta
318
- ed.set("titel", "x"); // throws UnknownField a typo, not a fallback
318
+ ed.set("titel", "x"); // throws UnknownField: a typo, not a fallback
319
319
  ed.card(2).set("body", "**note**"); // composable card, resolved by its $kind
320
320
  ```
321
321
 
322
322
  `DocumentWriter` / `CardWriter` are pure JS holding references to your existing
323
- `quill` and `doc` no WASM handle of their own, nothing to `free()`. `card(i)`
323
+ `quill` and `doc`: no WASM handle of their own, nothing to `free()`. `card(i)`
324
324
  is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
325
325
  write.
326
326
 
327
327
  ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
328
328
 
329
- Use **`engine.render`** for one-shot exports (PDF/SVG/PNG) compiles, emits
329
+ Use **`engine.render`** for one-shot exports (PDF/SVG/PNG): compiles, emits
330
330
  artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
331
331
  reactive previews: the session is a persistent compiler. `paint` / `render` /
332
332
  `regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
333
333
  recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
334
- tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional
334
+ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
335
335
  on throw, every read keeps serving the last-good compile. Don't open a session
336
- per export, and don't re-open per edit `apply` instead.
336
+ per export, and don't re-open per edit: `apply` instead.
337
337
 
338
338
  ### `engine.render(quill, parsed, opts?)`
339
339
  Render a pre-parsed `Document` against `quill`. Throws an
@@ -344,7 +344,7 @@ declared backend.
344
344
  Open once, render all or selected pages (`opts.pages`).
345
345
 
346
346
  The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
347
- `warnings` (non-fatal diagnostics of the current compile set at `open`,
347
+ `warnings` (non-fatal diagnostics of the current compile: set at `open`,
348
348
  refreshed by each committed `apply`),
349
349
  `apply(doc)` for in-place recompiles, `pageSize(page)`, and
350
350
  `paint(ctx, page, opts?)` for canvas previews. See below.
@@ -361,7 +361,7 @@ render a "no pages to preview" UI without relying on the throw.
361
361
  `OffscreenCanvasRenderingContext2D` (Worker), skipping PNG/SVG byte
362
362
  round-trips.
363
363
 
364
- The painter owns `canvas.width` / `canvas.height` it sizes the backing
364
+ The painter owns `canvas.width` / `canvas.height`: it sizes the backing
365
365
  store itself. Consumers own `canvas.style.*` (or the layout system that
366
366
  sets them) and read `layoutWidth` / `layoutHeight` from the returned
367
367
  `PaintResult`.
@@ -390,16 +390,16 @@ canvas.style.height = `${result.layoutHeight}px`;
390
390
  clamped page renders soft at the same `canvas.style` size.
391
391
  - `paint` writes the whole backing store with `putImageData`, which
392
392
  ignores the 2D context transform, `globalAlpha`, and clip. Give each
393
- visible page its own `` element you cannot composite two pages,
393
+ visible page its own `<canvas>` element: you cannot composite two pages,
394
394
  a sub-rect, or a context transform through `paint`.
395
- - `paint` is always a full repaint setting the backing-store width /
395
+ - `paint` is always a full repaint: setting the backing-store width /
396
396
  height clears it. No `clearRect` required. Each call re-rasterizes from
397
397
  scratch (no per-page raster cache), so keep a page's canvas alive while
398
398
  it stays near the viewport rather than pooling one canvas across pages:
399
399
  an idle canvas retains its pixels for free, whereas reusing a canvas on
400
400
  scroll re-runs a full render.
401
401
  - `pageCount` and `pageSize(page)` are stable for the session's
402
- lifetime (immutable snapshot) cache them.
402
+ lifetime (immutable snapshot): cache them.
403
403
  - Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
404
404
  same call signature works. `layoutWidth` / `layoutHeight` are
405
405
  informational in that mode (no CSS layout box); fold everything into
@@ -415,14 +415,14 @@ canvas.style.height = `${result.layoutHeight}px`;
415
415
 
416
416
  A field's *cell* is inferred from whether its schema declares a `default:`:
417
417
 
418
- - **Unendorsed** (no `default:`) `quill.blueprint` renders the
418
+ - **Unendorsed** (no `default:`): `quill.blueprint` renders the
419
419
  `!must_fill` marker in the value cell (carrying the field's `example` as a
420
420
  suggested value when one exists). An absent Unendorsed field zero-fills
421
421
  silently. A `!must_fill` marker left in the document is non-fatal: it emits
422
422
  the `validation::must_fill` warning and still renders. Partial documents
423
423
  are accepted; `engine.render(quill, doc)` only throws for malformed
424
424
  input.
425
- - **Endorsed** (with `default:`) `quill.blueprint` renders the
425
+ - **Endorsed** (with `default:`): `quill.blueprint` renders the
426
426
  default value with a type-only `# <type>` annotation (shippable as-is),
427
427
  and the default is used when the document omits the field.
428
428
 
@@ -431,7 +431,7 @@ document emits the non-fatal `validation::must_fill` warning.
431
431
 
432
432
  ### Errors
433
433
 
434
- Every method that can fail throws a **`QuillmarkError`** a JS `Error` with
434
+ Every method that can fail throws a **`QuillmarkError`**: a JS `Error` with
435
435
  `.diagnostics` attached. The type and a guard are exported from the root:
436
436
 
437
437
  ```ts
@@ -443,17 +443,17 @@ try {
443
443
  if (isQuillmarkError(e)) {
444
444
  for (const d of e.diagnostics) console.error(d.severity, d.message);
445
445
  } else {
446
- throw e; // not a quillmark failure programming error, re-throw
446
+ throw e; // not a quillmark failure: programming error, re-throw
447
447
  }
448
448
  }
449
449
  ```
450
450
 
451
- `QuillmarkError` is a **structural interface, not a class** the WASM layer
451
+ `QuillmarkError` is a **structural interface, not a class**: the WASM layer
452
452
  throws a real `Error` and attaches the property, so there is no constructor to
453
453
  `instanceof` against; narrow with `isQuillmarkError` (which also works on
454
454
  errors from any build or WASM instance in the page).
455
455
 
456
- `diagnostics` is always non-empty length 1 for most failures, length N for
456
+ `diagnostics` is always non-empty: length 1 for most failures, length N for
457
457
  backend compilation errors. `message` is derived from `diagnostics`
458
458
  (`diagnostics[0].message` for single-diagnostic errors; an aggregate
459
459
  `"<N> error(s): <first.message>"` summary for compilation failures).
@@ -461,15 +461,20 @@ backend compilation errors. `message` is derived from `diagnostics`
461
461
  Read `err.diagnostics[0]` for the primary diagnostic; iterate the array for
462
462
  compilation failures. The same shape applies to every throw site:
463
463
 
464
- - `Document.fromMarkdown` parse errors (missing root `$quill` metadata, YAML
465
- errors, `parse::input_too_large` for inputs > 10 MB).
466
- - `Document` mutators (`storeField`, the writer's `set`, etc.) mutator
464
+ - `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
465
+ errors, `parse::input_too_large` for inputs > 10 MiB).
466
+ - `Document` mutators (`storeField`, the writer's `set`, etc.): mutator
467
467
  failures carry a namespaced `edit::*` `code` on `diagnostics[0]`
468
468
  (`edit::invalid_field_name`, `edit::unknown_field`, `edit::index_out_of_range`,
469
469
  `edit::field_conform`, …). Route on `diagnostics[0].code`, never on message
470
470
  text.
471
- - `engine.render` / `session.render` backend compilation failures and
471
+ - `engine.render` / `session.render`: backend compilation failures and
472
472
  validation errors.
473
+ - Any method taking a `Quill` or `Document`: a handle from a *second* copy of
474
+ `@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
475
+ @quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
476
+ classes; dedupe to one. A value that is not a handle at all keeps its own
477
+ `runtime::not_a_document` / `runtime::not_a_quill`.
473
478
 
474
479
  ### Lifecycle
475
480
 
@@ -480,17 +485,15 @@ teardown hook for callers that want deterministic release.
480
485
 
481
486
  `engine.render` and `engine.open` read the `quill` and `doc` handles
482
487
  synchronously, before their first await, so freeing a handle as soon as the
483
- call returns `try { return engine.render(quill, doc); } finally
484
- { doc.free(); }` is safe even on the first render, while the backend
488
+ call returns: `try { return engine.render(quill, doc); } finally
489
+ { doc.free(); }`: is safe even on the first render, while the backend
485
490
  binary is still loading.
486
491
 
487
492
  The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
488
493
  evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
489
- sugar shown below ([explicit resource management][erm]) needs Node 24, but is
490
- optional the `try` / `finally` fallback runs on the Node 22 floor.
491
-
492
- For environments where `using` (the [explicit resource management][erm]
493
- proposal) hasn't landed, use an explicit `try` / `finally`:
494
+ sugar ([explicit resource management][erm]) needs Node 24 and is optional.
495
+ Where it hasn't landed, an explicit `try` / `finally` runs on the Node 22
496
+ floor:
494
497
 
495
498
  ```ts
496
499
  const session = await engine.open(quill, doc);