@quario/viewer 0.5.0 → 0.7.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
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **`fonts` is validated as a host property.** It was the one the viewer never
15
+ checked, so a malformed record reached the target inside the render and came
16
+ back as a render failure about a report that was never at fault. Its shape is
17
+ now checked with the other host properties and named on `fonts` rather than
18
+ on the target's `options.fonts`. A face that will not parse, or a missing
19
+ parser, is still found while the report is measured and remains a render
20
+ failure there.
21
+
22
+ ### Changed
23
+
24
+ - **A rejected host property reports as `host-option`.** `ViewerErrorKind`
25
+ gains a fourth member, so a host switching exhaustively over the kind needs
26
+ a case for it. A `page`, `zoom`, `filename`, `colorScheme` or `fonts` the
27
+ viewer rejected was reported as `mount-render` or `update-render` — a render
28
+ that was never attempted — and the panel said "Could not render the report",
29
+ which reads as the report being at fault. It now says the viewer is
30
+ misconfigured and that the report was not the problem.
31
+
32
+ A malformed `report` or `targets` still reports as a render failure: those
33
+ are refused where the render begins rather than when the property is written,
34
+ so nothing yet tells them apart from a render that failed.
35
+
36
+ ## [0.6.0] - 2026-09-07
37
+
38
+ ### Changed
39
+
40
+ - **Numbers presented through `format` now show a fixed two fraction digits**,
41
+ the same as every other target: `1,000.00` where the preview showed `1,000`,
42
+ `21.00%` where it showed `21%`, and a currency's own minor units in place of
43
+ a universal two. The digits come from the engine, so a page on screen and
44
+ the PDF of it agree. A formatted cell is also up to three characters wider,
45
+ so a line that just fitted can wrap and move a page break.
46
+
47
+ ### Fixed
48
+
49
+ - **A page the browser cannot fully paint no longer fails the render.** An
50
+ image whose pixel data is corrupt past the size in its header left that page
51
+ blank _and_ rejected `renderComplete`, so a host awaiting it saw an unhandled
52
+ rejection instead of an answer, and every page after it on screen went
53
+ unpainted too. Such an image is now drawn as nothing and the page is drawn
54
+ around it, marking and all; `renderComplete` resolves `true`, `rendered`
55
+ fires, and no `error` event is raised. It never rejects for this reason
56
+ again: a page that cannot be drawn is blank, not a failed render.
57
+
10
58
  ## [0.5.0] - 2026-09-05
11
59
 
12
60
  ### Changed
package/README.md CHANGED
@@ -97,13 +97,20 @@ non-bubbling, like `<img>`'s. Listen on the element. (Because the event is named
97
97
  inline `onerror` attribute on the element would fire too; `window.onerror` never sees it.)
98
98
 
99
99
  `renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
100
- on screen painted, `false` when it failed or there was nothing to render. It never rejects; failures
101
- arrive on the `error` event. `rendered` fires at that same moment. Like every outcome here, it
102
- answers for the newest render only. A superseded render's failure is reported to no one.
100
+ on screen finished trying to paint, `false` when it failed or there was nothing to render. It never
101
+ rejects; failures arrive on the `error` event. `rendered` fires at that same moment. Like every
102
+ outcome here, it answers for the newest render only. A superseded render's failure is reported to no
103
+ one.
103
104
 
104
- Your own mistakes surface on the same channel: a report that is not compiled, a missing
105
- `"html"` target, or a malformed option property becomes a `TypeError` naming the property, on
106
- the `error` event and the [error panel](#errors).
105
+ An image the browser cannot decode pixel data corrupt past the size in its header, which is all
106
+ the engine reads — is drawn as nothing, and the page is drawn around it. That is not a render
107
+ failure: `renderComplete` answers `true`, `rendered` fires, and no `error` event is raised. A face in
108
+ `fonts` is a different story: it is parsed while the report is measured, so bytes that will not parse
109
+ are a render error with a panel.
110
+
111
+ Your own mistakes surface on the same channel: a report that is not compiled, or a malformed
112
+ option property, becomes a `TypeError` naming the property, on the `error` event and the
113
+ [error panel](#errors). No particular target is required — the sheet is the layout's own.
107
114
 
108
115
  ## Lifecycle
109
116
 
@@ -250,7 +257,8 @@ export marks every page: it rides on the layout, so what you see is what the doc
250
257
  licensed render carries none.
251
258
 
252
259
  While a render is in flight a thin indeterminate bar sits on the toolbar's bottom edge, and the
253
- viewer reads `aria-busy="true"`. It reports that the viewer is working, not how far along. The
260
+ viewer's inner container the `.qv-viewer` div in its shadow root, not the element itself
261
+ reads `aria-busy="true"`. It reports that the viewer is working, not how far along. The
254
262
  engine streams events and cannot know how many are still coming.
255
263
 
256
264
  There is no Print button, because the sheet is the wrong thing to print. Your page's stylesheets do
@@ -261,9 +269,9 @@ document, the same bytes the PDF export hands over. A host that wants its own Pr
261
269
  two lines:
262
270
 
263
271
  ```js
264
- // `pdfTarget` is the pdf() from the quick start above, and `data` whatever you
265
- // last rendered — so what prints is exactly what the PDF export would produce.
266
- const bytes = await rpt.render(pdfTarget, data);
272
+ // The same compiled report and data the element holds, through the pdf()
273
+ // target from the quick start — so what prints is exactly the PDF export.
274
+ const bytes = await view.report.render(pdf(), view.data);
267
275
  window.open(URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })));
268
276
  ```
269
277
 
@@ -273,7 +281,7 @@ the export is, watermark and all.
273
281
  ## Errors
274
282
 
275
283
  When a render or an export fails, the viewer says so on the **error panel**, a strip across the top
276
- of the sheet carrying a short label and the error's own message. It replaces rather than stacks, is
284
+ of the sheet carrying a short label — "Could not render the report", "Could not update the report — showing the previous version", or "Could not export PDF" — and the error's own message. It replaces rather than stacks, is
277
285
  dismissed by its own button, and is cleared by the next render that lands on the sheet. A successful
278
286
  export leaves it up, because the panel describes what you are looking at and a download says
279
287
  nothing about that.
package/lib/check.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * Pure policy, no DOM: this module is what the Node suite pins.
10
10
  */
11
11
 
12
- import { pageBox } from "@quario/layout";
12
+ import { checkFonts, pageBox } from "@quario/layout";
13
13
  import { STEPS } from "./zoom.js";
14
14
 
15
15
  /** @type {(message: string) => never} */
@@ -55,6 +55,22 @@ export let geometry = (page) => {
55
55
  }
56
56
  };
57
57
 
58
+ /**
59
+ * The font mapping from the `fonts` property. Only the shape reaches here: a
60
+ * face that will not parse, or a missing parser, is found while the report is
61
+ * measured and is a render failure there. The layout names the mistake; this
62
+ * names the property, the way `geometry` does.
63
+ *
64
+ * @param {any} fonts
65
+ */
66
+ export let faces = (fonts) => {
67
+ try {
68
+ checkFonts(fonts, "fonts");
69
+ } catch (error) {
70
+ fail(/** @type {Error} */ (error).message);
71
+ }
72
+ };
73
+
58
74
  /** @type {(mode: any, floor: number, ceiling: number) => boolean} */
59
75
  let inRange = (mode, floor, ceiling) =>
60
76
  [Number.isFinite(mode), mode >= floor, mode <= ceiling].every(Boolean);
package/lib/index.d.ts CHANGED
@@ -24,16 +24,17 @@ export type ViewerFonts = LayoutFonts;
24
24
  export type ViewableReport = CompiledReport;
25
25
 
26
26
  /** Which failure an `error` event names. */
27
- export type ViewerErrorKind = "mount-render" | "update-render" | "export";
27
+ export type ViewerErrorKind = "mount-render" | "update-render" | "export" | "host-option";
28
28
 
29
29
  /** The `error` event's payload. */
30
30
  export interface ViewerErrorDetail {
31
31
  /** The caught value, exactly as thrown. */
32
32
  error: unknown;
33
33
  /**
34
- * Which failure occurred: `mount-render` until a render has ever landed
35
- * on the sheet, `update-render` after, `export` for a download that could not
36
- * be produced.
34
+ * Which failure occurred: `host-option` for a property the viewer rejected,
35
+ * which reached no render at all; otherwise `mount-render` until a render
36
+ * has ever landed on the sheet, `update-render` after, and `export` for a
37
+ * download that could not be produced.
37
38
  */
38
39
  kind: ViewerErrorKind;
39
40
  }
@@ -97,10 +98,11 @@ export class QuarioViewer extends LitElement {
97
98
  colorScheme: "light" | "dark" | "auto" | undefined;
98
99
  /**
99
100
  * The newest render settling: `true` when it landed on the sheet with the
100
- * pages on screen painted, `false` when it failed or there was nothing to
101
- * render. Never rejects failures are the `error` event's and like every
102
- * outcome here it answers for the newest render only. `rendered` fires at
103
- * that same moment.
101
+ * pages on screen finished trying to paint, `false` when it failed or there
102
+ * was nothing to render. A page the browser could not draw is blank and the
103
+ * render still landed. Never rejects failures are the
104
+ * `error` event's — and like every outcome here it answers for the newest
105
+ * render only. `rendered` fires at that same moment.
104
106
  */
105
107
  get renderComplete(): Promise<boolean>;
106
108
 
package/lib/index.js CHANGED
@@ -26,10 +26,11 @@
26
26
  */
27
27
  import { Task, TaskStatus } from "@lit/task";
28
28
  import { LitElement, html } from "lit";
29
+ import { Landing, failures, options } from "@quario/landing";
29
30
  import { layout } from "@quario/layout";
30
31
  import { BUTTON } from "./button.js";
31
32
  import { CHROME, progress } from "./chrome.js";
32
- import { exports, geometry, level, name, scheme } from "./check.js";
33
+ import { exports, faces, geometry, level, name, scheme } from "./check.js";
33
34
  import { MENU, zoomMenu } from "./menu.js";
34
35
  import { PANEL, label, panel } from "./panel.js";
35
36
  import { SURFACE, stage } from "./stage.js";
@@ -39,12 +40,6 @@ import { wanted } from "./zoom.js";
39
40
  /** @type {(report: unknown, targets: unknown) => boolean} */
40
41
  let vacant = (report, targets) => report === undefined && targets === undefined;
41
42
 
42
- /** @type {(error: unknown) => unknown} */
43
- let said = (error) => {
44
- // oxlint-disable-next-line typescript/no-base-to-string
45
- return error && String(error);
46
- };
47
-
48
43
  // The properties are described once, in the hand-written public declarations,
49
44
  // and read back here — a second copy in JSDoc is a copy that drifts.
50
45
  /** @import { ViewableReport, ViewerFonts, ViewerPage } from './index.d.ts' */
@@ -89,30 +84,36 @@ export class QuarioViewer extends LitElement {
89
84
  // fails and a disconnect that does not. The template interpolates its
90
85
  // element as a node, which lit-html leaves untouched across re-renders.
91
86
  #stage = stage();
92
- /** Whether a render has ever landed on the sheet — the mount/update boundary. */
93
- #landed = false;
94
- /** Whether the sheet stopped matching the properties while disconnected. */
95
- #stale = false;
96
87
  /** @type {"fit" | number} The live zoom mode; the controls move it. */
97
88
  #mode = "fit";
98
89
  /** @type {{ width: number, height: number, margin: number }} */
99
90
  #box = geometry(undefined);
100
91
  #name = "report";
101
- /** A property the host got wrong, rethrown by the task so it reports once. */
102
- /** @type {unknown} */
103
- #invalid;
104
- /** Bumped to re-run the task when its real arguments did not change. */
105
- #epoch = 0;
106
- /** @type {{ label: string, error: unknown } | undefined} */
107
- #failure;
108
- #dismissed = false;
92
+ /** The option check across one update cycle: first-failure-wins, the
93
+ * re-validate-everything rule, and the commit gate that keeps a `filename`
94
+ * write from clobbering the mode a reader clicked to. */
95
+ #options = options();
96
+ /** The render boundary — landed, stale, and the epoch that re-runs the task
97
+ * when its real arguments did not change. */
98
+ #landing = new Landing(this, { abort: () => this.#task.abort() });
99
+ /** The panel's model, with this element's own announce policy: every
100
+ * failure is drawn, because the viewer only re-runs when a host property or
101
+ * the epoch moves, so its rate is already bounded. The panel is a state and
102
+ * the error event is the log. */
103
+ #failures = failures();
109
104
  /** Re-applies geometry and scale after the update that changed them. */
110
105
  #reapply = false;
111
106
  /** @type {Set<string>} The exports in flight; their buttons disable. */
112
107
  #exporting = new Set();
113
108
  /** @type {(() => void) | undefined} */
114
109
  #unwatch;
115
- /** @type {Promise<void>} The newest swap's paint, which `renderComplete` waits behind. */
110
+ /**
111
+ * The newest swap's paint, which `renderComplete` waits behind. Bare, with
112
+ * no catch of its own: the stage settles a page it cannot draw rather than
113
+ * rejecting, so the chain below is reached whatever the pixels did.
114
+ *
115
+ * @type {Promise<void>}
116
+ */
116
117
  #painting = Promise.resolve();
117
118
 
118
119
  // The whole async pipeline: keyed on the render properties, re-run when one
@@ -124,7 +125,7 @@ export class QuarioViewer extends LitElement {
124
125
  // show" result: unlike the task primitive's own initial-state symbol it
125
126
  // settles `taskComplete`, which is what lets `renderComplete` always answer.
126
127
  #task = new Task(this, {
127
- args: () => [this.report, this.targets, this.data, this.page, this.fonts, this.#epoch],
128
+ args: () => [this.report, this.targets, this.data, this.page, this.fonts, this.#landing.epoch],
128
129
  task: async ([report, targets, data, page, fonts], { signal }) => {
129
130
  if (!this.#begin(report, targets)) return null;
130
131
  // The sheet's own target: the layout list, on the host's page and
@@ -135,7 +136,7 @@ export class QuarioViewer extends LitElement {
135
136
  // The engine takes no signal, so abandonment is the guards around this
136
137
  // body; the check only spares the swap when the answer arrives after a
137
138
  // disconnect mid-render.
138
- if (signal.aborted && !this.isConnected) return this.#abandon();
139
+ if (this.#landing.dropped(signal, this.isConnected)) return this.#landing.abandon();
139
140
  return { list, fonts };
140
141
  },
141
142
  onComplete: (result) => {
@@ -143,14 +144,12 @@ export class QuarioViewer extends LitElement {
143
144
  // The swap sizes every page before it settles, so the reader's place
144
145
  // is held; the paint it awaits is what `rendered` waits for.
145
146
  this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
146
- this.#landed = true;
147
- this.#stale = false;
147
+ this.#landing.land();
148
148
  // A render that landed on the sheet takes the panel down: the panel
149
149
  // says what is wrong with what the reader is looking at, and this is
150
150
  // the moment that stops being true. A successful export is not that
151
151
  // moment.
152
- this.#failure = undefined;
153
- this.#dismissed = false;
152
+ this.#failures.clear();
154
153
  // After the paint, so the update the task queued has already run.
155
154
  this.requestUpdate();
156
155
  this.dispatchEvent(new CustomEvent("rendered"));
@@ -158,7 +157,7 @@ export class QuarioViewer extends LitElement {
158
157
  },
159
158
  onError: (error) => {
160
159
  if (!this.isConnected) return;
161
- this.#announce(error, this.#landed ? "update-render" : "mount-render");
160
+ this.#announce(error, this.#kindOf(error));
162
161
  },
163
162
  });
164
163
 
@@ -186,19 +185,22 @@ export class QuarioViewer extends LitElement {
186
185
  }
187
186
 
188
187
  /**
189
- * The newest render settling: `true` when it landed on the sheet, `false`
190
- * when it failed or there was nothing to render. It never rejects a
191
- * failed render is handled, on the panel and through the error event and
192
- * like every outcome here it answers for the newest render only.
188
+ * The newest render settling: `true` when it landed on the sheet — after
189
+ * its pages have finished trying to paint `false` when it failed or there
190
+ * was nothing to render. A page the browser could not draw is blank and the
191
+ * render still landed, so `true` promises the sheet is done changing, not
192
+ * that every page carries pixels. It never rejects — a failed
193
+ * render is handled, on the panel and through the error event — and like
194
+ * every outcome here it answers for the newest render only.
193
195
  *
194
196
  * @returns {Promise<boolean>}
195
197
  */
196
198
  get renderComplete() {
197
199
  return this.updateComplete.then(() =>
198
200
  this.#task.status === TaskStatus.INITIAL
199
- ? this.#landed
201
+ ? this.#landing.landed
200
202
  : this.#task.taskComplete.then(
201
- () => this.#painting.then(() => this.#landed),
203
+ () => this.#painting.then(() => this.#landing.landed),
202
204
  () => false,
203
205
  ),
204
206
  );
@@ -218,7 +220,7 @@ export class QuarioViewer extends LitElement {
218
220
  this.#checkOptions(changed);
219
221
  // A new document is a new subject: dismissing the last failure said
220
222
  // nothing about this one.
221
- if (["data", "report"].some((key) => changed.has(key))) this.#dismissed = false;
223
+ if (["data", "report"].some((key) => changed.has(key))) this.#failures.reopen();
222
224
  }
223
225
 
224
226
  render() {
@@ -238,7 +240,7 @@ export class QuarioViewer extends LitElement {
238
240
  })}
239
241
  </div>
240
242
  <div class="qv-body">
241
- ${this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : ""}
243
+ ${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
242
244
  ${this.#stage.element}
243
245
  </div>
244
246
  </div>
@@ -267,14 +269,12 @@ export class QuarioViewer extends LitElement {
267
269
  // property write landed while disconnected. The task's arguments did not
268
270
  // change, so the epoch is what re-runs it; left alone, reparenting a
269
271
  // settled viewer costs nothing.
270
- this.#wake();
271
272
  }
272
273
 
273
274
  disconnectedCallback() {
274
275
  super.disconnectedCallback();
275
276
  // Abandon in-flight work and release the observer. The properties and the
276
277
  // sheet persist: removal is destruction only in the collector's sense.
277
- this.#task.abort();
278
278
  this.#unwatch?.();
279
279
  this.#unwatch = undefined;
280
280
  }
@@ -290,27 +290,13 @@ export class QuarioViewer extends LitElement {
290
290
  * @param {unknown} targets
291
291
  */
292
292
  #begin(report, targets) {
293
- if (this.#invalid) throw this.#invalid;
293
+ if (this.#options.invalid) throw this.#options.invalid;
294
294
  if (vacant(report, targets)) return null;
295
- if (!this.isConnected) return this.#abandon();
295
+ if (!this.isConnected) return this.#landing.abandon();
296
296
  exports(report, targets);
297
297
  return true;
298
298
  }
299
299
 
300
- #abandon() {
301
- this.#stale = true;
302
- return null;
303
- }
304
-
305
- /** @param {() => void} run */
306
- #take(run) {
307
- try {
308
- run();
309
- } catch (error) {
310
- this.#invalid ??= error;
311
- }
312
- }
313
-
314
300
  /**
315
301
  * Each property validates and commits on its own, stashing the first
316
302
  * failure: one bad property must not block a good write to another. All
@@ -327,42 +313,59 @@ export class QuarioViewer extends LitElement {
327
313
  * @param {Map<string, unknown>} changed
328
314
  */
329
315
  #checkOptions(changed) {
330
- let was = this.#invalid;
331
- this.#invalid = undefined;
332
- this.#take(() => {
333
- let box = geometry(this.page);
334
- if (!this.hasUpdated || changed.has("page")) {
335
- this.#box = box;
336
- this.#reapply = true;
337
- }
338
- });
339
- this.#take(() => {
340
- let mode = level(this.zoom);
341
- // Committed only when the host wrote `zoom`, so re-validating on a
342
- // filename change cannot clobber the mode the reader clicked to.
343
- if (!this.hasUpdated || changed.has("zoom")) {
344
- this.#mode = mode;
345
- this.#reapply = true;
346
- }
347
- });
348
- this.#take(() => {
349
- this.#name = name(this.filename);
350
- });
351
- this.#take(() => {
352
- let used = scheme(this.colorScheme);
353
- // Same gate as zoom/page: re-validate always, write the CSSOM pin only
354
- // when this property changed, so a filename write does not dirty
355
- // inherited color-scheme before layout.
356
- if (!this.hasUpdated || changed.has("colorScheme")) this.style.colorScheme = used;
357
- });
358
- if (said(was) !== said(this.#invalid)) this.#epoch++;
316
+ let moved = this.#options.recheck(this.hasUpdated, changed, [
317
+ (gate) => {
318
+ let box = geometry(this.page);
319
+ if (gate("page")) {
320
+ this.#box = box;
321
+ this.#reapply = true;
322
+ }
323
+ },
324
+ (gate) => {
325
+ // Committed only when the host wrote `zoom`, so re-validating on a
326
+ // filename change cannot clobber the mode the reader clicked to.
327
+ let mode = level(this.zoom);
328
+ if (gate("zoom")) {
329
+ this.#mode = mode;
330
+ this.#reapply = true;
331
+ }
332
+ },
333
+ () => void (this.#name = name(this.filename)),
334
+ // Nothing to commit: the shape is the whole answer, and the faces
335
+ // themselves are loaded inside the render where a bad one is a render
336
+ // failure. Checked here so a malformed record is a mistake named on the
337
+ // property rather than one the target names on `options.fonts`.
338
+ () => faces(this.fonts),
339
+ (gate) => {
340
+ // Same gate as zoom/page: re-validate always, write the CSSOM pin only
341
+ // when this property changed, so a filename write does not dirty
342
+ // inherited color-scheme before layout.
343
+ let used = scheme(this.colorScheme);
344
+ if (gate("colorScheme")) this.style.colorScheme = used;
345
+ },
346
+ ]);
347
+ if (moved) this.#landing.epoch++;
359
348
  }
360
349
 
361
- #wake() {
362
- if (this.#stale || this.#task.status === TaskStatus.PENDING) {
363
- this.#epoch++;
364
- this.requestUpdate();
365
- }
350
+ /**
351
+ * Which failure a throw from the task is. Identity, not a tag: `#begin`
352
+ * throws the very value `#checkOptions` stashed, so the one it stashed is
353
+ * the one host mistake this can name for certain — and a property the
354
+ * viewer rejected reached no render at all, so it carries no
355
+ * mount-versus-update distinction.
356
+ *
357
+ * `exports()`'s `report` and `targets` are host mistakes too and still
358
+ * report as a render: they are thrown from `#begin` rather than stashed, so
359
+ * nothing here tells them from a render that failed. Closing that means the
360
+ * task returning failures instead of throwing them, which is the channel
361
+ * quario-70jg.27 reworks — not worth doing twice.
362
+ *
363
+ * @param {unknown} error
364
+ * @returns {import('./index.d.ts').ViewerErrorKind}
365
+ */
366
+ #kindOf(error) {
367
+ if (error !== undefined && error === this.#options.invalid) return "host-option";
368
+ return this.#landing.landed ? "update-render" : "mount-render";
366
369
  }
367
370
 
368
371
  /** @param {() => void} run */
@@ -399,7 +402,7 @@ export class QuarioViewer extends LitElement {
399
402
  }
400
403
 
401
404
  #dismiss() {
402
- this.#dismissed = true;
405
+ this.#failures.dismiss();
403
406
  this.requestUpdate();
404
407
  }
405
408
 
@@ -409,12 +412,11 @@ export class QuarioViewer extends LitElement {
409
412
  * or anything after a disconnect — never come through here.
410
413
  *
411
414
  * @param {unknown} error The caught value.
412
- * @param {"mount-render" | "update-render" | "export"} kind
415
+ * @param {import('./index.d.ts').ViewerErrorKind} kind
413
416
  * @param {string} [format] The export format's name, for that kind alone.
414
417
  */
415
418
  #announce(error, kind, format) {
416
- this.#failure = { error, label: label(kind, format) };
417
- this.#dismissed = false;
419
+ this.#failures.announce(label(kind, format), error);
418
420
  this.requestUpdate();
419
421
  this.dispatchEvent(new CustomEvent("error", { detail: { error, kind } }));
420
422
  }
package/lib/panel.js CHANGED
@@ -80,16 +80,23 @@ export let PANEL = css`
80
80
  * its length: that failure leaves the previous report on the sheet, and
81
81
  * silently stale content is the thing the panel exists to prevent.
82
82
  *
83
- * @param {"mount-render" | "update-render" | "export"} kind
83
+ * @param {import('./index.d.ts').ViewerErrorKind} kind
84
84
  * @param {string} [format] The export format's name, for that kind alone.
85
85
  * @returns {string}
86
86
  */
87
87
  export let label = (kind, format) =>
88
88
  kind === "export"
89
89
  ? "Could not export " + format
90
- : kind === "update-render"
91
- ? "Could not update the report showing the previous version"
92
- : "Could not render the report";
90
+ : kind === "host-option"
91
+ ? // Named apart because the panel speaks to the reader, and where that
92
+ // reader is a playground's visitor the panel is the product rather
93
+ // than chrome (CONTEXT.md, "Error panel"). Told the report could not
94
+ // be rendered, they read it as their own document failing. A property
95
+ // they cannot see and cannot fix is where that reading is wrong.
96
+ "This viewer is misconfigured — the report itself was not the problem."
97
+ : kind === "update-render"
98
+ ? "Could not update the report — showing the previous version"
99
+ : "Could not render the report";
93
100
 
94
101
  /**
95
102
  * What an error says. Not every throw is an `Error` — a host's registry
package/lib/stage.js CHANGED
@@ -21,6 +21,19 @@
21
21
  * asked a thousand-page report for gigabytes of backing store, and past what
22
22
  * the browser would grant the pages simply came up blank.
23
23
  *
24
+ * **A page the browser will not paint is blank, not a failure.** `start`
25
+ * swallows and nothing here rejects: the report laid out and the list reached
26
+ * the sheet, which is all a render promised, so the surfaces have no channel
27
+ * for it and deliberately grow none.
28
+ *
29
+ * Little reaches it. An image that will not decode is the layout's own to
30
+ * absorb — `paint` draws it as nothing and draws the page around it — and a
31
+ * face is parsed with fontkit while the report is measured, so bytes that will
32
+ * not parse are a render error long before the sheet. What is left is the face
33
+ * fontkit accepted and the browser refuses, which the browser suite drives by
34
+ * making `FontFace.prototype.load` reject under a record handed straight to
35
+ * `swap`.
36
+ *
24
37
  * **A canvas on the sheet is never re-pointed at a second paint.** `paint()`
25
38
  * awaits before it draws, so a canvas whose paint is still in flight is
26
39
  * *retired* rather than painted over or emptied: it comes off the sheet, its
@@ -57,17 +70,12 @@
57
70
  */
58
71
 
59
72
  import { css } from "lit";
60
- import { PX_PER_POINT, paint } from "@quario/layout";
61
-
62
- /**
63
- * Space around the sheet, in px. Read from here and nowhere else: the stage's
64
- * margin in the CSS below, the width `fit()` measures against, and the offset
65
- * `scale()` converts a scroll position through.
66
- */
67
- let GUTTER = 28;
73
+ import { GAP, GUTTER, sheet } from "@quario/landing";
74
+ import { PX_PER_POINT } from "@quario/layout";
68
75
 
69
- /** The gap between two pages, in px. */
70
- let GAP = 16;
76
+ // `GUTTER` and `GAP` come from `@quario/landing`, which walks the reach over
77
+ // them: the CSS below and the sheet's own arithmetic have to agree about where
78
+ // a page sits, and two copies of that is a reach that paints the wrong pages.
71
79
 
72
80
  export let SURFACE = css`
73
81
  /* Where scrollbars take width, the gutter is held whether one is showing or
@@ -137,307 +145,24 @@ export let stage = () => {
137
145
  // inside the viewer. The `qv-*` names are documented as stable, so the
138
146
  // narrower one keeps its name.
139
147
  wrapper.className = "qv-stage";
140
- let sheet = document.createElement("div");
141
- sheet.className = "qv-sheet";
142
- wrapper.append(sheet);
148
+ let sheetEl = document.createElement("div");
149
+ sheetEl.className = "qv-sheet";
150
+ wrapper.append(sheetEl);
143
151
  scroll.append(wrapper);
144
152
 
145
153
  /** The page width to fit against before a list arrives, in points. */
146
154
  let fallback = 0;
147
- /** @type {any} */
148
- let list = null;
149
- /** @type {any} */
150
- let fonts;
151
155
  /** The percentage currently on screen. */
152
156
  let applied = 100;
153
-
154
- /**
155
- * What each backed page is carrying: the scale it was painted at, so a page
156
- * re-entering the reach at that scale is not repainted and a scale change
157
- * invalidates it, and the paint itself, so whoever asks second waits behind
158
- * the paint the first caller started rather than being told it is done.
159
- * Keyed by the canvas, so a swap's new pages start with nothing remembered
160
- * and the pages it replaced take their entries with them — which is why the
161
- * map is weak: nothing has to remember to forget them.
162
- *
163
- * @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
164
- */
165
- let backed = new WeakMap();
166
-
167
- /**
168
- * The canvases whose paint has finished. A promise's settled state is not
169
- * synchronously observable and this is the one question the rule below
170
- * turns on: a canvas in here has nothing left that could draw into it, so
171
- * it can be emptied or re-sized in place; one that is backed but absent
172
- * here is still being painted, and must be retired instead. Weak on the
173
- * same key as `backed`, so a retired canvas takes its membership with it.
174
- *
175
- * Membership is per paint, not per canvas — `start` takes a canvas out
176
- * before painting it again, or a page settled at one scale would count as
177
- * settled the moment it began painting at the next.
178
- *
179
- * @type {WeakSet<HTMLCanvasElement>}
180
- */
181
- let settled = new WeakSet();
182
-
183
157
  /** CSS pixels per point at the applied percentage. */
184
158
  let ratio = () => (PX_PER_POINT * applied) / 100;
185
159
 
186
- /**
187
- * The canvas standing for page `i` right now. Read through here and never
188
- * held: a retired page is a different element, so a canvas taken before an
189
- * await may be off the sheet by the time it is used.
190
- *
191
- * @type {(i: number) => HTMLCanvasElement}
192
- */
193
- let pageAt = (i) => /** @type {HTMLCanvasElement} */ (sheet.children[i]);
194
-
195
- /**
196
- * Give a page's canvas its size on screen. The one answer to how big page
197
- * `i` is at the applied percentage: `pageOf` builds a canvas with it and
198
- * `sizeAll` writes it over the sheet after a scale change, so a page
199
- * retired between the two cannot arrive sizeless and move the extent the
200
- * reader is scrolling through.
201
- *
202
- * @type {(canvas: HTMLElement, i: number, px?: number) => void}
203
- */
204
- let sizePage = (canvas, i, px = ratio()) => {
205
- let each = list.pages[i];
206
- canvas.style.width = each.width * px + "px";
207
- canvas.style.height = each.height * px + "px";
208
- };
209
-
210
- /**
211
- * One page of the list as a canvas: sized on screen, carrying no pixels
212
- * yet, and named for a screen reader. Every canvas on the sheet is built
213
- * here — a swap's and a retirement's alike — so a replacement is the same
214
- * element in every respect but identity.
215
- *
216
- * @type {(i: number) => HTMLCanvasElement}
217
- */
218
- let pageOf = (i) => {
219
- let canvas = document.createElement("canvas");
220
- canvas.className = "qv-page";
221
- // A canvas is 300 x 150 until told otherwise, and that store counts. A
222
- // page starts with none and takes one once it is inside the reach.
223
- canvas.width = 0;
224
- canvas.height = 0;
225
- canvas.setAttribute("role", "img");
226
- canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
227
- sizePage(canvas, i);
228
- return canvas;
229
- };
230
-
231
- /**
232
- * Take page `i`'s canvas off the sheet and stand a fresh one in its place,
233
- * answering with the replacement. A paint still in flight holds the old
234
- * canvas's context and draws into something nobody is looking at, which is
235
- * how a superseded paint is stopped here — by construction, rather than by
236
- * a check the painter would have to make above its own draw (ADR 0046).
237
- * The retired canvas takes its entries in `backed` and `settled` with it.
238
- *
239
- * Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
240
- * store is what ADR 0043 rations, and leaving it to be collected whenever
241
- * the superseded paint lets go of the context is the timing that ADR
242
- * refuses. `paint()` does its whole `save`/draw/`restore` after its awaits,
243
- * so emptying the canvas between them unbalances nothing — it just leaves
244
- * every op clipped to nothing, which spares the raster work too.
245
- *
246
- * @type {(i: number) => HTMLCanvasElement}
247
- */
248
- let retire = (i) => {
249
- let old = pageAt(i);
250
- let fresh = pageOf(i);
251
- old.replaceWith(fresh);
252
- old.width = 0;
253
- old.height = 0;
254
- return fresh;
255
- };
256
-
257
- /**
258
- * Give page `i`'s pixels back. A page whose paint has settled is emptied in
259
- * place: sizing the canvas to 0 × 0 is the one idiom that frees the store
260
- * synchronously in every engine the viewer runs in, and the CSS size is
261
- * untouched, so the page keeps its place in the extent and shows the
262
- * sheet's white. A page still painting is retired instead — emptying it
263
- * would leave that paint pointed at a canvas the next pass over the reach
264
- * re-sizes and re-paints.
265
- *
266
- * @type {(i: number) => void}
267
- */
268
- let release = (i) => {
269
- let canvas = pageAt(i);
270
- if (!backed.has(canvas)) return;
271
- if (!settled.has(canvas)) return void retire(i);
272
- backed.delete(canvas);
273
- canvas.width = 0;
274
- canvas.height = 0;
275
- };
276
-
277
- /** Device pixels per point at the applied percentage: what a page is
278
- * painted at, and what its backing store is sized in. */
279
- let deviceScale = () => ratio() * (globalThis.devicePixelRatio || 1);
280
-
281
- /**
282
- * Start painting page `i`, and answer with that paint. **The one place a
283
- * paint begins, and the one place the rule is enforced**: a canvas whose
284
- * paint is still in flight is retired here before a second one is pointed
285
- * at it, so no caller can reach a live canvas with a second paint by
286
- * forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
287
- * the next pass over the reach tries again instead of counting the page as
288
- * painted; one that settles joins `settled`.
289
- *
290
- * @type {(i: number) => Promise<void>}
291
- */
292
- let start = (i) => {
293
- let canvas = pageAt(i);
294
- if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
295
- settled.delete(canvas);
296
- let scale = deviceScale();
297
- let each = list.pages[i];
298
- canvas.width = Math.round(each.width * scale);
299
- canvas.height = Math.round(each.height * scale);
300
- let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
301
- let painted = paint(ctx, each, { scale, fonts }).then(
302
- () => {
303
- settled.add(canvas);
304
- },
305
- (failure) => {
306
- backed.delete(canvas);
307
- throw failure;
308
- },
309
- );
310
- backed.set(canvas, { scale, painted });
311
- return painted;
312
- };
313
-
314
- /**
315
- * Paint one page and answer with that paint — or with the paint already
316
- * under way at this scale, which is what makes scrolling back over ground
317
- * already covered free. Answering with the paint rather than with a
318
- * resolved promise is what lets two callers share one page's paint and both
319
- * settle behind its pixels.
320
- *
321
- * Takes the index, not the canvas: the page it paints may be retired out
322
- * from under a caller, so a caller that handed one in would be left holding
323
- * an element that is no longer on the sheet.
324
- *
325
- * @type {(i: number) => Promise<void>}
326
- */
327
- let paintPage = (i) => {
328
- let carrying = backed.get(pageAt(i));
329
- if (carrying && carrying.scale === deviceScale()) return carrying.painted;
330
- return start(i);
331
- };
332
-
333
- /**
334
- * Bumped per repaint, so a repaint overtaken by the next stops walking.
335
- * What that guards is the backing store, not the pixels: a superseded loop
336
- * would paint at the *current* scale — `paintPage` reads it afresh — but
337
- * onto pages the newer reach has since dropped, re-backing pages that
338
- * should be blank (ADR 0043). Stale pixels are `retire`'s business, not
339
- * this one, so neither guard stands in for the other.
340
- *
341
- * It guards repaint against repaint, and nothing else. A repaint chooses
342
- * its pages once and holds that list across its awaits, so a scroll pass
343
- * releasing a page mid-repaint is one this loop will paint anyway; the
344
- * store that leaves behind is bounded by the reach and goes back on the
345
- * next pass over it.
346
- */
347
- let epoch = 0;
348
-
349
- /**
350
- * Give every page its CSS size. Callers run this before writing the scroll
351
- * offsets back: the extent those offsets are clamped against is this one,
352
- * and the reach below is read from the offsets once they are in.
353
- */
354
- let sizeAll = () => {
355
- if (!list) return;
356
- let px = ratio();
357
- for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
358
- };
359
-
360
- /**
361
- * The indices of the pages in the reach — the ones the viewport shows, and
362
- * everything within one viewport height above or below. Walked over the
363
- * list's own geometry (the gutter, each page's height at the applied
364
- * scale, the gap), never a layout read, so it costs nothing to ask
365
- * mid-scroll — the whole list is walked because a multiply and an add per
366
- * page is beneath measuring, and stopping early would be a second rule
367
- * about where the reach ends. `clientHeight` is read afresh each time, so a
368
- * pane that changed size changes the reach with it.
369
- *
370
- * @returns {number[]}
371
- */
372
- let inReach = () => {
373
- let view = scroll.clientHeight;
374
- let top = scroll.scrollTop - view;
375
- let bottom = scroll.scrollTop + 2 * view;
376
- let px = ratio();
377
- let y = GUTTER;
378
- let found = [];
379
- for (let [i, each] of list.pages.entries()) {
380
- let height = each.height * px;
381
- if (y + height >= top && y <= bottom) found.push(i);
382
- y += height + GAP;
383
- }
384
- return found;
385
- };
386
-
387
- /**
388
- * Release every page the reach has left behind, and answer with the indices
389
- * of the ones to keep, in the order they are painted in. The single
390
- * definition of which pages carry pixels: the repaint below and the scroll
391
- * pass both go through here, and neither holds a page the other released.
392
- *
393
- * Indices rather than elements, for the reason `paintPage` takes one: a
394
- * page can be retired between this pass and the paint that follows it.
395
- *
396
- * @type {() => number[]}
397
- */
398
- let keep = () => {
399
- let wanted = new Set(inReach());
400
- let kept = [];
401
- // `retire` replaces one for one, so the count is fixed across the walk.
402
- for (let i = 0, total = sheet.children.length; i < total; i++) {
403
- if (wanted.has(i)) kept.push(i);
404
- else release(i);
405
- }
406
- return kept;
407
- };
408
-
409
- // Paint the reach in order, yielding between pages and giving way to any
410
- // repaint that started since. Sizing is the caller's, and comes first.
411
- let repaint = async () => {
412
- if (!list) return;
413
- let mine = ++epoch;
414
- for (let i of keep()) {
415
- if (mine !== epoch) return;
416
- await paintPage(i);
417
- }
418
- };
419
-
420
- /**
421
- * One reach pass a frame, however many scrolls and resizes ask for one. It
422
- * keeps no epoch of its own, so a swap painting behind it is never cut off
423
- * part-painted, and a page it starts is one a swap arriving at the same page
424
- * waits behind rather than skips. Nothing awaits these paints here: a scroll
425
- * is not a render and has no failure channel of its own, and a paint that
426
- * failed is forgotten, so the next pass over the reach reports it the way
427
- * any other paint does.
428
- */
429
- let pending = false;
430
- let follow = () => {
431
- if (pending) return;
432
- pending = true;
433
- requestAnimationFrame(() => {
434
- pending = false;
435
- if (!list) return;
436
- for (let i of keep()) paintPage(i).catch(() => {});
437
- });
438
- };
439
- scroll.addEventListener("scroll", follow);
440
-
160
+ /** The paged sheet: the page canvases, their backing-store lifetime, the
161
+ * reach and its painting, in `@quario/landing`. It asks `ratio()` afresh,
162
+ * so the percentage this element applies is the scale it paints at and
163
+ * the elements above stay this file's, because the CSS is the viewer's own
164
+ * public surface. */
165
+ let paged = sheet(scroll, sheetEl, ratio);
441
166
  return {
442
167
  element: scroll,
443
168
 
@@ -450,7 +175,7 @@ export let stage = () => {
450
175
  fit: () => {
451
176
  let usable = scroll.clientWidth - 2 * GUTTER;
452
177
  // The list's page width, or the geometry's before one arrives.
453
- let width = (list ? list.width : fallback) * PX_PER_POINT;
178
+ let width = (paged.width() ?? fallback) * PX_PER_POINT;
454
179
  if (usable <= 0 || !width) return null;
455
180
  return (usable / width) * 100;
456
181
  },
@@ -473,7 +198,7 @@ export let stage = () => {
473
198
  * so a zoom step costs a handful of pages however long the report is.
474
199
  */
475
200
  scale: (percent) => {
476
- let held = sheet.firstChild && {
201
+ let held = sheetEl.firstChild && {
477
202
  top: scroll.scrollTop,
478
203
  left: scroll.scrollLeft,
479
204
  height: scroll.clientHeight,
@@ -481,13 +206,13 @@ export let stage = () => {
481
206
  };
482
207
  let ratioOf = percent / applied;
483
208
  applied = percent;
484
- sizeAll();
209
+ paged.sizeAll();
485
210
  if (held) {
486
211
  let middle = held.top + held.height / 2 - GUTTER;
487
212
  scroll.scrollTop = GUTTER + middle * ratioOf - held.height / 2;
488
213
  scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
489
214
  }
490
- void repaint();
215
+ void paged.repaint();
491
216
  },
492
217
 
493
218
  /**
@@ -510,25 +235,22 @@ export let stage = () => {
510
235
  * — and the reach is read from those offsets, so it is chosen after they
511
236
  * are in. What the
512
237
  * returned promise settles behind is the reach, which is what the caller's
513
- * `renderComplete` means by "the pages on screen are painted".
238
+ * `renderComplete` means by "the pages on screen have finished trying to
239
+ * paint" — and **it never rejects**, because `start` swallows a page the
240
+ * browser will not draw and the reach walks on past it.
514
241
  */
515
242
  swap: async (next, faces) => {
516
243
  // Reading the offsets flushes layout, so only an actual reswap pays for
517
244
  // it: on an empty sheet there is nothing scrolled to preserve.
518
- let held = sheet.firstChild && {
245
+ let held = sheetEl.firstChild && {
519
246
  top: scroll.scrollTop,
520
247
  left: scroll.scrollLeft,
521
248
  };
522
- list = next;
523
- fonts = faces;
524
- sheet.replaceChildren(
525
- ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
526
- );
249
+ await paged.swap(next, faces);
527
250
  if (held) {
528
251
  scroll.scrollTop = held.top;
529
252
  scroll.scrollLeft = held.left;
530
253
  }
531
- await repaint();
532
254
  },
533
255
 
534
256
  /**
@@ -541,7 +263,7 @@ export let stage = () => {
541
263
  let observer = new ResizeObserver(() => {
542
264
  // A resize that changes the fit repaints through `changed()`; one
543
265
  // that does not still moved the viewport the reach is measured in.
544
- follow();
266
+ paged.follow();
545
267
  changed();
546
268
  });
547
269
  observer.observe(scroll);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/viewer",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The embeddable report viewer shell for quario — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -44,7 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@lit/task": "^1.0.3",
47
- "@quario/layout": "^0.2.0",
47
+ "@quario/landing": "^0.1.0",
48
+ "@quario/layout": "^0.4.0",
48
49
  "lit": "^3.3.3"
49
50
  },
50
51
  "devDependencies": {
@@ -53,18 +54,19 @@
53
54
  "@size-limit/preset-small-lib": "^13.0.3",
54
55
  "esbuild": "^0.28.2",
55
56
  "exceljs": "^4.4.0",
56
- "quario": "^0.5.0",
57
+ "quario": "^0.7.0",
57
58
  "size-limit": "^13.0.3",
58
59
  "typescript": "^7.0.2"
59
60
  },
60
61
  "peerDependencies": {
61
- "quario": "^0.5.0"
62
+ "quario": "^0.7.0"
62
63
  },
63
64
  "size-limit": [
64
65
  {
65
66
  "path": "lib/index.js",
66
67
  "ignore": [
67
68
  "quario",
69
+ "@quario/landing",
68
70
  "@quario/layout",
69
71
  "lit",
70
72
  "@lit/task"