@quario/viewer 0.6.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,32 @@ 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
+
10
36
  ## [0.6.0] - 2026-09-07
11
37
 
12
38
  ### Changed
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
  }
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,23 +84,23 @@ 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. */
@@ -130,7 +125,7 @@ export class QuarioViewer extends LitElement {
130
125
  // show" result: unlike the task primitive's own initial-state symbol it
131
126
  // settles `taskComplete`, which is what lets `renderComplete` always answer.
132
127
  #task = new Task(this, {
133
- 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],
134
129
  task: async ([report, targets, data, page, fonts], { signal }) => {
135
130
  if (!this.#begin(report, targets)) return null;
136
131
  // The sheet's own target: the layout list, on the host's page and
@@ -141,7 +136,7 @@ export class QuarioViewer extends LitElement {
141
136
  // The engine takes no signal, so abandonment is the guards around this
142
137
  // body; the check only spares the swap when the answer arrives after a
143
138
  // disconnect mid-render.
144
- if (signal.aborted && !this.isConnected) return this.#abandon();
139
+ if (this.#landing.dropped(signal, this.isConnected)) return this.#landing.abandon();
145
140
  return { list, fonts };
146
141
  },
147
142
  onComplete: (result) => {
@@ -149,14 +144,12 @@ export class QuarioViewer extends LitElement {
149
144
  // The swap sizes every page before it settles, so the reader's place
150
145
  // is held; the paint it awaits is what `rendered` waits for.
151
146
  this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
152
- this.#landed = true;
153
- this.#stale = false;
147
+ this.#landing.land();
154
148
  // A render that landed on the sheet takes the panel down: the panel
155
149
  // says what is wrong with what the reader is looking at, and this is
156
150
  // the moment that stops being true. A successful export is not that
157
151
  // moment.
158
- this.#failure = undefined;
159
- this.#dismissed = false;
152
+ this.#failures.clear();
160
153
  // After the paint, so the update the task queued has already run.
161
154
  this.requestUpdate();
162
155
  this.dispatchEvent(new CustomEvent("rendered"));
@@ -164,7 +157,7 @@ export class QuarioViewer extends LitElement {
164
157
  },
165
158
  onError: (error) => {
166
159
  if (!this.isConnected) return;
167
- this.#announce(error, this.#landed ? "update-render" : "mount-render");
160
+ this.#announce(error, this.#kindOf(error));
168
161
  },
169
162
  });
170
163
 
@@ -205,9 +198,9 @@ export class QuarioViewer extends LitElement {
205
198
  get renderComplete() {
206
199
  return this.updateComplete.then(() =>
207
200
  this.#task.status === TaskStatus.INITIAL
208
- ? this.#landed
201
+ ? this.#landing.landed
209
202
  : this.#task.taskComplete.then(
210
- () => this.#painting.then(() => this.#landed),
203
+ () => this.#painting.then(() => this.#landing.landed),
211
204
  () => false,
212
205
  ),
213
206
  );
@@ -227,7 +220,7 @@ export class QuarioViewer extends LitElement {
227
220
  this.#checkOptions(changed);
228
221
  // A new document is a new subject: dismissing the last failure said
229
222
  // nothing about this one.
230
- if (["data", "report"].some((key) => changed.has(key))) this.#dismissed = false;
223
+ if (["data", "report"].some((key) => changed.has(key))) this.#failures.reopen();
231
224
  }
232
225
 
233
226
  render() {
@@ -247,7 +240,7 @@ export class QuarioViewer extends LitElement {
247
240
  })}
248
241
  </div>
249
242
  <div class="qv-body">
250
- ${this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : ""}
243
+ ${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
251
244
  ${this.#stage.element}
252
245
  </div>
253
246
  </div>
@@ -276,14 +269,12 @@ export class QuarioViewer extends LitElement {
276
269
  // property write landed while disconnected. The task's arguments did not
277
270
  // change, so the epoch is what re-runs it; left alone, reparenting a
278
271
  // settled viewer costs nothing.
279
- this.#wake();
280
272
  }
281
273
 
282
274
  disconnectedCallback() {
283
275
  super.disconnectedCallback();
284
276
  // Abandon in-flight work and release the observer. The properties and the
285
277
  // sheet persist: removal is destruction only in the collector's sense.
286
- this.#task.abort();
287
278
  this.#unwatch?.();
288
279
  this.#unwatch = undefined;
289
280
  }
@@ -299,27 +290,13 @@ export class QuarioViewer extends LitElement {
299
290
  * @param {unknown} targets
300
291
  */
301
292
  #begin(report, targets) {
302
- if (this.#invalid) throw this.#invalid;
293
+ if (this.#options.invalid) throw this.#options.invalid;
303
294
  if (vacant(report, targets)) return null;
304
- if (!this.isConnected) return this.#abandon();
295
+ if (!this.isConnected) return this.#landing.abandon();
305
296
  exports(report, targets);
306
297
  return true;
307
298
  }
308
299
 
309
- #abandon() {
310
- this.#stale = true;
311
- return null;
312
- }
313
-
314
- /** @param {() => void} run */
315
- #take(run) {
316
- try {
317
- run();
318
- } catch (error) {
319
- this.#invalid ??= error;
320
- }
321
- }
322
-
323
300
  /**
324
301
  * Each property validates and commits on its own, stashing the first
325
302
  * failure: one bad property must not block a good write to another. All
@@ -336,42 +313,59 @@ export class QuarioViewer extends LitElement {
336
313
  * @param {Map<string, unknown>} changed
337
314
  */
338
315
  #checkOptions(changed) {
339
- let was = this.#invalid;
340
- this.#invalid = undefined;
341
- this.#take(() => {
342
- let box = geometry(this.page);
343
- if (!this.hasUpdated || changed.has("page")) {
344
- this.#box = box;
345
- this.#reapply = true;
346
- }
347
- });
348
- this.#take(() => {
349
- let mode = level(this.zoom);
350
- // Committed only when the host wrote `zoom`, so re-validating on a
351
- // filename change cannot clobber the mode the reader clicked to.
352
- if (!this.hasUpdated || changed.has("zoom")) {
353
- this.#mode = mode;
354
- this.#reapply = true;
355
- }
356
- });
357
- this.#take(() => {
358
- this.#name = name(this.filename);
359
- });
360
- this.#take(() => {
361
- let used = scheme(this.colorScheme);
362
- // Same gate as zoom/page: re-validate always, write the CSSOM pin only
363
- // when this property changed, so a filename write does not dirty
364
- // inherited color-scheme before layout.
365
- if (!this.hasUpdated || changed.has("colorScheme")) this.style.colorScheme = used;
366
- });
367
- 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++;
368
348
  }
369
349
 
370
- #wake() {
371
- if (this.#stale || this.#task.status === TaskStatus.PENDING) {
372
- this.#epoch++;
373
- this.requestUpdate();
374
- }
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";
375
369
  }
376
370
 
377
371
  /** @param {() => void} run */
@@ -408,7 +402,7 @@ export class QuarioViewer extends LitElement {
408
402
  }
409
403
 
410
404
  #dismiss() {
411
- this.#dismissed = true;
405
+ this.#failures.dismiss();
412
406
  this.requestUpdate();
413
407
  }
414
408
 
@@ -418,12 +412,11 @@ export class QuarioViewer extends LitElement {
418
412
  * or anything after a disconnect — never come through here.
419
413
  *
420
414
  * @param {unknown} error The caught value.
421
- * @param {"mount-render" | "update-render" | "export"} kind
415
+ * @param {import('./index.d.ts').ViewerErrorKind} kind
422
416
  * @param {string} [format] The export format's name, for that kind alone.
423
417
  */
424
418
  #announce(error, kind, format) {
425
- this.#failure = { error, label: label(kind, format) };
426
- this.#dismissed = false;
419
+ this.#failures.announce(label(kind, format), error);
427
420
  this.requestUpdate();
428
421
  this.dispatchEvent(new CustomEvent("error", { detail: { error, kind } }));
429
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
@@ -70,17 +70,12 @@
70
70
  */
71
71
 
72
72
  import { css } from "lit";
73
- import { PX_PER_POINT, paint } from "@quario/layout";
73
+ import { GAP, GUTTER, sheet } from "@quario/landing";
74
+ import { PX_PER_POINT } from "@quario/layout";
74
75
 
75
- /**
76
- * Space around the sheet, in px. Read from here and nowhere else: the stage's
77
- * margin in the CSS below, the width `fit()` measures against, and the offset
78
- * `scale()` converts a scroll position through.
79
- */
80
- let GUTTER = 28;
81
-
82
- /** The gap between two pages, in px. */
83
- 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.
84
79
 
85
80
  export let SURFACE = css`
86
81
  /* Where scrollbars take width, the gutter is held whether one is showing or
@@ -150,319 +145,24 @@ export let stage = () => {
150
145
  // inside the viewer. The `qv-*` names are documented as stable, so the
151
146
  // narrower one keeps its name.
152
147
  wrapper.className = "qv-stage";
153
- let sheet = document.createElement("div");
154
- sheet.className = "qv-sheet";
155
- wrapper.append(sheet);
148
+ let sheetEl = document.createElement("div");
149
+ sheetEl.className = "qv-sheet";
150
+ wrapper.append(sheetEl);
156
151
  scroll.append(wrapper);
157
152
 
158
153
  /** The page width to fit against before a list arrives, in points. */
159
154
  let fallback = 0;
160
- /** @type {any} */
161
- let list = null;
162
- /** @type {any} */
163
- let fonts;
164
155
  /** The percentage currently on screen. */
165
156
  let applied = 100;
166
-
167
- /**
168
- * What each backed page is carrying: the scale it was painted at, so a page
169
- * re-entering the reach at that scale is not repainted and a scale change
170
- * invalidates it, and the paint itself, so whoever asks second waits behind
171
- * the paint the first caller started rather than being told it is done.
172
- * Keyed by the canvas, so a swap's new pages start with nothing remembered
173
- * and the pages it replaced take their entries with them — which is why the
174
- * map is weak: nothing has to remember to forget them.
175
- *
176
- * @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
177
- */
178
- let backed = new WeakMap();
179
-
180
- /**
181
- * The canvases whose paint has finished. A promise's settled state is not
182
- * synchronously observable and this is the one question the rule below
183
- * turns on: a canvas in here has nothing left that could draw into it, so
184
- * it can be emptied or re-sized in place; one that is backed but absent
185
- * here is still being painted, and must be retired instead. Weak on the
186
- * same key as `backed`, so a retired canvas takes its membership with it.
187
- *
188
- * Membership is per paint, not per canvas — `start` takes a canvas out
189
- * before painting it again, or a page settled at one scale would count as
190
- * settled the moment it began painting at the next.
191
- *
192
- * @type {WeakSet<HTMLCanvasElement>}
193
- */
194
- let settled = new WeakSet();
195
-
196
157
  /** CSS pixels per point at the applied percentage. */
197
158
  let ratio = () => (PX_PER_POINT * applied) / 100;
198
159
 
199
- /**
200
- * The canvas standing for page `i` right now. Read through here and never
201
- * held: a retired page is a different element, so a canvas taken before an
202
- * await may be off the sheet by the time it is used.
203
- *
204
- * @type {(i: number) => HTMLCanvasElement}
205
- */
206
- let pageAt = (i) => /** @type {HTMLCanvasElement} */ (sheet.children[i]);
207
-
208
- /**
209
- * Give a page's canvas its size on screen. The one answer to how big page
210
- * `i` is at the applied percentage: `pageOf` builds a canvas with it and
211
- * `sizeAll` writes it over the sheet after a scale change, so a page
212
- * retired between the two cannot arrive sizeless and move the extent the
213
- * reader is scrolling through.
214
- *
215
- * @type {(canvas: HTMLElement, i: number, px?: number) => void}
216
- */
217
- let sizePage = (canvas, i, px = ratio()) => {
218
- let each = list.pages[i];
219
- canvas.style.width = each.width * px + "px";
220
- canvas.style.height = each.height * px + "px";
221
- };
222
-
223
- /**
224
- * One page of the list as a canvas: sized on screen, carrying no pixels
225
- * yet, and named for a screen reader. Every canvas on the sheet is built
226
- * here — a swap's and a retirement's alike — so a replacement is the same
227
- * element in every respect but identity.
228
- *
229
- * @type {(i: number) => HTMLCanvasElement}
230
- */
231
- let pageOf = (i) => {
232
- let canvas = document.createElement("canvas");
233
- canvas.className = "qv-page";
234
- // A canvas is 300 x 150 until told otherwise, and that store counts. A
235
- // page starts with none and takes one once it is inside the reach.
236
- canvas.width = 0;
237
- canvas.height = 0;
238
- canvas.setAttribute("role", "img");
239
- canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
240
- sizePage(canvas, i);
241
- return canvas;
242
- };
243
-
244
- /**
245
- * Take page `i`'s canvas off the sheet and stand a fresh one in its place,
246
- * answering with the replacement. A paint still in flight holds the old
247
- * canvas's context and draws into something nobody is looking at, which is
248
- * how a superseded paint is stopped here — by construction, rather than by
249
- * a check the painter would have to make above its own draw (ADR 0046).
250
- * The retired canvas takes its entries in `backed` and `settled` with it.
251
- *
252
- * Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
253
- * store is what ADR 0043 rations, and leaving it to be collected whenever
254
- * the superseded paint lets go of the context is the timing that ADR
255
- * refuses. `paint()` does its whole `save`/draw/`restore` after its awaits,
256
- * so emptying the canvas between them unbalances nothing — it just leaves
257
- * every op clipped to nothing, which spares the raster work too.
258
- *
259
- * @type {(i: number) => HTMLCanvasElement}
260
- */
261
- let retire = (i) => {
262
- let old = pageAt(i);
263
- let fresh = pageOf(i);
264
- old.replaceWith(fresh);
265
- old.width = 0;
266
- old.height = 0;
267
- return fresh;
268
- };
269
-
270
- /**
271
- * Give page `i`'s pixels back. A page whose paint has settled is emptied in
272
- * place: sizing the canvas to 0 × 0 is the one idiom that frees the store
273
- * synchronously in every engine the viewer runs in, and the CSS size is
274
- * untouched, so the page keeps its place in the extent and shows the
275
- * sheet's white. A page still painting is retired instead — emptying it
276
- * would leave that paint pointed at a canvas the next pass over the reach
277
- * re-sizes and re-paints.
278
- *
279
- * @type {(i: number) => void}
280
- */
281
- let release = (i) => {
282
- let canvas = pageAt(i);
283
- if (!backed.has(canvas)) return;
284
- if (!settled.has(canvas)) return void retire(i);
285
- backed.delete(canvas);
286
- canvas.width = 0;
287
- canvas.height = 0;
288
- };
289
-
290
- /** Device pixels per point at the applied percentage: what a page is
291
- * painted at, and what its backing store is sized in. */
292
- let deviceScale = () => ratio() * (globalThis.devicePixelRatio || 1);
293
-
294
- /**
295
- * Start painting page `i`, and answer with that paint. **The one place a
296
- * paint begins, and the one place the rule is enforced**: a canvas whose
297
- * paint is still in flight is retired here before a second one is pointed
298
- * at it, so no caller can reach a live canvas with a second paint by
299
- * forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
300
- * the next pass over the reach tries again instead of counting the page as
301
- * painted; one that settles joins `settled`.
302
- *
303
- * **The paint this answers with never rejects**, which is the whole of why
304
- * `swap` does not either. A page that could not be drawn is not a render
305
- * that failed: the report laid out and the list reached the sheet, so there
306
- * is nothing for the error panel to say and nothing to report through the
307
- * error event. Swallowed here, the one place a paint begins, so no caller
308
- * has to remember to; one bad page then costs its own pixels rather than
309
- * every page after it in the reach, which this loop awaits one at a time.
310
- *
311
- * See the header for how little can still reach it — the layout absorbs a
312
- * bad image and refuses a bad face while measuring — and why it stays
313
- * anyway. A paint that failed is forgotten so the next pass over the reach
314
- * tries again, which is what makes a failure for a passing reason
315
- * recoverable.
316
- *
317
- * @type {(i: number) => Promise<void>}
318
- */
319
- let start = (i) => {
320
- let canvas = pageAt(i);
321
- if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
322
- settled.delete(canvas);
323
- let scale = deviceScale();
324
- let each = list.pages[i];
325
- canvas.width = Math.round(each.width * scale);
326
- canvas.height = Math.round(each.height * scale);
327
- let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
328
- let painted = paint(ctx, each, { scale, fonts }).then(
329
- () => {
330
- settled.add(canvas);
331
- },
332
- () => {
333
- backed.delete(canvas);
334
- },
335
- );
336
- backed.set(canvas, { scale, painted });
337
- return painted;
338
- };
339
-
340
- /**
341
- * Paint one page and answer with that paint — or with the paint already
342
- * under way at this scale, which is what makes scrolling back over ground
343
- * already covered free. Answering with the paint rather than with a
344
- * resolved promise is what lets two callers share one page's paint and both
345
- * settle behind its pixels.
346
- *
347
- * Takes the index, not the canvas: the page it paints may be retired out
348
- * from under a caller, so a caller that handed one in would be left holding
349
- * an element that is no longer on the sheet.
350
- *
351
- * @type {(i: number) => Promise<void>}
352
- */
353
- let paintPage = (i) => {
354
- let carrying = backed.get(pageAt(i));
355
- if (carrying && carrying.scale === deviceScale()) return carrying.painted;
356
- return start(i);
357
- };
358
-
359
- /**
360
- * Bumped per repaint, so a repaint overtaken by the next stops walking.
361
- * What that guards is the backing store, not the pixels: a superseded loop
362
- * would paint at the *current* scale — `paintPage` reads it afresh — but
363
- * onto pages the newer reach has since dropped, re-backing pages that
364
- * should be blank (ADR 0043). Stale pixels are `retire`'s business, not
365
- * this one, so neither guard stands in for the other.
366
- *
367
- * It guards repaint against repaint, and nothing else. A repaint chooses
368
- * its pages once and holds that list across its awaits, so a scroll pass
369
- * releasing a page mid-repaint is one this loop will paint anyway; the
370
- * store that leaves behind is bounded by the reach and goes back on the
371
- * next pass over it.
372
- */
373
- let epoch = 0;
374
-
375
- /**
376
- * Give every page its CSS size. Callers run this before writing the scroll
377
- * offsets back: the extent those offsets are clamped against is this one,
378
- * and the reach below is read from the offsets once they are in.
379
- */
380
- let sizeAll = () => {
381
- if (!list) return;
382
- let px = ratio();
383
- for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
384
- };
385
-
386
- /**
387
- * The indices of the pages in the reach — the ones the viewport shows, and
388
- * everything within one viewport height above or below. Walked over the
389
- * list's own geometry (the gutter, each page's height at the applied
390
- * scale, the gap), never a layout read, so it costs nothing to ask
391
- * mid-scroll — the whole list is walked because a multiply and an add per
392
- * page is beneath measuring, and stopping early would be a second rule
393
- * about where the reach ends. `clientHeight` is read afresh each time, so a
394
- * pane that changed size changes the reach with it.
395
- *
396
- * @returns {number[]}
397
- */
398
- let inReach = () => {
399
- let view = scroll.clientHeight;
400
- let top = scroll.scrollTop - view;
401
- let bottom = scroll.scrollTop + 2 * view;
402
- let px = ratio();
403
- let y = GUTTER;
404
- let found = [];
405
- for (let [i, each] of list.pages.entries()) {
406
- let height = each.height * px;
407
- if (y + height >= top && y <= bottom) found.push(i);
408
- y += height + GAP;
409
- }
410
- return found;
411
- };
412
-
413
- /**
414
- * Release every page the reach has left behind, and answer with the indices
415
- * of the ones to keep, in the order they are painted in. The single
416
- * definition of which pages carry pixels: the repaint below and the scroll
417
- * pass both go through here, and neither holds a page the other released.
418
- *
419
- * Indices rather than elements, for the reason `paintPage` takes one: a
420
- * page can be retired between this pass and the paint that follows it.
421
- *
422
- * @type {() => number[]}
423
- */
424
- let keep = () => {
425
- let wanted = new Set(inReach());
426
- let kept = [];
427
- // `retire` replaces one for one, so the count is fixed across the walk.
428
- for (let i = 0, total = sheet.children.length; i < total; i++) {
429
- if (wanted.has(i)) kept.push(i);
430
- else release(i);
431
- }
432
- return kept;
433
- };
434
-
435
- // Paint the reach in order, yielding between pages and giving way to any
436
- // repaint that started since. Sizing is the caller's, and comes first.
437
- let repaint = async () => {
438
- if (!list) return;
439
- let mine = ++epoch;
440
- for (let i of keep()) {
441
- if (mine !== epoch) return;
442
- await paintPage(i);
443
- }
444
- };
445
-
446
- /**
447
- * One reach pass a frame, however many scrolls and resizes ask for one. It
448
- * keeps no epoch of its own, so a swap painting behind it is never cut off
449
- * part-painted, and a page it starts is one a swap arriving at the same page
450
- * waits behind rather than skips. Nothing awaits these paints here: a scroll
451
- * is not a render, and a page that will not paint is blank whoever asked for
452
- * it — `start` settles either way, so there is nothing here to catch.
453
- */
454
- let pending = false;
455
- let follow = () => {
456
- if (pending) return;
457
- pending = true;
458
- requestAnimationFrame(() => {
459
- pending = false;
460
- if (!list) return;
461
- for (let i of keep()) void paintPage(i);
462
- });
463
- };
464
- scroll.addEventListener("scroll", follow);
465
-
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);
466
166
  return {
467
167
  element: scroll,
468
168
 
@@ -475,7 +175,7 @@ export let stage = () => {
475
175
  fit: () => {
476
176
  let usable = scroll.clientWidth - 2 * GUTTER;
477
177
  // The list's page width, or the geometry's before one arrives.
478
- let width = (list ? list.width : fallback) * PX_PER_POINT;
178
+ let width = (paged.width() ?? fallback) * PX_PER_POINT;
479
179
  if (usable <= 0 || !width) return null;
480
180
  return (usable / width) * 100;
481
181
  },
@@ -498,7 +198,7 @@ export let stage = () => {
498
198
  * so a zoom step costs a handful of pages however long the report is.
499
199
  */
500
200
  scale: (percent) => {
501
- let held = sheet.firstChild && {
201
+ let held = sheetEl.firstChild && {
502
202
  top: scroll.scrollTop,
503
203
  left: scroll.scrollLeft,
504
204
  height: scroll.clientHeight,
@@ -506,13 +206,13 @@ export let stage = () => {
506
206
  };
507
207
  let ratioOf = percent / applied;
508
208
  applied = percent;
509
- sizeAll();
209
+ paged.sizeAll();
510
210
  if (held) {
511
211
  let middle = held.top + held.height / 2 - GUTTER;
512
212
  scroll.scrollTop = GUTTER + middle * ratioOf - held.height / 2;
513
213
  scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
514
214
  }
515
- void repaint();
215
+ void paged.repaint();
516
216
  },
517
217
 
518
218
  /**
@@ -542,20 +242,15 @@ export let stage = () => {
542
242
  swap: async (next, faces) => {
543
243
  // Reading the offsets flushes layout, so only an actual reswap pays for
544
244
  // it: on an empty sheet there is nothing scrolled to preserve.
545
- let held = sheet.firstChild && {
245
+ let held = sheetEl.firstChild && {
546
246
  top: scroll.scrollTop,
547
247
  left: scroll.scrollLeft,
548
248
  };
549
- list = next;
550
- fonts = faces;
551
- sheet.replaceChildren(
552
- ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
553
- );
249
+ await paged.swap(next, faces);
554
250
  if (held) {
555
251
  scroll.scrollTop = held.top;
556
252
  scroll.scrollLeft = held.left;
557
253
  }
558
- await repaint();
559
254
  },
560
255
 
561
256
  /**
@@ -568,7 +263,7 @@ export let stage = () => {
568
263
  let observer = new ResizeObserver(() => {
569
264
  // A resize that changes the fit repaints through `changed()`; one
570
265
  // that does not still moved the viewport the reach is measured in.
571
- follow();
266
+ paged.follow();
572
267
  changed();
573
268
  });
574
269
  observer.observe(scroll);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/viewer",
3
- "version": "0.6.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.3.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.6.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.6.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"