@quario/viewer 0.6.0 → 0.8.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,15 +1,75 @@
1
- # Changelog
2
-
3
- All notable changes to @quario/viewer are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [0.6.0] - 2026-09-07
11
-
12
- ### Changed
1
+ # @quario/viewer
2
+
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **An image's box is now as wide as the container it sits in.** A
8
+ `background` or `border*` on an image item hugged the picture and now spans
9
+ the content width, or the slot's share inside a split; the picture itself
10
+ does not move. See the `@quario/layout` changelog for the rule and for what
11
+ it costs a report that relied on a border hugging a logo.
12
+ - **Only the pages you can see are on the sheet.** A page outside the reach
13
+ used to be an empty canvas holding its place; now it has no element at all,
14
+ and the sheet carries the document's extent itself — its height and width are
15
+ written from the layout, and it paints the page silhouettes. The stretch a
16
+ reader scrolls through is exactly what it was, and a scale change no longer
17
+ costs the browser a relayout of every page: that was around 10 microseconds a
18
+ page, so a five-thousand-page report paid roughly 50 ms of it each time.
19
+
20
+ This changes the sheet's markup, which matters if you style it. `.qv-page` is
21
+ now absolutely positioned inside `.qv-sheet`, and is present only for the pages
22
+ on screen and one viewport height either side. A rule that gave a page a
23
+ margin, or that relied on the pages being a flow of siblings, no longer
24
+ applies — the sheet places its own pages, and does it with inline styles a
25
+ stylesheet rule cannot override. `--qv-sheet-shadow` is unchanged.
26
+
27
+ One cost, stated: a page outside the reach has no `role="img"` node, so
28
+ assistive technology sees the few images of the reach renumbering as the
29
+ reader scrolls rather than a document of named pages.
30
+
31
+ ### Patch Changes
32
+
33
+ - **Every published README says where the documentation is.** Each package now
34
+ carries a Documentation section pointing at the reference, at the report schema
35
+ that normatively specifies what a report may declare, and at the package's own
36
+ API. The paragraphs that used to end on an unstated contract — the event
37
+ stream's field semantics, the style vocabulary, page columns, the Content
38
+ Security Policy a fragment with images needs, the formula mangling, and each
39
+ target's own contract — link the page that states it. Every link is an absolute
40
+ URL, so it resolves from the npm package page as readily as from an installed
41
+ copy.
42
+ - Updated dependencies
43
+ - @quario/layout@0.5.0
44
+ - @quario/landing@0.2.0
45
+ - quario@0.8.0
46
+
47
+ ## 0.7.0
48
+
49
+ ### Minor Changes
50
+
51
+ - **`fonts` is validated as a host property.** It was the one the viewer never
52
+ checked, so a malformed record reached the target inside the render and came
53
+ back as a render failure about a report that was never at fault. Its shape is
54
+ now checked with the other host properties and named on `fonts` rather than
55
+ on the target's `options.fonts`. A face that will not parse, or a missing
56
+ parser, is still found while the report is measured and remains a render
57
+ failure there.
58
+ - **A rejected host property reports as `host-option`.** `ViewerErrorKind`
59
+ gains a fourth member, so a host switching exhaustively over the kind needs
60
+ a case for it. A `page`, `zoom`, `filename`, `colorScheme` or `fonts` the
61
+ viewer rejected was reported as `mount-render` or `update-render` — a render
62
+ that was never attempted — and the panel said "Could not render the report",
63
+ which reads as the report being at fault. It now says the viewer is
64
+ misconfigured and that the report was not the problem.
65
+
66
+ A malformed `report` or `targets` still reports as a render failure: those
67
+ are refused where the render begins rather than when the property is written,
68
+ so nothing yet tells them apart from a render that failed.
69
+
70
+ ## 0.6.0
71
+
72
+ ### Minor Changes
13
73
 
14
74
  - **Numbers presented through `format` now show a fixed two fraction digits**,
15
75
  the same as every other target: `1,000.00` where the preview showed `1,000`,
@@ -17,9 +77,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
77
  a universal two. The digits come from the engine, so a page on screen and
18
78
  the PDF of it agree. A formatted cell is also up to three characters wider,
19
79
  so a line that just fitted can wrap and move a page break.
20
-
21
- ### Fixed
22
-
23
80
  - **A page the browser cannot fully paint no longer fails the render.** An
24
81
  image whose pixel data is corrupt past the size in its header left that page
25
82
  blank _and_ rejected `renderComplete`, so a host awaiting it saw an unhandled
@@ -29,29 +86,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
29
86
  fires, and no `error` event is raised. It never rejects for this reason
30
87
  again: a page that cannot be drawn is blank, not a failed render.
31
88
 
32
- ## [0.5.0] - 2026-09-05
89
+ ## 0.5.0
33
90
 
34
- ### Changed
91
+ ### Minor Changes
35
92
 
36
93
  - **Previewing with `fonts` now needs `fontkit`, not `@pdf-lib/fontkit`.**
37
94
  Install `fontkit` instead. The viewer measures host TrueType faces through
38
95
  `@quario/layout`, whose optional parser this is; the old package crashed on
39
96
  any OpenType face needing a shaping state machine, so those faces now
40
97
  preview where they used to throw.
41
-
42
98
  - **The bar's controls are drawn icons.** The zoom trigger, the menu's check
43
99
  and the error panel's dismiss are drawn from Lucide's set rather than by
44
100
  hand, at one weight and one grid, and the dismiss no longer relies on the
45
101
  host's font to draw a multiplication sign. They paint in `currentColor`, so
46
102
  `--qv-icon` and `--qv-icon-active` still recolour them.
47
-
48
103
  - **An export button names its format in words.** Each one is a download icon
49
104
  with `PDF`, `XLSX` or `CSV` beside it, in place of the sheet drawing that
50
105
  lettered the format inside itself. The buttons are wider; the accessible
51
106
  name is unchanged.
52
-
53
- ### Fixed
54
-
55
107
  - **A zoom no longer leaves a page part-drawn.** Changing the zoom while a
56
108
  page was still painting could leave that page carrying content at the old
57
109
  scale on a canvas sized for the new one — the rest of it blank — and nothing
@@ -59,27 +111,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
59
111
  whose paint is still in flight is now replaced rather than reused, so the
60
112
  superseded paint has nowhere to land.
61
113
 
62
- ## [0.4.0] - 2026-09-03
114
+ ## 0.4.0
63
115
 
64
- ### Added
116
+ ### Minor Changes
65
117
 
66
118
  - **`fonts`.** The font mapping a host passes to `pdf({ fonts })`, so the
67
119
  preview measures and draws in the same TrueType faces the document embeds.
68
-
69
- ### Changed
70
-
71
120
  - **`renderComplete` and `rendered` settle once the pages on screen are
72
121
  painted.** Both already answered for the newest render reaching the sheet;
73
122
  what that means is now stated: the pages the reader can see carry their
74
123
  pixels, and the pages further down the report do not hold the promise up.
75
124
  A screenshot or a pixel-reading test taken at that moment sees what the
76
125
  reader sees.
77
-
78
126
  - **A split slot's box now fills the split's height.** A slot's background and
79
127
  border stopped at that slot's own content and now take the split's height,
80
128
  the way a table cell's take their row's. See the `@quario/layout` changelog
81
129
  for the rule and for what it costs a report that relied on the short box.
82
-
83
130
  - **The sheet has pages, and they break where the PDF's do.** The viewer
84
131
  depends on `@quario/layout` and paints the same display list the pdf target
85
132
  writes, one canvas per page, in the faces the document itself uses.
@@ -89,9 +136,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
89
136
  `page` change re-lays the report out rather than resizing a sheet. The
90
137
  unlicensed marking is painted per page from the list; the `--qv-mark`
91
138
  token is gone with the DOM stamps.
92
-
93
- ### Fixed
94
-
95
139
  - **A long report no longer asks for a backing store per page.** The viewer
96
140
  paints the pages near the viewport — the ones on screen and one screenful
97
141
  either side — and keeps every other page sized but blank. A report of a
@@ -102,23 +146,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102
146
  the first frame, and a zoom step now repaints a handful of pages instead of
103
147
  walking the document.
104
148
 
105
- ## [0.3.0] - 2026-09-02
149
+ ## 0.3.0
106
150
 
107
- ### Changed
151
+ ### Minor Changes
108
152
 
109
153
  - **Report and group containers are flex columns.** Adjacent authored
110
154
  `spaceBefore` / `spaceAfter` margins add rather than collapse, matching
111
155
  the html reference stylesheet.
112
-
113
- ### Fixed
114
-
115
156
  - **The adopted sheet occupies a line and preserves authored newlines.**
116
157
  Same `min-height: 1lh` and `white-space: pre-line` the html reference
117
158
  stylesheet now carries on `.q-item`, and `pre-line` on table cells.
118
159
 
119
- ## [0.2.0] - 2026-09-01
160
+ ## 0.2.0
120
161
 
121
- ### Changed
162
+ ### Minor Changes
122
163
 
123
164
  - **The sheet is now page-shaped, so short reports reserve a full page.**
124
165
  `page` sized the sheet's width and padding and ignored its height, so a
@@ -137,9 +178,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
137
178
  - **The adopted sheet's baseline face moved onto `.q-report`.** It was on
138
179
  `.q-item` and `.q-table`, where a direct rule would have beaten a report's
139
180
  own declared family. Same look; the fragment's new root is what carries it.
140
-
141
- ### Fixed
142
-
143
181
  - **The sheet no longer draws report text in the platform's own face.** It
144
182
  hard-coded `system-ui`, which is SF Pro on macOS, Segoe UI on Windows and
145
183
  Roboto on Android — so the same report showed a different typeface to every
@@ -148,9 +186,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
148
186
  resolves to Helvetica. The viewer's own chrome keeps `system-ui`, which is
149
187
  what a toolbar should do.
150
188
 
151
- ## [0.1.0] - 2026-08-27
189
+ ## 0.1.0
152
190
 
153
- ### Added
191
+ ### Minor Changes
154
192
 
155
193
  - **An embeddable `<quario-viewer>`.** Assign a compiled report, the targets
156
194
  to render with, and data; it shows the HTML report on a continuous sheet,
package/README.md CHANGED
@@ -314,6 +314,13 @@ reference seam, not a frozen vocabulary: backdrop and bar (`--qv-backdrop`, `--q
314
314
  The report itself is paint, not markup: there is no report stylesheet to restyle, because what
315
315
  you see is the layout the PDF target writes.
316
316
 
317
+ ## Documentation
318
+
319
+ [The quario documentation](https://getquario.com/docs/) is the reference.
320
+ The [report schema](https://getquario.com/docs/reference/report-schema/) is the normative
321
+ specification of what a report may declare, and
322
+ [`@quario/viewer`](https://getquario.com/docs/reference/viewer/) is this element's own API.
323
+
317
324
  ## License
318
325
 
319
326
  Commercial software with readable source. Free, unlimited, watermarked evaluation; per-developer
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/icons.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * default, 14px in the gutter and the menu's tick slot — because `x` appears
5
5
  * in two contexts at once (ADR 0044).
6
6
  *
7
- * Generated by `scripts/icons.mjs` from lucide 1.40.0. Do not edit:
7
+ * Generated by `scripts/icons.mjs` from lucide 1.42.0. Do not edit:
8
8
  * `npm run icons` rewrites it, and `npm run check` regenerates it into a
9
9
  * temp directory and compares.
10
10
  *
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
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The stage: the surface the pages are painted on. It owns the scroll
3
3
  * container the reader looks through, the wrapper that carries the gutter,
4
- * and the sheet — one canvas per page of the layout list, painted by
5
- * `@quario/layout`'s `paint` — their elements, their CSS, and every number
6
- * relating them.
4
+ * and the sheet — the layout list's whole extent, with a canvas for each page
5
+ * of the reach painted by `@quario/layout`'s `paint` — their elements, their
6
+ * CSS, and every number relating them.
7
7
  *
8
8
  * Zoom is a repaint, not a transform: each page canvas is sized to the
9
9
  * percentage in CSS pixels and painted at that scale times the device pixel
@@ -11,15 +11,17 @@
11
11
  * reader saw. The list never changes under a zoom — the preview scales and
12
12
  * never reflows (zoom.js says why).
13
13
  *
14
- * Every page keeps its CSS size, but only the
15
- * [reach](../../../CONTEXT.md#reach) carries pixels — the pages on screen and
16
- * one viewport height either side. The extent the reader scrolls through is
17
- * whole and synchronous whatever is painted; what comes and goes is the
18
- * backing store, released by sizing a canvas to 0 × 0. A scroll or a resize
19
- * runs the same arithmetic over the list's own geometry, coalesced to one
20
- * pass a frame. ADR 0043 says what that is worth: painting the whole sheet
21
- * asked a thousand-page report for gigabytes of backing store, and past what
22
- * the browser would grant the pages simply came up blank.
14
+ * The sheet carries the whole list's extent, but only the
15
+ * [reach](../../../CONTEXT.md#reach) carries elements and pixels — the pages
16
+ * on screen and one viewport height either side. The extent the reader scrolls
17
+ * through is whole and synchronous whatever is standing, because the sheet is
18
+ * given its height and width inline and paints the page silhouettes itself; a
19
+ * page leaving the reach is removed, which takes its backing store with it
20
+ * (ADR 0065). A scroll or a resize runs the same arithmetic over the list's own
21
+ * geometry, coalesced to one pass a frame. ADR 0043 says what the reach is
22
+ * worth: painting the whole sheet asked a thousand-page report for gigabytes of
23
+ * backing store, and past what the browser would grant the pages simply came up
24
+ * blank.
23
25
  *
24
26
  * **A page the browser will not paint is blank, not a failure.** `start`
25
27
  * swallows and nothing here rejects: the report laid out and the list reached
@@ -39,13 +41,14 @@
39
41
  * *retired* rather than painted over or emptied: it comes off the sheet, its
40
42
  * pixels go back, and a fresh one stands in its place, so the superseded
41
43
  * paint draws into an element nobody is looking at. A canvas whose paint has
42
- * settled has nothing that could land late and is re-sized or emptied in
43
- * place as before. Without this a zoom landing mid-paint could leave a page
44
+ * settled has nothing that could land late and is re-sized in place as before;
45
+ * a page leaving the reach is removed either way, which takes its pixels with
46
+ * it whether that paint has landed or not. Without this a zoom landing mid-paint could leave a page
44
47
  * carrying old-scale content on a new-scale canvas, with the memo below
45
48
  * calling it painted so that nothing repainted it again (ADR 0046).
46
49
  *
47
- * Retirement is lazy, and only the paint is guarded. A scale change sizes
48
- * every page's CSS box at once but retires a page only when the repaint loop
50
+ * Retirement is lazy, and only the paint is guarded. A scale change sizes the
51
+ * reach's CSS boxes at once but retires a page only when the repaint loop
49
52
  * reaches it, so a page further down the reach shows its old pixels stretched
50
53
  * into the new box until its turn comes — the ordinary look of a zoom in
51
54
  * progress, not the artefact above. The cost is that a retired page is a
@@ -70,17 +73,14 @@
70
73
  */
71
74
 
72
75
  import { css } from "lit";
73
- import { PX_PER_POINT, paint } from "@quario/layout";
76
+ import { GUTTER, sheet } from "@quario/landing";
77
+ import { PX_PER_POINT } from "@quario/layout";
74
78
 
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;
79
+ // `GUTTER` comes from `@quario/landing`, which walks the reach over it: the CSS
80
+ // below and the sheet's own arithmetic have to agree about where a page sits,
81
+ // and two copies of that is a reach that paints the wrong pages. The gap
82
+ // between pages is that package's too, and is not named here at all — the sheet
83
+ // positions its own pages now, so this file has nothing to space.
84
84
 
85
85
  export let SURFACE = css`
86
86
  /* Where scrollbars take width, the gutter is held whether one is showing or
@@ -106,16 +106,17 @@ export let SURFACE = css`
106
106
  margin: ${GUTTER}px auto;
107
107
  }
108
108
 
109
- /* The sheet is the stack of pages. */
110
- .qv-sheet {
111
- display: flex;
112
- flex-direction: column;
113
- gap: ${GAP}px;
114
- }
115
-
116
- /* One page. The painter fills it white; the background here is what a page
117
- outside the reach shows, and what one inside it shows between being sized
118
- and being painted. */
109
+ /* The sheet is the whole document's extent, and the pages of the reach are
110
+ positioned inside it. It has no rule here because it has no look: its
111
+ position, its height, its width and its page silhouettes are all written
112
+ inline by \`@quario/landing\` from the list's own geometry, which is the one
113
+ place any of them is decided (ADR 0065). A page outside the reach has no
114
+ element, so what shows there is the sheet's own paint.
115
+
116
+ One page of the reach, at the size and offset the sheet gives it. The
117
+ painter fills it white; the background here is what it shows between being
118
+ placed and being painted, and the shadow is what lifts it off the sheet —
119
+ the two this file does decide. */
119
120
  .qv-page {
120
121
  display: block;
121
122
  background: #fff;
@@ -150,319 +151,24 @@ export let stage = () => {
150
151
  // inside the viewer. The `qv-*` names are documented as stable, so the
151
152
  // narrower one keeps its name.
152
153
  wrapper.className = "qv-stage";
153
- let sheet = document.createElement("div");
154
- sheet.className = "qv-sheet";
155
- wrapper.append(sheet);
154
+ let sheetEl = document.createElement("div");
155
+ sheetEl.className = "qv-sheet";
156
+ wrapper.append(sheetEl);
156
157
  scroll.append(wrapper);
157
158
 
158
159
  /** The page width to fit against before a list arrives, in points. */
159
160
  let fallback = 0;
160
- /** @type {any} */
161
- let list = null;
162
- /** @type {any} */
163
- let fonts;
164
161
  /** The percentage currently on screen. */
165
162
  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
163
  /** CSS pixels per point at the applied percentage. */
197
164
  let ratio = () => (PX_PER_POINT * applied) / 100;
198
165
 
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
-
166
+ /** The paged sheet: the page canvases, their backing-store lifetime, the
167
+ * reach and its painting, in `@quario/landing`. It asks `ratio()` afresh,
168
+ * so the percentage this element applies is the scale it paints at and
169
+ * the elements above stay this file's, because the CSS is the viewer's own
170
+ * public surface. */
171
+ let paged = sheet(scroll, sheetEl, ratio);
466
172
  return {
467
173
  element: scroll,
468
174
 
@@ -475,7 +181,7 @@ export let stage = () => {
475
181
  fit: () => {
476
182
  let usable = scroll.clientWidth - 2 * GUTTER;
477
183
  // The list's page width, or the geometry's before one arrives.
478
- let width = (list ? list.width : fallback) * PX_PER_POINT;
184
+ let width = (paged.width() ?? fallback) * PX_PER_POINT;
479
185
  if (usable <= 0 || !width) return null;
480
186
  return (usable / width) * 100;
481
187
  },
@@ -491,14 +197,17 @@ export let stage = () => {
491
197
  * offset; horizontally the wrapper is centred by auto margins while it
492
198
  * fits — which is exactly when `scrollLeft` is 0 anyway — and its margins
493
199
  * are 0 once it overflows, so the plain ratio holds wherever it can be
494
- * seen. The browser clamps whatever it cannot honour. An empty sheet has
495
- * no view to hold, which is what mounting at an authored zoom takes.
200
+ * seen. The browser clamps whatever it cannot honour. A sheet with no list
201
+ * on it has no view to hold, which is what mounting at an authored zoom
202
+ * takes. Asked of the list rather than of the sheet's children, which
203
+ * since ADR 0065 are the reach's and can be none of them while a list is
204
+ * standing — and it saves a DOM read besides.
496
205
  *
497
206
  * Only the reach is repainted, and only where the scale actually changed,
498
207
  * so a zoom step costs a handful of pages however long the report is.
499
208
  */
500
209
  scale: (percent) => {
501
- let held = sheet.firstChild && {
210
+ let held = paged.count() > 0 && {
502
211
  top: scroll.scrollTop,
503
212
  left: scroll.scrollLeft,
504
213
  height: scroll.clientHeight,
@@ -506,13 +215,13 @@ export let stage = () => {
506
215
  };
507
216
  let ratioOf = percent / applied;
508
217
  applied = percent;
509
- sizeAll();
218
+ paged.sizeAll();
510
219
  if (held) {
511
220
  let middle = held.top + held.height / 2 - GUTTER;
512
221
  scroll.scrollTop = GUTTER + middle * ratioOf - held.height / 2;
513
222
  scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
514
223
  }
515
- void repaint();
224
+ void paged.repaint();
516
225
  },
517
226
 
518
227
  /**
@@ -528,12 +237,12 @@ export let stage = () => {
528
237
 
529
238
  /**
530
239
  * Put a laid-out report on the sheet, keeping the reader where they
531
- * were: one canvas per page, the reach among them painted at the applied
532
- * scale. The order is the point of the method: the pages come off
533
- * `pageOf` already at their size, so the browser clamps the offsets going
534
- * back against the extent the sheet will have rather than the one it had
535
- * — and the reach is read from those offsets, so it is chosen after they
536
- * are in. What the
240
+ * were: the extent whole at once, with the reach's pages painted at the
241
+ * applied scale. The order is the point of the method: the sheet takes its
242
+ * height from the list before anything else, so the browser clamps the
243
+ * offsets going back against the extent the sheet will have rather than
244
+ * the one it had — and the reach is read from those offsets, so it is
245
+ * chosen after they are in. What the
537
246
  * returned promise settles behind is the reach, which is what the caller's
538
247
  * `renderComplete` means by "the pages on screen have finished trying to
539
248
  * paint" — and **it never rejects**, because `start` swallows a page the
@@ -541,21 +250,16 @@ export let stage = () => {
541
250
  */
542
251
  swap: async (next, faces) => {
543
252
  // Reading the offsets flushes layout, so only an actual reswap pays for
544
- // it: on an empty sheet there is nothing scrolled to preserve.
545
- let held = sheet.firstChild && {
253
+ // it: with no list standing there is nothing scrolled to preserve.
254
+ let held = paged.count() > 0 && {
546
255
  top: scroll.scrollTop,
547
256
  left: scroll.scrollLeft,
548
257
  };
549
- list = next;
550
- fonts = faces;
551
- sheet.replaceChildren(
552
- ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
553
- );
258
+ await paged.swap(next, faces);
554
259
  if (held) {
555
260
  scroll.scrollTop = held.top;
556
261
  scroll.scrollLeft = held.left;
557
262
  }
558
- await repaint();
559
263
  },
560
264
 
561
265
  /**
@@ -568,7 +272,7 @@ export let stage = () => {
568
272
  let observer = new ResizeObserver(() => {
569
273
  // A resize that changes the fit repaints through `changed()`; one
570
274
  // that does not still moved the viewport the reach is measured in.
571
- follow();
275
+ paged.follow();
572
276
  changed();
573
277
  });
574
278
  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.8.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.2.0",
48
+ "@quario/layout": "^0.5.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.8.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.8.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"