@quario/viewer 0.4.0 → 0.6.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,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-07
11
+
12
+ ### Changed
13
+
14
+ - **Numbers presented through `format` now show a fixed two fraction digits**,
15
+ the same as every other target: `1,000.00` where the preview showed `1,000`,
16
+ `21.00%` where it showed `21%`, and a currency's own minor units in place of
17
+ a universal two. The digits come from the engine, so a page on screen and
18
+ the PDF of it agree. A formatted cell is also up to three characters wider,
19
+ so a line that just fitted can wrap and move a page break.
20
+
21
+ ### Fixed
22
+
23
+ - **A page the browser cannot fully paint no longer fails the render.** An
24
+ image whose pixel data is corrupt past the size in its header left that page
25
+ blank _and_ rejected `renderComplete`, so a host awaiting it saw an unhandled
26
+ rejection instead of an answer, and every page after it on screen went
27
+ unpainted too. Such an image is now drawn as nothing and the page is drawn
28
+ around it, marking and all; `renderComplete` resolves `true`, `rendered`
29
+ fires, and no `error` event is raised. It never rejects for this reason
30
+ again: a page that cannot be drawn is blank, not a failed render.
31
+
32
+ ## [0.5.0] - 2026-09-05
33
+
34
+ ### Changed
35
+
36
+ - **Previewing with `fonts` now needs `fontkit`, not `@pdf-lib/fontkit`.**
37
+ Install `fontkit` instead. The viewer measures host TrueType faces through
38
+ `@quario/layout`, whose optional parser this is; the old package crashed on
39
+ any OpenType face needing a shaping state machine, so those faces now
40
+ preview where they used to throw.
41
+
42
+ - **The bar's controls are drawn icons.** The zoom trigger, the menu's check
43
+ and the error panel's dismiss are drawn from Lucide's set rather than by
44
+ hand, at one weight and one grid, and the dismiss no longer relies on the
45
+ host's font to draw a multiplication sign. They paint in `currentColor`, so
46
+ `--qv-icon` and `--qv-icon-active` still recolour them.
47
+
48
+ - **An export button names its format in words.** Each one is a download icon
49
+ with `PDF`, `XLSX` or `CSV` beside it, in place of the sheet drawing that
50
+ lettered the format inside itself. The buttons are wider; the accessible
51
+ name is unchanged.
52
+
53
+ ### Fixed
54
+
55
+ - **A zoom no longer leaves a page part-drawn.** Changing the zoom while a
56
+ page was still painting could leave that page carrying content at the old
57
+ scale on a canvas sized for the new one — the rest of it blank — and nothing
58
+ repainted it afterwards: scrolling away and back left it as it was. A page
59
+ whose paint is still in flight is now replaced rather than reused, so the
60
+ superseded paint has nowhere to land.
61
+
10
62
  ## [0.4.0] - 2026-09-03
11
63
 
12
64
  ### Added
package/README.md CHANGED
@@ -41,8 +41,11 @@ view.filename = "sales";
41
41
  ```
42
42
 
43
43
  with `<quario-viewer></quario-viewer>` in your markup, sized by your own CSS. The element is
44
- `display: block`. Importing `@quario/viewer` defines nothing: the main entry exports the
45
- `QuarioViewer` class and is side-effect-free, and `@quario/viewer/register` performs the one-line
44
+ `display: block`, and its chrome is laid out to fit a width of **320px** — a phone-width embed is
45
+ the narrowest the bar is designed for. Narrower than that and the export buttons push the zoom
46
+ control off the left of the bar, quietly: the bar does not wrap or scroll. Importing
47
+ `@quario/viewer` defines nothing: the main entry exports the `QuarioViewer` class and is
48
+ side-effect-free, and `@quario/viewer/register` performs the one-line
46
49
  `customElements.define("quario-viewer", QuarioViewer)`. A host that wants its own tag imports the
47
50
  class and defines it itself. The repository ships this wiring as a runnable page at
48
51
  `example/viewer.js`.
@@ -84,23 +87,30 @@ out.
84
87
  ```js
85
88
  view.addEventListener("rendered", () => {});
86
89
  view.addEventListener("error", ({ detail: { error, kind } }) => {});
87
- await view.renderComplete; // true when the newest render reached the sheet
90
+ await view.renderComplete; // true when the newest render landed on the sheet
88
91
  ```
89
92
 
90
- `rendered` fires each time a render reaches the sheet; `error` fires for every failure you should
93
+ `rendered` fires each time a render lands on the sheet; `error` fires for every failure you should
91
94
  know about, with `detail.kind` naming which: `"mount-render"` until a render has ever landed,
92
95
  `"update-render"` after, `"export"` for a download that could not be produced. Both events are
93
96
  non-bubbling, like `<img>`'s. Listen on the element. (Because the event is named `error`, an
94
97
  inline `onerror` attribute on the element would fire too; `window.onerror` never sees it.)
95
98
 
96
- `renderComplete` awaits the newest render settling: `true` when it reached the sheet with the pages
97
- on screen painted, `false` when it failed or there was nothing to render. It never rejects; failures
98
- arrive on the `error` event. `rendered` fires at that same moment. Like every outcome here, it
99
- answers for the newest render only. A superseded render's failure is reported to no one.
99
+ `renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
100
+ on screen finished trying to paint, `false` when it failed or there was nothing to render. It never
101
+ rejects; failures arrive on the `error` event. `rendered` fires at that same moment. Like every
102
+ outcome here, it answers for the newest render only. A superseded render's failure is reported to no
103
+ one.
100
104
 
101
- Your own mistakes surface on the same channel: a report that is not compiled, a missing
102
- `"html"` target, or a malformed option property becomes a `TypeError` naming the property, on
103
- the `error` event and the [error panel](#errors).
105
+ An image the browser cannot decode pixel data corrupt past the size in its header, which is all
106
+ the engine reads — is drawn as nothing, and the page is drawn around it. That is not a render
107
+ failure: `renderComplete` answers `true`, `rendered` fires, and no `error` event is raised. A face in
108
+ `fonts` is a different story: it is parsed while the report is measured, so bytes that will not parse
109
+ are a render error with a panel.
110
+
111
+ Your own mistakes surface on the same channel: a report that is not compiled, or a malformed
112
+ option property, becomes a `TypeError` naming the property, on the `error` event and the
113
+ [error panel](#errors). No particular target is required — the sheet is the layout's own.
104
114
 
105
115
  ## Lifecycle
106
116
 
@@ -247,7 +257,8 @@ export marks every page: it rides on the layout, so what you see is what the doc
247
257
  licensed render carries none.
248
258
 
249
259
  While a render is in flight a thin indeterminate bar sits on the toolbar's bottom edge, and the
250
- viewer reads `aria-busy="true"`. It reports that the viewer is working, not how far along. The
260
+ viewer's inner container the `.qv-viewer` div in its shadow root, not the element itself
261
+ reads `aria-busy="true"`. It reports that the viewer is working, not how far along. The
251
262
  engine streams events and cannot know how many are still coming.
252
263
 
253
264
  There is no Print button, because the sheet is the wrong thing to print. Your page's stylesheets do
@@ -258,9 +269,9 @@ document, the same bytes the PDF export hands over. A host that wants its own Pr
258
269
  two lines:
259
270
 
260
271
  ```js
261
- // `pdfTarget` is the pdf() from the quick start above, and `data` whatever you
262
- // last rendered — so what prints is exactly what the PDF export would produce.
263
- const bytes = await rpt.render(pdfTarget, data);
272
+ // The same compiled report and data the element holds, through the pdf()
273
+ // target from the quick start — so what prints is exactly the PDF export.
274
+ const bytes = await view.report.render(pdf(), view.data);
264
275
  window.open(URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })));
265
276
  ```
266
277
 
@@ -270,8 +281,8 @@ the export is, watermark and all.
270
281
  ## Errors
271
282
 
272
283
  When a render or an export fails, the viewer says so on the **error panel**, a strip across the top
273
- of the sheet carrying a short label and the error's own message. It replaces rather than stacks, is
274
- dismissed by its own button, and is cleared by the next render that reaches the sheet. A successful
284
+ of the sheet carrying a short label — "Could not render the report", "Could not update the report — showing the previous version", or "Could not export PDF" — and the error's own message. It replaces rather than stacks, is
285
+ dismissed by its own button, and is cleared by the next render that lands on the sheet. A successful
275
286
  export leaves it up, because the panel describes what you are looking at and a download says
276
287
  nothing about that.
277
288
 
package/lib/button.js CHANGED
@@ -18,6 +18,11 @@ export let BUTTON = css`
18
18
  border-radius: 5px;
19
19
  background: transparent;
20
20
  color: var(--_icon);
21
+ /* A form control does not inherit \`font\`, so the chrome's own 13px/1.4
22
+ system-ui stops at every button unless it is restated here. The shorthand
23
+ rather than \`font-family\` alone: the UA sets the shorthand, so restating
24
+ one longhand leaves size and line-height at the UA's. */
25
+ font: inherit;
21
26
  cursor: pointer;
22
27
  }
23
28
 
@@ -63,9 +68,11 @@ let fallback = (value, otherwise) => value ?? otherwise;
63
68
  * @param {{ title: string, label?: string, disabled?: boolean, name?: string,
64
69
  * popover?: string, click?: () => void,
65
70
  * content?: import('lit').TemplateResult }} control
66
- * `label` is visible text for the controls that read as words; `content`
67
- * is the icon template the zoom trigger and the export buttons pass
68
- * instead. `name` marks an export button with the target it downloads, and
71
+ * `content` is the icon a control draws and `label` the visible text it
72
+ * reads as; an export button carries both, the zoom trigger and the panel's
73
+ * dismiss the icon alone. Neither is ever the accessible name — `title` is,
74
+ * which is why the icons are `aria-hidden`. `name` marks an export button
75
+ * with the target it downloads, and
69
76
  * `popover` names the menu this button opens — the platform then owns the
70
77
  * opening and the invoker's own `aria-expanded`, which is why such a button
71
78
  * needs no click of its own.
@@ -83,6 +90,6 @@ export let button = ({ title, label, disabled, name, popover, click, content })
83
90
  ?disabled=${disabled}
84
91
  @click=${click}
85
92
  >
86
- ${fallback(label, content)}
93
+ ${content ?? nothing}${label ?? nothing}
87
94
  </button>
88
95
  `;
package/lib/check.js CHANGED
@@ -94,8 +94,9 @@ export let name = (filename) => {
94
94
  return base;
95
95
  };
96
96
 
97
- /** @type {Record<string, string>} */
98
- let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
97
+ /** The schemes the property accepts. Two are their own CSS value; `auto` is
98
+ * the one that is not, so it is the only wording written here. */
99
+ let SCHEMES = ["light", "dark", "auto"];
99
100
 
100
101
  /**
101
102
  * The used `color-scheme` from the `colorScheme` property. Light is the
@@ -108,6 +109,6 @@ let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
108
109
  */
109
110
  export let scheme = (colorScheme) => {
110
111
  let pin = colorScheme ?? "light";
111
- if (!Object.hasOwn(SCHEME, pin)) fail('colorScheme: expected "light", "dark", or "auto"');
112
- return SCHEME[pin];
112
+ if (!SCHEMES.includes(pin)) fail('colorScheme: expected "light", "dark", or "auto"');
113
+ return pin === "auto" ? "light dark" : pin;
113
114
  };
package/lib/chrome.js CHANGED
@@ -166,6 +166,25 @@ export let CHROME = css`
166
166
  display: flex;
167
167
  gap: 2px;
168
168
  }
169
+
170
+ /* An export button reads as a drawn download and the format in words, so it
171
+ takes the width its text needs rather than the icon-only box. */
172
+ .qv-exports .qv-button {
173
+ gap: 5px;
174
+ width: auto;
175
+ padding: 0 9px;
176
+ }
177
+
178
+ /* The base icon size, once per package rather than once per control: an
179
+ inline <svg> carrying a viewBox and no width falls back to 300x150, and
180
+ nothing in the chrome wants that. 18px is 0.75 of Lucide's 24 grid, which
181
+ puts its stroke 2 at 1.5px beside 13px text. The narrower selectors that
182
+ drop to 14px live with the controls they belong to — the menu's tick slot
183
+ in menu.js (ADR 0044). */
184
+ .qv-viewer svg {
185
+ width: 18px;
186
+ height: 18px;
187
+ }
169
188
  `;
170
189
 
171
190
  /**
package/lib/icons.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The icons @quario/viewer draws, vendored from Lucide: one export per icon,
3
+ * finished templates that carry no size. Chrome CSS decides that — 18px by
4
+ * default, 14px in the gutter and the menu's tick slot — because `x` appears
5
+ * in two contexts at once (ADR 0044).
6
+ *
7
+ * Generated by `scripts/icons.mjs` from lucide 1.40.0. Do not edit:
8
+ * `npm run icons` rewrites it, and `npm run check` regenerates it into a
9
+ * temp directory and compares.
10
+ *
11
+ * Lucide is ISC licensed. Copyright (c) 2026 Lucide Icons and Contributors.
12
+ */
13
+ import { html, svg } from "lit";
14
+
15
+ /** Upstream's grid and stroke, written once. */
16
+ /** @type {(paths: import('lit').SVGTemplateResult) => import('lit').TemplateResult} */
17
+ let icon = (paths) =>
18
+ html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
19
+
20
+ export let check = icon(svg`<path d="M20 6 9 17l-5-5"/>`);
21
+ export let download = icon(
22
+ svg`<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>`,
23
+ );
24
+ export let search = icon(svg`<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>`);
25
+ export let x = icon(svg`<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`);
package/lib/index.d.ts CHANGED
@@ -31,8 +31,8 @@ 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 reached
35
- * the sheet, `update-render` after, `export` for a download that could not
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
36
  * be produced.
37
37
  */
38
38
  kind: ViewerErrorKind;
@@ -96,11 +96,12 @@ export class QuarioViewer extends LitElement {
96
96
  */
97
97
  colorScheme: "light" | "dark" | "auto" | undefined;
98
98
  /**
99
- * The newest render settling: `true` when it reached the sheet with the
100
- * pages on screen painted, `false` when it failed or there was nothing to
101
- * render. Never rejects failures are the `error` event's and like every
102
- * outcome here it answers for the newest render only. `rendered` fires at
103
- * that same moment.
99
+ * The newest render settling: `true` when it landed on the sheet with the
100
+ * pages on screen finished trying to paint, `false` when it failed or there
101
+ * was nothing to render. A page the browser could not draw is blank and the
102
+ * render still landed. Never rejects failures are the
103
+ * `error` event's — and like every outcome here it answers for the newest
104
+ * render only. `rendered` fires at that same moment.
104
105
  */
105
106
  get renderComplete(): Promise<boolean>;
106
107
 
@@ -128,7 +129,7 @@ export class QuarioViewer extends LitElement {
128
129
 
129
130
  /** The element's events. Both are non-bubbling, like `<img>`'s. */
130
131
  export interface QuarioViewerEventMap {
131
- /** A render reached the sheet — the mount and every update. */
132
+ /** A render landed on the sheet — the mount and every update. */
132
133
  rendered: CustomEvent<undefined>;
133
134
  /** A failure the host should know about; `detail.kind` says which. */
134
135
  error: CustomEvent<ViewerErrorDetail>;
package/lib/index.js CHANGED
@@ -33,7 +33,7 @@ import { exports, geometry, level, name, scheme } from "./check.js";
33
33
  import { MENU, zoomMenu } from "./menu.js";
34
34
  import { PANEL, label, panel } from "./panel.js";
35
35
  import { SURFACE, stage } from "./stage.js";
36
- import { EXPORTS, download, exportGroup } from "./toolbar.js";
36
+ import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
37
37
  import { wanted } from "./zoom.js";
38
38
 
39
39
  /** @type {(report: unknown, targets: unknown) => boolean} */
@@ -89,7 +89,7 @@ export class QuarioViewer extends LitElement {
89
89
  // fails and a disconnect that does not. The template interpolates its
90
90
  // element as a node, which lit-html leaves untouched across re-renders.
91
91
  #stage = stage();
92
- /** Whether a render has ever reached the sheet — the mount/update boundary. */
92
+ /** Whether a render has ever landed on the sheet — the mount/update boundary. */
93
93
  #landed = false;
94
94
  /** Whether the sheet stopped matching the properties while disconnected. */
95
95
  #stale = false;
@@ -112,7 +112,13 @@ export class QuarioViewer extends LitElement {
112
112
  #exporting = new Set();
113
113
  /** @type {(() => void) | undefined} */
114
114
  #unwatch;
115
- /** @type {Promise<void>} The newest swap's paint, which `renderComplete` waits behind. */
115
+ /**
116
+ * The newest swap's paint, which `renderComplete` waits behind. Bare, with
117
+ * no catch of its own: the stage settles a page it cannot draw rather than
118
+ * rejecting, so the chain below is reached whatever the pixels did.
119
+ *
120
+ * @type {Promise<void>}
121
+ */
116
122
  #painting = Promise.resolve();
117
123
 
118
124
  // The whole async pipeline: keyed on the render properties, re-run when one
@@ -135,7 +141,7 @@ export class QuarioViewer extends LitElement {
135
141
  // The engine takes no signal, so abandonment is the guards around this
136
142
  // body; the check only spares the swap when the answer arrives after a
137
143
  // disconnect mid-render.
138
- if (this.#drop(signal)) return this.#abandon();
144
+ if (signal.aborted && !this.isConnected) return this.#abandon();
139
145
  return { list, fonts };
140
146
  },
141
147
  onComplete: (result) => {
@@ -145,7 +151,7 @@ export class QuarioViewer extends LitElement {
145
151
  this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
146
152
  this.#landed = true;
147
153
  this.#stale = false;
148
- // A render that reached the sheet takes the panel down: the panel
154
+ // A render that landed on the sheet takes the panel down: the panel
149
155
  // says what is wrong with what the reader is looking at, and this is
150
156
  // the moment that stops being true. A successful export is not that
151
157
  // moment.
@@ -186,10 +192,13 @@ export class QuarioViewer extends LitElement {
186
192
  }
187
193
 
188
194
  /**
189
- * The newest render settling: `true` when it reached the sheet, `false`
190
- * when it failed or there was nothing to render. It never rejects a
191
- * failed render is handled, on the panel and through the error event and
192
- * like every outcome here it answers for the newest render only.
195
+ * The newest render settling: `true` when it landed on the sheet — after
196
+ * its pages have finished trying to paint `false` when it failed or there
197
+ * was nothing to render. A page the browser could not draw is blank and the
198
+ * render still landed, so `true` promises the sheet is done changing, not
199
+ * that every page carries pixels. It never rejects — a failed
200
+ * render is handled, on the panel and through the error event — and like
201
+ * every outcome here it answers for the newest render only.
193
202
  *
194
203
  * @returns {Promise<boolean>}
195
204
  */
@@ -238,7 +247,7 @@ export class QuarioViewer extends LitElement {
238
247
  })}
239
248
  </div>
240
249
  <div class="qv-body">
241
- ${this.#panel()}
250
+ ${this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : ""}
242
251
  ${this.#stage.element}
243
252
  </div>
244
253
  </div>
@@ -259,7 +268,9 @@ export class QuarioViewer extends LitElement {
259
268
  super.connectedCallback();
260
269
  // A fitted sheet follows the host's box; the observer is released on
261
270
  // disconnect, so reconnection takes it again.
262
- this.#watchStage();
271
+ this.#unwatch ??= this.#stage.watch(() => {
272
+ if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
273
+ });
263
274
  // Reconnect re-renders from the current properties — but only when the
264
275
  // sheet stopped matching them: a render was abandoned mid-flight, or a
265
276
  // property write landed while disconnected. The task's arguments did not
@@ -295,11 +306,6 @@ export class QuarioViewer extends LitElement {
295
306
  return true;
296
307
  }
297
308
 
298
- /** @param {AbortSignal} signal */
299
- #drop(signal) {
300
- return signal.aborted && !this.isConnected;
301
- }
302
-
303
309
  #abandon() {
304
310
  this.#stale = true;
305
311
  return null;
@@ -361,18 +367,6 @@ export class QuarioViewer extends LitElement {
361
367
  if (said(was) !== said(this.#invalid)) this.#epoch++;
362
368
  }
363
369
 
364
- #panel() {
365
- return this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : "";
366
- }
367
-
368
- #onResize() {
369
- if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
370
- }
371
-
372
- #watchStage() {
373
- this.#unwatch ??= this.#stage.watch(() => this.#onResize());
374
- }
375
-
376
370
  #wake() {
377
371
  if (this.#stale || this.#task.status === TaskStatus.PENDING) {
378
372
  this.#epoch++;
@@ -442,12 +436,13 @@ export class QuarioViewer extends LitElement {
442
436
  * @param {any} target
443
437
  */
444
438
  async #export(target) {
445
- let { type, label: format } = EXPORTS[target.name];
439
+ let type = EXPORTS[target.name];
440
+ let format = target.name.toUpperCase();
446
441
  this.#exporting.add(target.name);
447
442
  this.requestUpdate();
448
443
  try {
449
444
  let body = await /** @type {any} */ (this.report).render(target, this.data);
450
- this.#ifConnected(() => download(body, this.#name + "." + target.name, type));
445
+ this.#ifConnected(() => saveAs(body, this.#name + "." + target.name, type));
451
446
  } catch (error) {
452
447
  this.#ifConnected(() => this.#announce(error, "export", format));
453
448
  } finally {
package/lib/menu.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The zoom menu: the magnifier trigger in the bar and the popover it opens —
2
+ * The zoom menu: the search trigger in the bar and the popover it opens —
3
3
  * "Fit page" above a separator, then the percentages `zoom.js` offers. It is
4
4
  * the whole zoom control; the live percentage is never drawn, it is what the
5
5
  * trigger is named after ("Zoom, 62%"), so the bar carries one icon rather
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { css, html } from "lit";
31
31
  import { button } from "./button.js";
32
+ import { check, search } from "./icons.js";
32
33
  import { STEPS } from "./zoom.js";
33
34
 
34
35
  /** The popover's id, scoped to the shadow root, so two viewers never collide. */
@@ -85,12 +86,22 @@ export let MENU = css`
85
86
  }
86
87
 
87
88
  /* The check's slot is held whether or not a check is in it, so the labels
88
- line up down the menu. */
89
+ line up down the menu. 14px rather than 13: the small icon size serves this
90
+ slot and the editor's gutter alike, which is one size fewer than buying a
91
+ third for one glyph (ADR 0044). */
89
92
  .qv-tick {
90
93
  display: inline-flex;
91
94
  flex: none;
92
- width: 13px;
93
- height: 13px;
95
+ width: 14px;
96
+ height: 14px;
97
+ }
98
+
99
+ /* Narrower than the chrome's 18px base on specificity, not on where this
100
+ sheet sits in \`static styles\` — reordering that array must not silently
101
+ put the tick back to 18px. */
102
+ .qv-viewer .qv-tick svg {
103
+ width: 14px;
104
+ height: 14px;
94
105
  }
95
106
 
96
107
  .qv-separator {
@@ -100,30 +111,6 @@ export let MENU = css`
100
111
  }
101
112
  `;
102
113
 
103
- /** @type {import('lit').TemplateResult} */
104
- let magnifier = html`
105
- <svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
106
- <g fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
107
- <circle cx="8.75" cy="8.75" r="5.25"></circle>
108
- <path d="M12.6 12.6 16.4 16.4"></path>
109
- </g>
110
- </svg>
111
- `;
112
-
113
- /** @type {import('lit').TemplateResult} */
114
- let tick = html`
115
- <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
116
- <path
117
- d="M3.4 8.4 6.4 11.4 12.6 4.8"
118
- fill="none"
119
- stroke="currentColor"
120
- stroke-width="1.9"
121
- stroke-linecap="round"
122
- stroke-linejoin="round"
123
- ></path>
124
- </svg>
125
- `;
126
-
127
114
  /**
128
115
  * The button that opens this menu, found by the invoker relationship itself
129
116
  * rather than by where it sits: a wrapper added for layout, or a second
@@ -276,7 +263,7 @@ let row = (label, value, checked, choose) => html`
276
263
  data-zoom=${String(value)}
277
264
  @click=${pick(value, choose)}
278
265
  >
279
- <span class="qv-tick">${checked ? tick : ""}</span>
266
+ <span class="qv-tick">${checked ? check : ""}</span>
280
267
  ${label}
281
268
  </button>
282
269
  `;
@@ -298,7 +285,7 @@ export let zoomMenu = ({ mode, percent, choose }) => html`
298
285
  ${button({
299
286
  title: "Zoom, " + percent + "%",
300
287
  popover: ID,
301
- content: magnifier,
288
+ content: search,
302
289
  })}
303
290
  <div
304
291
  id=${ID}
package/lib/panel.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { css, html } from "lit";
21
21
  import { button } from "./button.js";
22
+ import { x } from "./icons.js";
22
23
 
23
24
  export let PANEL = css`
24
25
  /* Positioned rather than laid out, for the reason the progress strip is: a
@@ -121,6 +122,6 @@ export let panel = (failure, dismiss) => html`
121
122
  <p class="qv-error-label">${failure.label}</p>
122
123
  <p class="qv-error-message">${wording(failure.error)}</p>
123
124
  </div>
124
- ${button({ title: "Dismiss", label: "×", click: dismiss })}
125
+ ${button({ title: "Dismiss", content: x, click: dismiss })}
125
126
  </div>
126
127
  `;
package/lib/stage.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * never reflows (zoom.js says why).
13
13
  *
14
14
  * Every page keeps its CSS size, but only the
15
- * [band](../../../CONTEXT.md#band) carries pixels — the pages on screen and
15
+ * [reach](../../../CONTEXT.md#reach) carries pixels — the pages on screen and
16
16
  * one viewport height either side. The extent the reader scrolls through is
17
17
  * whole and synchronous whatever is painted; what comes and goes is the
18
18
  * backing store, released by sizing a canvas to 0 × 0. A scroll or a resize
@@ -21,6 +21,41 @@
21
21
  * asked a thousand-page report for gigabytes of backing store, and past what
22
22
  * the browser would grant the pages simply came up blank.
23
23
  *
24
+ * **A page the browser will not paint is blank, not a failure.** `start`
25
+ * swallows and nothing here rejects: the report laid out and the list reached
26
+ * the sheet, which is all a render promised, so the surfaces have no channel
27
+ * for it and deliberately grow none.
28
+ *
29
+ * Little reaches it. An image that will not decode is the layout's own to
30
+ * absorb — `paint` draws it as nothing and draws the page around it — and a
31
+ * face is parsed with fontkit while the report is measured, so bytes that will
32
+ * not parse are a render error long before the sheet. What is left is the face
33
+ * fontkit accepted and the browser refuses, which the browser suite drives by
34
+ * making `FontFace.prototype.load` reject under a record handed straight to
35
+ * `swap`.
36
+ *
37
+ * **A canvas on the sheet is never re-pointed at a second paint.** `paint()`
38
+ * awaits before it draws, so a canvas whose paint is still in flight is
39
+ * *retired* rather than painted over or emptied: it comes off the sheet, its
40
+ * pixels go back, and a fresh one stands in its place, so the superseded
41
+ * 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
+ * carrying old-scale content on a new-scale canvas, with the memo below
45
+ * calling it painted so that nothing repainted it again (ADR 0046).
46
+ *
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
49
+ * reaches it, so a page further down the reach shows its old pixels stretched
50
+ * into the new box until its turn comes — the ordinary look of a zoom in
51
+ * progress, not the artefact above. The cost is that a retired page is a
52
+ * replaced `role="img"` node: assistive technology reading that page sees it
53
+ * swapped under them. Judged acceptable because retirement only happens while
54
+ * that very page is mid-repaint and about to change what it shows anyway, and
55
+ * because the alternative — a stable wrapper element per page to announce
56
+ * from — is a second element per page on a sheet ADR 0043 exists to keep
57
+ * cheap.
58
+ *
24
59
  * What the stage does not decide is which percentage to show: `fit()`
25
60
  * measures what would make one page span the width available, and `zoom.js`
26
61
  * owns the policy over that answer. What is on screen, though, is the
@@ -79,7 +114,7 @@ export let SURFACE = css`
79
114
  }
80
115
 
81
116
  /* One page. The painter fills it white; the background here is what a page
82
- outside the band shows, and what one inside it shows between being sized
117
+ outside the reach shows, and what one inside it shows between being sized
83
118
  and being painted. */
84
119
  .qv-page {
85
120
  display: block;
@@ -131,7 +166,7 @@ export let stage = () => {
131
166
 
132
167
  /**
133
168
  * What each backed page is carrying: the scale it was painted at, so a page
134
- * re-entering the band at that scale is not repainted and a scale change
169
+ * re-entering the reach at that scale is not repainted and a scale change
135
170
  * invalidates it, and the paint itself, so whoever asks second waits behind
136
171
  * the paint the first caller started rather than being told it is done.
137
172
  * Keyed by the canvas, so a swap's new pages start with nothing remembered
@@ -143,84 +178,224 @@ export let stage = () => {
143
178
  let backed = new WeakMap();
144
179
 
145
180
  /**
146
- * Give a page's pixels back. Sizing the canvas to 0 × 0 is the one idiom
147
- * that frees the store synchronously in every engine the viewer runs in;
148
- * the CSS size is untouched, so the page keeps its place in the extent and
149
- * shows the sheet's white.
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.
150
191
  *
151
- * @type {(canvas: HTMLCanvasElement) => void}
192
+ * @type {WeakSet<HTMLCanvasElement>}
152
193
  */
153
- let release = (canvas) => {
194
+ let settled = new WeakSet();
195
+
196
+ /** CSS pixels per point at the applied percentage. */
197
+ let ratio = () => (PX_PER_POINT * applied) / 100;
198
+
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);
154
283
  if (!backed.has(canvas)) return;
284
+ if (!settled.has(canvas)) return void retire(i);
155
285
  backed.delete(canvas);
156
286
  canvas.width = 0;
157
287
  canvas.height = 0;
158
288
  };
159
289
 
160
- /** The page width the fit measures against: the list's, or the geometry's
161
- * before one arrives. */
162
- let pageWidth = () => (list ? list.width : fallback);
163
-
164
- /** CSS pixels per point at the applied percentage. */
165
- let ratio = () => (PX_PER_POINT * applied) / 100;
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);
166
293
 
167
294
  /**
168
- * Paint one page and answer with that paint or with the paint already
169
- * under way at this scale, which is what makes scrolling back over ground
170
- * already covered free. Answering with the paint rather than with a
171
- * resolved promise is what lets two callers reach the same page and both
172
- * settle behind its pixels. A paint that failed is forgotten, so the next
173
- * pass over the band tries again instead of counting the page as painted.
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`.
174
302
  *
175
- * @type {(canvas: HTMLCanvasElement, i: number) => Promise<void>}
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>}
176
318
  */
177
- let paintPage = (canvas, i) => {
178
- let scale = ratio() * (globalThis.devicePixelRatio || 1);
179
- let carrying = backed.get(canvas);
180
- if (carrying && carrying.scale === scale) return carrying.painted;
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();
181
324
  let each = list.pages[i];
182
325
  canvas.width = Math.round(each.width * scale);
183
326
  canvas.height = Math.round(each.height * scale);
184
327
  let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
185
- let painted = paint(ctx, each, { scale, fonts }).catch((failure) => {
186
- backed.delete(canvas);
187
- throw failure;
188
- });
328
+ let painted = paint(ctx, each, { scale, fonts }).then(
329
+ () => {
330
+ settled.add(canvas);
331
+ },
332
+ () => {
333
+ backed.delete(canvas);
334
+ },
335
+ );
189
336
  backed.set(canvas, { scale, painted });
190
337
  return painted;
191
338
  };
192
339
 
193
- /** Bumped per repaint, so a repaint overtaken by the next stops painting. */
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
+ */
194
373
  let epoch = 0;
195
374
 
196
375
  /**
197
376
  * Give every page its CSS size. Callers run this before writing the scroll
198
377
  * offsets back: the extent those offsets are clamped against is this one,
199
- * and the band below is read from the offsets once they are in.
378
+ * and the reach below is read from the offsets once they are in.
200
379
  */
201
380
  let sizeAll = () => {
202
381
  if (!list) return;
203
382
  let px = ratio();
204
- for (let [i, each] of list.pages.entries()) {
205
- let canvas = /** @type {HTMLElement} */ (sheet.children[i]);
206
- canvas.style.width = each.width * px + "px";
207
- canvas.style.height = each.height * px + "px";
208
- }
383
+ for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
209
384
  };
210
385
 
211
386
  /**
212
- * The indices of the pages in the band — the ones the viewport shows, and
387
+ * The indices of the pages in the reach — the ones the viewport shows, and
213
388
  * everything within one viewport height above or below. Walked over the
214
389
  * list's own geometry (the gutter, each page's height at the applied
215
390
  * scale, the gap), never a layout read, so it costs nothing to ask
216
391
  * mid-scroll — the whole list is walked because a multiply and an add per
217
392
  * page is beneath measuring, and stopping early would be a second rule
218
- * about where the band ends. `clientHeight` is read afresh each time, so a
219
- * pane that changed size changes the band with it.
393
+ * about where the reach ends. `clientHeight` is read afresh each time, so a
394
+ * pane that changed size changes the reach with it.
220
395
  *
221
396
  * @returns {number[]}
222
397
  */
223
- let inBand = () => {
398
+ let inReach = () => {
224
399
  let view = scroll.clientHeight;
225
400
  let top = scroll.scrollTop - view;
226
401
  let bottom = scroll.scrollTop + 2 * view;
@@ -236,73 +411,58 @@ export let stage = () => {
236
411
  };
237
412
 
238
413
  /**
239
- * Release every page the band has left behind, and answer with the ones to
240
- * keep, each with its index in the order they are painted in. The single
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
241
416
  * definition of which pages carry pixels: the repaint below and the scroll
242
417
  * pass both go through here, and neither holds a page the other released.
243
418
  *
244
- * @type {() => [number, HTMLCanvasElement][]}
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[]}
245
423
  */
246
424
  let keep = () => {
247
- let wanted = new Set(inBand());
425
+ let wanted = new Set(inReach());
248
426
  let kept = [];
249
- for (let i = 0; i < sheet.children.length; i++) {
250
- let canvas = /** @type {HTMLCanvasElement} */ (sheet.children[i]);
251
- if (wanted.has(i)) kept.push(/** @type {[number, HTMLCanvasElement]} */ ([i, canvas]));
252
- else release(canvas);
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);
253
431
  }
254
432
  return kept;
255
433
  };
256
434
 
257
- // Paint the band in order, yielding between pages and giving way to any
435
+ // Paint the reach in order, yielding between pages and giving way to any
258
436
  // repaint that started since. Sizing is the caller's, and comes first.
259
437
  let repaint = async () => {
260
438
  if (!list) return;
261
439
  let mine = ++epoch;
262
- for (let [i, canvas] of keep()) {
440
+ for (let i of keep()) {
263
441
  if (mine !== epoch) return;
264
- await paintPage(canvas, i);
442
+ await paintPage(i);
265
443
  }
266
444
  };
267
445
 
268
446
  /**
269
- * The band pass a scroll or a resize runs. It keeps no epoch of its own, so
270
- * a swap painting behind it is never cut off part-painted, and a page it
271
- * starts is one a swap reaching the same page waits behind rather than
272
- * skips. Nothing awaits these paints here: a scroll is not a render and has
273
- * no failure channel of its own, and a paint that failed is forgotten, so
274
- * the next pass over the band reports it the way any other paint does.
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.
275
453
  */
276
- let sync = () => {
277
- if (!list) return;
278
- for (let [i, canvas] of keep()) paintPage(canvas, i).catch(() => {});
279
- };
280
-
281
- /** One band pass a frame, however many scrolls and resizes ask for one. */
282
454
  let pending = false;
283
455
  let follow = () => {
284
456
  if (pending) return;
285
457
  pending = true;
286
458
  requestAnimationFrame(() => {
287
459
  pending = false;
288
- sync();
460
+ if (!list) return;
461
+ for (let i of keep()) void paintPage(i);
289
462
  });
290
463
  };
291
464
  scroll.addEventListener("scroll", follow);
292
465
 
293
- /** @type {(i: number, total: number) => HTMLCanvasElement} */
294
- let pageOf = (i, total) => {
295
- let canvas = document.createElement("canvas");
296
- canvas.className = "qv-page";
297
- // A canvas is 300 x 150 until told otherwise, and that store counts. A
298
- // page starts with none and takes one when the band reaches it.
299
- canvas.width = 0;
300
- canvas.height = 0;
301
- canvas.setAttribute("role", "img");
302
- canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + total);
303
- return canvas;
304
- };
305
-
306
466
  return {
307
467
  element: scroll,
308
468
 
@@ -314,7 +474,8 @@ export let stage = () => {
314
474
  */
315
475
  fit: () => {
316
476
  let usable = scroll.clientWidth - 2 * GUTTER;
317
- let width = pageWidth() * PX_PER_POINT;
477
+ // The list's page width, or the geometry's before one arrives.
478
+ let width = (list ? list.width : fallback) * PX_PER_POINT;
318
479
  if (usable <= 0 || !width) return null;
319
480
  return (usable / width) * 100;
320
481
  },
@@ -333,7 +494,7 @@ export let stage = () => {
333
494
  * seen. The browser clamps whatever it cannot honour. An empty sheet has
334
495
  * no view to hold, which is what mounting at an authored zoom takes.
335
496
  *
336
- * Only the band is repainted, and only where the scale actually changed,
497
+ * Only the reach is repainted, and only where the scale actually changed,
337
498
  * so a zoom step costs a handful of pages however long the report is.
338
499
  */
339
500
  scale: (percent) => {
@@ -367,26 +528,29 @@ export let stage = () => {
367
528
 
368
529
  /**
369
530
  * Put a laid-out report on the sheet, keeping the reader where they
370
- * were: one canvas per page, the band among them painted at the applied
371
- * scale. The order is the point of the method: the pages take their size
372
- * before the offsets go back, so the browser clamps them against the
373
- * extent they will have rather than the one they had — and the band is
374
- * read from those offsets, so it is chosen after they are in. What the
375
- * returned promise settles behind is the band, which is what the caller's
376
- * `renderComplete` means by "the pages on screen are painted".
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
537
+ * returned promise settles behind is the reach, which is what the caller's
538
+ * `renderComplete` means by "the pages on screen have finished trying to
539
+ * paint" — and **it never rejects**, because `start` swallows a page the
540
+ * browser will not draw and the reach walks on past it.
377
541
  */
378
542
  swap: async (next, faces) => {
379
543
  // Reading the offsets flushes layout, so only an actual reswap pays for
380
544
  // it: on an empty sheet there is nothing scrolled to preserve.
381
- let held = sheet.firstChild && { top: scroll.scrollTop, left: scroll.scrollLeft };
545
+ let held = sheet.firstChild && {
546
+ top: scroll.scrollTop,
547
+ left: scroll.scrollLeft,
548
+ };
382
549
  list = next;
383
550
  fonts = faces;
384
551
  sheet.replaceChildren(
385
- ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) =>
386
- pageOf(i, next.pages.length),
387
- ),
552
+ ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
388
553
  );
389
- sizeAll();
390
554
  if (held) {
391
555
  scroll.scrollTop = held.top;
392
556
  scroll.scrollLeft = held.left;
@@ -403,7 +567,7 @@ export let stage = () => {
403
567
  watch: (changed) => {
404
568
  let observer = new ResizeObserver(() => {
405
569
  // A resize that changes the fit repaints through `changed()`; one
406
- // that does not still moved the viewport the band is measured in.
570
+ // that does not still moved the viewport the reach is measured in.
407
571
  follow();
408
572
  changed();
409
573
  });
package/lib/toolbar.js CHANGED
@@ -3,54 +3,32 @@
3
3
  * host passed, in the order they were given. It sits at the right of the
4
4
  * viewer's bar, beside the zoom menu; no exportable target, no group.
5
5
  * The element owns the click — rendering with its current data and
6
- * auto-downloading through `download` below — because only it knows
6
+ * auto-downloading through `saveAs` below — because only it knows
7
7
  * whether it is still connected when the file arrives.
8
8
  *
9
- * The icon's label goes in as a template value, never as a string spliced
9
+ * The format's name goes in as a template value, never as a string spliced
10
10
  * into a document, so this module has no markup edge to escape at and never
11
11
  * grows one (hard constraint 4).
12
12
  */
13
13
  import { html, nothing } from "lit";
14
14
  import { button } from "./button.js";
15
+ import { download } from "./icons.js";
15
16
 
16
- /** The exportable targets: content type and button wording. */
17
- /** @type {Record<string, { type: string, label: string }>} */
17
+ /** The exportable targets, filed under the extension each downloads as: the
18
+ * content type its blob carries. The wording on the button and in a failure
19
+ * is that same name upper-cased, never a second spelling of it. */
20
+ /** @type {Record<string, string>} */
18
21
  export let EXPORTS = {
19
- pdf: { type: "application/pdf", label: "PDF" },
20
- xlsx: {
21
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
- label: "XLSX",
23
- },
24
- csv: { type: "text/csv", label: "CSV" },
22
+ pdf: "application/pdf",
23
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
24
+ csv: "text/csv",
25
25
  };
26
26
 
27
- // Sheet outline with a folded corner, over the format's name.
28
- /** @type {(label: string) => import('lit').TemplateResult} */
29
- let icon = (label) => html`
30
- <svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
31
- <g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round">
32
- <path d="M4.5 2.5h7l4 4v11h-11z"></path>
33
- <path d="M11.5 2.5v4h4"></path>
34
- </g>
35
- <text
36
- x="10"
37
- y="14.5"
38
- text-anchor="middle"
39
- font-size="5"
40
- font-weight="700"
41
- letter-spacing="0.2"
42
- fill="currentColor"
43
- >
44
- ${label}
45
- </text>
46
- </svg>
47
- `;
48
-
49
27
  // The anchor joins the document for the click — detached-anchor downloads
50
28
  // are unreliable outside Chromium — and the URL is revoked well after the
51
29
  // browser has had time to start reading the blob.
52
30
  /** @type {(body: string | Uint8Array<ArrayBuffer>, name: string, type: string) => void} */
53
- export let download = (body, name, type) => {
31
+ export let saveAs = (body, name, type) => {
54
32
  let url = URL.createObjectURL(new Blob([body], { type }));
55
33
  let anchor = document.createElement("a");
56
34
  anchor.href = url;
@@ -80,14 +58,17 @@ export let exportGroup = (targets, context) => {
80
58
  return html`
81
59
  <div class="qv-exports">
82
60
  ${exportable.map((target) => {
83
- let { label } = EXPORTS[target.name];
61
+ let label = target.name.toUpperCase();
84
62
  return button({
85
- // The icon carries the format, so the button takes no text of its own.
63
+ // One drawn verb and the format in words beside it: the download
64
+ // icon says what the click does, and the format is information the
65
+ // reader needs rather than a glyph to decode (ADR 0044).
86
66
  title: "Download " + label,
67
+ label,
87
68
  name: target.name,
88
69
  disabled: context.busy.has(target.name),
89
70
  click: () => context.click(target),
90
- content: icon(label),
71
+ content: download,
91
72
  });
92
73
  })}
93
74
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/viewer",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,21 +44,21 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@lit/task": "^1.0.3",
47
- "@quario/layout": "^0.1.0",
47
+ "@quario/layout": "^0.3.0",
48
48
  "lit": "^3.3.3"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@arethetypeswrong/cli": "^0.18.3",
52
+ "@cantoo/pdf-lib": "^2.9.1",
52
53
  "@size-limit/preset-small-lib": "^13.0.3",
53
54
  "esbuild": "^0.28.2",
54
55
  "exceljs": "^4.4.0",
55
- "pdf-lib": "^1.17.1",
56
- "quario": "^0.4.0",
56
+ "quario": "^0.6.0",
57
57
  "size-limit": "^13.0.3",
58
58
  "typescript": "^7.0.2"
59
59
  },
60
60
  "peerDependencies": {
61
- "quario": "^0.4.0"
61
+ "quario": "^0.6.0"
62
62
  },
63
63
  "size-limit": [
64
64
  {