@quario/viewer 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @quario/viewer
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **A collapsed group does not draw its content.** The sheet paints the layout's pages, so an instance whose group declares `collapsed` reads as its header and footer alone.
8
+ - **A group's `label` titles its outline row.** The panel lists the layout's marks, so an instance that declares a label reads under it rather than under its first header line.
9
+ - **A linked run is clickable, and reachable with a keyboard.** Every run carrying an `href` now gets a region of its own over the words the page paints, so a reader clicks it or reaches it with Tab and opens it with Enter. A URL is an anchor opening in a tab of its own. An `href` naming a group's `label` is a button that steps the sheet to that instance, the way an outline row does, and a label no instance carries is no region at all. The regions follow the reach as the pages do, and move with the zoom.
10
+
11
+ The size budget rises from 8.5 kB to 9.5 kB to hold them.
12
+
13
+ - **An optional outline panel.** Set `outline` to `true` and the bar carries one more control, which opens a panel beside the sheet. The panel lists the report's group instances, nested by depth, in document order, and a row scrolls the sheet to where that instance begins. The list is the group tree the PDF target writes as bookmarks, so a report declares nothing for it. An instance whose bands all resolve hidden is not in it. The module is off by default. The reader opens and closes the panel.
14
+
15
+ ### Patch Changes
16
+
17
+ - **An error quario replaces keeps the one it replaced, as `cause`.** A failure inside one of your own functions reaches you named where it happened — `detail[0].value [{{ total() }}]: ...` — which is a copy, rebuilt from the class and a prefixed message. The stack that says which line of your function threw used to go with the original. It now rides along: read `error.cause` for the throw as it happened, and the message for where quario was when it happened.
18
+
19
+ The viewer's and the editor's own property checks do the same. A `page` or `fonts` value either element refuses throws a `TypeError` naming the property, and that error now carries the layout's own account of the value behind it.
20
+
21
+ - **`renderComplete` answers for the newest render at the moment it settles.** A render that starts behind a pending `await` is the one the promise then waits for, so a host awaiting it while properties keep changing waits for the last change's render. Before, the promise could settle for the render in flight when it was asked, or miss one that started behind it.
22
+ - **The viewer reports a target it cannot export, rather than dropping it.** A `targets` entry whose name is not `pdf`, `xlsx`, `csv`, `docx` or `html` used to disappear: no button, no error. It is now a host mistake like any other malformed property. The error panel names the entry by its index and the name it carried, and the last good render stays up. An `html` target is still accepted and not read, so one list can serve both `render` and the viewer.
23
+ - Updated dependencies
24
+ - quario@0.11.0
25
+ - @quario/layout@0.8.0
26
+ - @quario/landing@0.3.1
27
+
3
28
  ## 0.10.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -19,6 +19,8 @@ exportable target it carries nothing else.
19
19
  - [Lifecycle](#lifecycle)
20
20
  - [Using with React, Vue and Svelte](#using-with-react-vue-and-svelte)
21
21
  - [Zoom](#zoom)
22
+ - [Outline](#outline)
23
+ - [Links](#links)
22
24
  - [What the preview is](#what-the-preview-is)
23
25
  - [Errors](#errors)
24
26
  - [Color scheme](#color-scheme)
@@ -81,11 +83,14 @@ package to install. See [Using with React, Vue and Svelte](#using-with-react-vue
81
83
  | `fonts` | The font mapping, as passed to `pdf({ fonts })` | none |
82
84
  | `filename` | Export download name, without extension | `"report"` |
83
85
  | `colorScheme` | `"light"`, `"dark"`, or `"auto"` | `"light"` |
86
+ | `outline` | `true` to offer the outline panel | `false` |
84
87
 
85
88
  Properties, not attributes: `report`, `targets`, `data`, `page` and `fonts` are values no attribute
86
89
  could carry. `targets` mirrors `report.render(target, data)` for the exports:
87
90
  `"pdf"`/`"xlsx"`/`"csv"`/`"docx"` targets become export buttons, in the order given, and the
88
- sheet needs none of them. The quario instance (and with it the license and the registry) stays
91
+ sheet needs none of them. An `"html"` target is accepted and not read, so one list can serve
92
+ `render` and the viewer. Any other name is a host mistake: the viewer reports it on the error panel
93
+ with the entry's index, and renders nothing. The quario instance (and with it the license and the registry) stays
89
94
  yours: the element takes the compiled report, never a schema.
90
95
 
91
96
  Assigning `data` (or any of the others) re-renders. Rapid successive writes render only the newest
@@ -115,8 +120,9 @@ inline `onerror` attribute on the element would fire too. `window.onerror` never
115
120
  `renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
116
121
  on screen finished trying to paint, `false` when it failed or there was nothing to render. It never
117
122
  rejects. Failures arrive on the `error` event. `rendered` fires at that same moment. Like every
118
- outcome here, it answers for the newest render only. A superseded render's failure reaches no
119
- one.
123
+ outcome here, it answers for the newest render only: the render that is newest when the promise
124
+ settles, so a render that starts behind your `await` is the one it then waits for. A superseded
125
+ render's failure reaches no one.
120
126
 
121
127
  An image the browser cannot decode — pixel data corrupt past the size in its header, which is all
122
128
  the engine reads — is drawn as nothing, and the page is drawn around it. That is not a render
@@ -250,6 +256,18 @@ Every page keeps its size, so the scrollbar and the scroll extent are the whole
250
256
  start. A page further off is blank paper until you scroll to it, which is why a zoom step costs the
251
257
  same on a thousand-page report as on a five-page one.
252
258
 
259
+ ## Links
260
+
261
+ A run that carries an [`href`](https://github.com/getquario/quario/blob/main/SCHEMA.md#style-declarations) gets a region of its own over the words the page
262
+ paints. The reader clicks it, or reaches it with the Tab key and opens it with Enter. You
263
+ declare nothing for this and turn nothing on.
264
+
265
+ A URL opens in a tab of its own, so a reader reading a report never loses it. The engine
266
+ admitted that URL against your instance's `schemes` allowlist before the viewer saw it,
267
+ which is where you decide what a report may point at. An `href` that starts with `#` names
268
+ a group's `label` and steps the sheet to that instance, the way an outline row does. A
269
+ label no instance carries is no region, and the words stay as they are.
270
+
253
271
  ## What the preview is
254
272
 
255
273
  The pages on screen are the pages the PDF export writes: both consume one layout, `@quario/layout`'s
@@ -258,6 +276,8 @@ longer one is as many pages as the layout breaks it into, stacked down the sheet
258
276
  container to a viewer that draws a short report therefore reserves a full page. Set `zoom` to a
259
277
  percentage small enough where the box has to stay small.
260
278
 
279
+ The reader reads and does not reorder. The viewer paints the layout's pages and offers no sort, because a page is not a grid; a host that wants sorted rows renders the report again with them sorted. The XLSX target's `filter` option is where a reader gets a control of their own.
280
+
261
281
  Each page is a canvas, and text on it is drawn in the face the document will use. The viewer
262
282
  registers a TrueType family you pass as `fonts` from your own bytes and draws it as the browser
263
283
  shapes it —
@@ -319,6 +339,19 @@ data is the message itself, if your own registry functions interpolate a row int
319
339
  That is your call, and the panel puts it on screen as text, never as markup. Compile errors are not
320
340
  part of this: `q.report(schema)` raises those before the viewer is ever handed a report.
321
341
 
342
+ ## Outline
343
+
344
+ Set `outline` to `true` and the bar carries one more control. It opens a panel beside the sheet
345
+ that lists the report's group instances, nested by depth, in document order. A row scrolls the
346
+ sheet to where that instance begins. The list is the group tree the PDF target writes as
347
+ bookmarks, and a row reads the group's `label` where one is declared. A group instance whose bands all resolve
348
+ hidden is not in the list. The property turns the module on. The reader opens and closes the
349
+ panel, and the panel opens when the module turns on.
350
+
351
+ ```js
352
+ view.outline = true;
353
+ ```
354
+
322
355
  ## Color scheme
323
356
 
324
357
  `colorScheme` paints the **chrome** — backdrop, bar, controls, progress strip, error
package/lib/button.js CHANGED
@@ -24,16 +24,28 @@ export let BUTTON = css`
24
24
  cursor: pointer;
25
25
  }
26
26
 
27
- .qv-button:hover {
27
+ /* The interaction treatment, written once for every <button> the viewer
28
+ draws. A menu row (\`menu.js\`) and an outline row (\`outline.js\`) are
29
+ buttons too: a different shape — full width, left-aligned — but hovering
30
+ and focusing them mean the same thing, and stating that twice is how the
31
+ two drifted apart before the palette landed. Where a row differs it says
32
+ so itself. */
33
+ .qv-button:hover,
34
+ .qv-menuitem:hover,
35
+ .qv-outline-row:hover {
28
36
  background: var(--_hover);
29
37
  color: var(--_icon-active);
30
38
  }
31
39
 
32
- .qv-button:active {
40
+ .qv-button:active,
41
+ .qv-menuitem:active,
42
+ .qv-outline-row:active {
33
43
  background: var(--_active);
34
44
  }
35
45
 
36
- .qv-button:focus-visible {
46
+ .qv-button:focus-visible,
47
+ .qv-menuitem:focus-visible,
48
+ .qv-outline-row:focus-visible {
37
49
  outline: 2px solid var(--_focus);
38
50
  outline-offset: 1px;
39
51
  }
@@ -65,8 +77,8 @@ let fallback = (value, otherwise) => value ?? otherwise;
65
77
  *
66
78
  * @param {{ title: string, label?: string, disabled?: boolean, name?: string,
67
79
  * popover?: string, click?: () => void,
68
- * content?: import('lit').TemplateResult }} control
69
- * `content` is the icon a control draws and `label` the visible text it
80
+ * content: import('lit').TemplateResult }} control
81
+ * `content` is the icon every control draws and `label` the visible text it
70
82
  * reads as. Neither is ever the accessible name — `title` is, which is why
71
83
  * the icons are `aria-hidden`. `name` marks an export button with the target
72
84
  * it downloads. `popover` names the menu this button opens, and the platform
@@ -85,6 +97,6 @@ export let button = ({ title, label, disabled, name, popover, click, content })
85
97
  ?disabled=${disabled}
86
98
  @click=${click}
87
99
  >
88
- ${content ?? nothing}${label ?? nothing}
100
+ ${content}${label ?? nothing}
89
101
  </button>
90
102
  `;
package/lib/check.js CHANGED
@@ -7,11 +7,20 @@
7
7
  */
8
8
 
9
9
  import { checkFonts, pageBox } from "@quario/layout";
10
+ import { EXPORTS } from "./toolbar.js";
10
11
  import { STEPS } from "./zoom.js";
11
12
 
12
- /** @type {(message: string) => never} */
13
- let fail = (message) => {
14
- throw new TypeError("quario-viewer: " + message);
13
+ /**
14
+ * Name the mistake on the property that carries it. An `Error` stands in for
15
+ * its own message and rides along as `cause`: a replacement is a claim about
16
+ * what went wrong, and only the error it replaced can contradict it
17
+ * (docs/adr/0084).
18
+ *
19
+ * @type {(said: string | Error) => never}
20
+ */
21
+ let fail = (said) => {
22
+ let message = said instanceof Error ? said.message : said;
23
+ throw new TypeError("quario-viewer: " + message, said instanceof Error ? { cause: said } : {});
15
24
  };
16
25
 
17
26
  let REPORT = [
@@ -23,10 +32,19 @@ let REPORT = [
23
32
  /** @type {(report: any) => boolean} */
24
33
  let compiled = (report) => REPORT.every((check) => check(report));
25
34
 
35
+ // The one target the viewer accepts and never reads: a host that hands one
36
+ // list to `render` and to the viewer carries it, and the sheet is the
37
+ // layout's own. Every other name the export map does not hold is a mistake,
38
+ // because the viewer would drop it without a button and without a word.
39
+ let UNREAD = ["html"];
40
+ let ACCEPTED = [...Object.keys(EXPORTS), ...UNREAD];
41
+ let NAMES = ACCEPTED.map((name) => '"' + name + '"').join(", ");
42
+
26
43
  /**
27
44
  * The shapes a render needs: a compiled report, and the export targets as an
28
45
  * array — possibly empty, since the sheet is the layout's own and needs no
29
- * target of the host's.
46
+ * target of the host's. Each entry names a target the viewer exports or the
47
+ * one it leaves unread; anything else is named on its index.
30
48
  *
31
49
  * @param {any} report
32
50
  * @param {any} targets
@@ -34,6 +52,19 @@ let compiled = (report) => REPORT.every((check) => check(report));
34
52
  export let exports = (report, targets) => {
35
53
  if (!compiled(report)) fail("report: expected a compiled report from a quario instance");
36
54
  if (!Array.isArray(targets)) fail("targets: expected an array of render targets");
55
+ targets.forEach(named);
56
+ };
57
+
58
+ /** What a rejected entry carried, in the message's words. */
59
+ /** @type {(target: any) => string} */
60
+ let carried = (target) =>
61
+ target?.name === undefined ? "a value with no name" : JSON.stringify(target.name);
62
+
63
+ /** One entry of `targets`, named on its index when the viewer would drop it. */
64
+ /** @type {(target: any, i: number) => void} */
65
+ let named = (target, i) => {
66
+ if (ACCEPTED.includes(target?.name)) return;
67
+ fail("targets[" + i + "]: expected a target named one of " + NAMES + ", got " + carried(target));
37
68
  };
38
69
 
39
70
  /**
@@ -48,7 +79,7 @@ export let geometry = (page) => {
48
79
  try {
49
80
  return pageBox(page, "page");
50
81
  } catch (error) {
51
- return fail(/** @type {Error} */ (error).message);
82
+ return fail(/** @type {Error} */ (error));
52
83
  }
53
84
  };
54
85
 
@@ -63,7 +94,7 @@ export let faces = (fonts) => {
63
94
  try {
64
95
  checkFonts(fonts, "fonts");
65
96
  } catch (error) {
66
- fail(/** @type {Error} */ (error).message);
97
+ fail(/** @type {Error} */ (error));
67
98
  }
68
99
  };
69
100
 
@@ -104,6 +135,19 @@ export let name = (filename) => {
104
135
  return base;
105
136
  };
106
137
 
138
+ /**
139
+ * Whether the outline module is on, from the `outline` property, off by
140
+ * default. A flag, so anything but a boolean or nothing is a mistake.
141
+ *
142
+ * @param {any} outline
143
+ * @returns {boolean}
144
+ */
145
+ export let outlined = (outline) => {
146
+ if (outline !== undefined && typeof outline !== "boolean")
147
+ fail("outline: expected true or false");
148
+ return outline === true;
149
+ };
150
+
107
151
  /** The schemes the property accepts. Two are their own CSS value; `auto` is
108
152
  * the one that is not, so it is the only wording written here. */
109
153
  let SCHEMES = ["light", "dark", "auto"];
package/lib/chrome.js CHANGED
@@ -66,13 +66,13 @@ export let CHROME = css`
66
66
  font: 13px/1.4 system-ui, sans-serif;
67
67
  }
68
68
 
69
- /* Everything below the bar. It exists to be the box the error panel is
70
- positioned against: anchored to the viewer instead, the panel would cover
71
- the bar's own controls. */
69
+ /* Everything below the bar: the outline panel when it is open, then the
70
+ stage. It exists to be the box the error panel is positioned against:
71
+ anchored to the viewer instead, the panel would cover the bar's own
72
+ controls. */
72
73
  .qv-body {
73
74
  position: relative;
74
75
  display: flex;
75
- flex-direction: column;
76
76
  flex: 1;
77
77
  min-height: 0;
78
78
  }
@@ -148,6 +148,10 @@ export let CHROME = css`
148
148
  margin-right: auto;
149
149
  }
150
150
 
151
+ .qv-outline-toggle {
152
+ display: flex;
153
+ }
154
+
151
155
  .qv-exports {
152
156
  display: flex;
153
157
  gap: 2px;
package/lib/icons.js CHANGED
@@ -21,5 +21,8 @@ export let check = icon(svg`<path d="M20 6 9 17l-5-5"/>`);
21
21
  export let download = icon(
22
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
23
  );
24
+ export let listTree = icon(
25
+ svg`<path d="M8 5h13"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="M3 10a2 2 0 0 0 2 2h3"/><path d="M3 5v12a2 2 0 0 0 2 2h3"/>`,
26
+ );
24
27
  export let search = icon(svg`<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>`);
25
28
  export let x = icon(svg`<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`);
package/lib/index.d.ts CHANGED
@@ -97,6 +97,14 @@ export class QuarioViewer extends LitElement {
97
97
  * follows the OS via CSS `color-scheme`. The sheet stays white.
98
98
  */
99
99
  colorScheme: "light" | "dark" | "auto" | undefined;
100
+ /**
101
+ * Whether the outline module is on. Off by default. On, the bar carries a
102
+ * toggle and a panel beside the sheet lists the report's group instances,
103
+ * nested by depth, in document order; a row scrolls to where that instance
104
+ * begins. The list is the one the PDF target writes as bookmarks, so
105
+ * nothing in the report declares it, and a hollow instance is not in it.
106
+ */
107
+ outline: boolean | undefined;
100
108
  /**
101
109
  * The newest render settling: `true` when it landed on the sheet with the
102
110
  * pages on screen finished trying to paint, `false` when it failed or there
package/lib/index.js CHANGED
@@ -28,8 +28,10 @@ import { Landing, failures, options } from "@quario/landing";
28
28
  import { layout } from "@quario/layout";
29
29
  import { BUTTON } from "./button.js";
30
30
  import { CHROME, progress } from "./chrome.js";
31
- import { exports, faces, geometry, level, name, scheme } from "./check.js";
31
+ import { LINKS } from "./links.js";
32
+ import { exports, faces, geometry, level, name, outlined, scheme } from "./check.js";
32
33
  import { MENU, zoomMenu } from "./menu.js";
34
+ import { OUTLINE, outlinePanel, outlineToggle } from "./outline.js";
33
35
  import { PANEL, label, panel } from "./panel.js";
34
36
  import { SURFACE, stage } from "./stage.js";
35
37
  import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
@@ -60,7 +62,7 @@ export class QuarioViewer extends LitElement {
60
62
  // CHROME declares the default palette every other sheet paints from, so it
61
63
  // has to be in this list -- but anywhere in it: a custom property resolves
62
64
  // down the inherited chain, not by stylesheet order.
63
- static styles = [CHROME, BUTTON, MENU, SURFACE, PANEL];
65
+ static styles = [CHROME, BUTTON, MENU, OUTLINE, SURFACE, LINKS, PANEL];
64
66
 
65
67
  // Properties only, no attributes: `report`, `targets`, `data` and `page`
66
68
  // are values no attribute could carry, and one door beats two.
@@ -73,6 +75,7 @@ export class QuarioViewer extends LitElement {
73
75
  fonts: { attribute: false },
74
76
  filename: { attribute: false },
75
77
  colorScheme: { attribute: false },
78
+ outline: { attribute: false },
76
79
  };
77
80
 
78
81
  // The stage is built once and never rebuilt: it is the one imperative thing
@@ -101,17 +104,15 @@ export class QuarioViewer extends LitElement {
101
104
  #reapply = false;
102
105
  /** @type {Set<string>} The exports in flight; their buttons disable. */
103
106
  #exporting = new Set();
107
+ /** Whether the outline module is on, committed from the property. */
108
+ #outlined = false;
109
+ /** Whether the reader has the panel open. The reader's, so a property
110
+ * write does not close what they opened. */
111
+ #outlineOpen = true;
112
+ /** @type {readonly any[]} The newest landed list's marks: the outline. */
113
+ #marks = [];
104
114
  /** @type {(() => void) | undefined} */
105
115
  #unwatch;
106
- /**
107
- * The newest swap's paint, which `renderComplete` waits behind. Bare, with
108
- * no catch of its own: the stage settles a page it cannot draw rather than
109
- * rejecting, so the chain below is reached whatever the pixels did.
110
- *
111
- * @type {Promise<void>}
112
- */
113
- #painting = Promise.resolve();
114
-
115
116
  // The whole async pipeline: keyed on the render properties, re-run when one
116
117
  // changes — `page` and `fonts` among them, since either moves where the
117
118
  // pages break. Side effects live only in the callbacks below, which the
@@ -135,24 +136,33 @@ export class QuarioViewer extends LitElement {
135
136
  return { list, fonts };
136
137
  },
137
138
  onComplete: (result) => {
138
- if (result === null || !this.isConnected) return;
139
+ if (result === null || !this.isConnected) return this.#landing.nothing();
139
140
  // The swap sizes every page before it settles, so the reader's place
140
141
  // is held; the paint it awaits is what `rendered` waits for.
141
142
  // The boundary takes the paint rather than a promise to report back
142
143
  // with: it reads which run this is here, while this run is still the
143
- // newest one.
144
- this.#painting = this.#landing.land(this.#stage.swap(result.list, result.fonts)).then(() => {
145
- // A render that landed on the sheet takes the panel down: the panel
146
- // says what is wrong with what the reader is looking at, and this is
147
- // the moment that stops being true. A successful export is not that
148
- // moment.
149
- this.#failures.clear();
150
- // After the paint, so the update the task queued has already run.
151
- this.requestUpdate();
152
- this.dispatchEvent(new CustomEvent("rendered"));
153
- });
144
+ // newest one. The work after the paint rides inside it, so the run
145
+ // ends and `renderComplete` settles — once `rendered` has fired.
146
+ // Bare, with no catch: the stage settles a page it cannot draw rather
147
+ // than rejecting, so the chain is reached whatever the pixels did.
148
+ this.#marks = result.list.marks;
149
+ // `void`: the boundary owns this promise now, and a caller awaiting the
150
+ // paint asks `renderComplete` rather than holding a field of its own.
151
+ void this.#landing.land(
152
+ this.#stage.swap(result.list, result.fonts).then(() => {
153
+ // A render that landed on the sheet takes the panel down: the panel
154
+ // says what is wrong with what the reader is looking at, and this is
155
+ // the moment that stops being true. A successful export is not that
156
+ // moment.
157
+ this.#failures.clear();
158
+ // After the paint, so the update the task queued has already run.
159
+ this.requestUpdate();
160
+ this.dispatchEvent(new CustomEvent("rendered"));
161
+ }),
162
+ );
154
163
  },
155
164
  onError: (error) => {
165
+ this.#landing.fail();
156
166
  if (!this.isConnected) return;
157
167
  this.#announce(error, this.#kindOf(error));
158
168
  },
@@ -179,6 +189,8 @@ export class QuarioViewer extends LitElement {
179
189
  this.filename = undefined;
180
190
  /** @type {"light" | "dark" | "auto" | undefined} */
181
191
  this.colorScheme = undefined;
192
+ /** @type {boolean | undefined} */
193
+ this.outline = undefined;
182
194
  }
183
195
 
184
196
  /**
@@ -188,19 +200,17 @@ export class QuarioViewer extends LitElement {
188
200
  * render still landed, so `true` promises the sheet is done changing, not
189
201
  * that every page carries pixels. It never rejects — a failed
190
202
  * render is handled, on the panel and through the error event — and like
191
- * every outcome here it answers for the newest render only.
203
+ * every outcome here it answers for the newest render only: the one that
204
+ * is newest when it settles, so a run starting behind it is the one it
205
+ * then waits for.
206
+ *
207
+ * After `updateComplete`, because a property written just before the ask
208
+ * starts its run in the update this waits for.
192
209
  *
193
210
  * @returns {Promise<boolean>}
194
211
  */
195
212
  get renderComplete() {
196
- return this.updateComplete.then(() =>
197
- this.#task.status === TaskStatus.INITIAL
198
- ? this.#landing.landed
199
- : this.#task.taskComplete.then(
200
- () => this.#painting.then(() => this.#landing.landed),
201
- () => false,
202
- ),
203
- );
213
+ return this.updateComplete.then(() => this.#landing.complete);
204
214
  }
205
215
 
206
216
  /** @param {Map<string, unknown>} changed */
@@ -226,6 +236,7 @@ export class QuarioViewer extends LitElement {
226
236
  <div class="qv-viewer" aria-busy=${String(busy)}>
227
237
  <div class="qv-bar">
228
238
  ${progress(busy)}
239
+ ${this.#outlineControl()}
229
240
  ${zoomMenu({
230
241
  mode: this.#mode,
231
242
  percent: Math.round(this.#stage.percent()),
@@ -238,6 +249,7 @@ export class QuarioViewer extends LitElement {
238
249
  </div>
239
250
  <div class="qv-body">
240
251
  ${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
252
+ ${this.#outlineAside()}
241
253
  ${this.#stage.element}
242
254
  </div>
243
255
  </div>
@@ -341,6 +353,15 @@ export class QuarioViewer extends LitElement {
341
353
  let used = scheme(this.colorScheme);
342
354
  if (gate("colorScheme")) this.style.colorScheme = used;
343
355
  },
356
+ // The panel opens with the module: turning it on is what a reader would
357
+ // do next, and the toggle is there to close it.
358
+ (gate) => {
359
+ let on = outlined(this.outline);
360
+ if (gate("outline")) {
361
+ this.#outlined = on;
362
+ this.#outlineOpen = true;
363
+ }
364
+ },
344
365
  ]);
345
366
  if (moved) this.#landing.epoch++;
346
367
  }
@@ -404,6 +425,29 @@ export class QuarioViewer extends LitElement {
404
425
  this.requestUpdate();
405
426
  }
406
427
 
428
+ /** The bar's outline toggle, while the module is on. */
429
+ #outlineControl() {
430
+ if (!this.#outlined) return "";
431
+ return outlineToggle({ open: this.#outlineOpen, toggle: () => this.#toggleOutline() });
432
+ }
433
+
434
+ /** The outline panel, while the module is on and the reader has it open. */
435
+ #outlineAside() {
436
+ if (!this.#outlined || !this.#outlineOpen) return "";
437
+ return outlinePanel({
438
+ marks: this.#marks,
439
+ go: (mark) => this.#stage.reveal(mark.page, mark.y),
440
+ });
441
+ }
442
+
443
+ /** Open or close the outline panel. Chrome moves; nothing re-renders the
444
+ * report, and a fitted sheet follows its narrower or wider box through the
445
+ * observer `watch()` started. */
446
+ #toggleOutline() {
447
+ this.#outlineOpen = !this.#outlineOpen;
448
+ this.requestUpdate();
449
+ }
450
+
407
451
  /**
408
452
  * Report one failure, wherever it came from: the panel for the reader, the
409
453
  * event for the host. Failures that reach neither — a superseded render's,
package/lib/links.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The links on the sheet: one focusable region per linked run, over the very
3
+ * rectangle the painter drew it in.
4
+ *
5
+ * `@quario/layout` writes one text op per run, carrying the destination the
6
+ * engine admitted beside the `x`, `y`, `w` and `h` it drew at (`docs/adr/0085`),
7
+ * so the geometry is the list's own and nothing here re-measures. A run that
8
+ * wraps across two lines is two ops, which is two regions — the correct
9
+ * rendering rather than a degradation, as it is in the PDF.
10
+ *
11
+ * **A region is an element, because a painted one is not reachable.** ADR 0085
12
+ * makes focusability and Enter activation part of done: a link a pointer can
13
+ * reach and a keyboard cannot is an accessibility defect shipped as a feature.
14
+ * An `<a>` and a `<button>` carry both from the platform, so this module owns
15
+ * where they sit and nothing about how they are worked.
16
+ *
17
+ * The two destinations take the two elements. A URL leaves the page, which is
18
+ * what an anchor is for, and it opens in a tab of its own so a reader reading a
19
+ * report never loses it. An `href` naming a group's `label` moves the sheet,
20
+ * which is what a button is for — the same step the outline panel takes, and
21
+ * `reveal` is the one that takes it.
22
+ *
23
+ * **A destination the report does not hold is no region at all**, as it is in
24
+ * the PDF: a surface cannot invent a place, and a dead link is worse than plain
25
+ * text. The first instance wins a repeated label, which is what `SCHEMA.md` asks
26
+ * an author to declare labels that differ for.
27
+ *
28
+ * This is the viewer's own, and it is **not** the editor's hit box (ADR 0061,
29
+ * ADR 0085): it carries a destination and no path, and a link in the editor's
30
+ * preview stays deliberately unclickable, because the author is editing the
31
+ * document rather than reading it.
32
+ *
33
+ * The sheet carries elements only in the [reach](../../../CONTEXT.md#reach)
34
+ * (ADR 0043), and so does this: the regions are rebuilt when the band of pages
35
+ * under the reader moves, or when the scale does. Nothing here is markup — the
36
+ * text goes in as a property and the destination as an attribute — so the
37
+ * viewer still has no markup edge.
38
+ */
39
+
40
+ import { css } from "lit";
41
+
42
+ /** How far either side of the view the regions reach, in pages. The editor's
43
+ * overlay uses the same one page: enough that a scroll landing between two
44
+ * frames never shows a linked run with nothing over it. */
45
+ let PAD = 1;
46
+
47
+ export let LINKS = css`
48
+ /* Above the page canvases, which are appended after this layer on every
49
+ swap. Both are positioned, so one line settles the order. */
50
+ .qv-links {
51
+ position: absolute;
52
+ inset: 0;
53
+ z-index: 1;
54
+ }
55
+
56
+ /* A region shows nothing: the run under it is already painted. What it
57
+ carries is the pointer, the focus ring and the platform's activation. */
58
+ .qv-link {
59
+ position: absolute;
60
+ display: block;
61
+ padding: 0;
62
+ border: none;
63
+ background: transparent;
64
+ cursor: pointer;
65
+ }
66
+
67
+ .qv-link:focus-visible {
68
+ outline: 2px solid var(--_focus);
69
+ outline-offset: 1px;
70
+ }
71
+ `;
72
+
73
+ /**
74
+ * @typedef {{ title: string, page: number, x: number, y: number }} Mark
75
+ * @typedef {{ op: any, page: number }} Linked A linked text op, and its page.
76
+ */
77
+
78
+ /**
79
+ * The linked runs on pages `first` through `last`, in document order. Scans
80
+ * those pages rather than the document, because this is asked on every swap and
81
+ * every scroll and the answer is bounded by the view.
82
+ *
83
+ * @param {any} list A layout list.
84
+ * @param {number} first
85
+ * @param {number} last
86
+ * @returns {Linked[]}
87
+ */
88
+ export let linksOn = (list, first, last) => {
89
+ /** @type {Linked[]} */
90
+ let out = [];
91
+ for (let page = first; page <= last; page++)
92
+ for (let op of list.pages[page].ops) if (op.kind === "text" && op.href) out.push({ op, page });
93
+ return out;
94
+ };
95
+
96
+ /**
97
+ * The document's named destinations, by label. The first instance wins a
98
+ * repeated one, as a reader scrolling to the first match would — the rule the
99
+ * PDF target's annotations follow, and the one `SCHEMA.md` states.
100
+ *
101
+ * @param {readonly Mark[]} marks The list's own outline entries.
102
+ * @returns {Map<string, Mark>}
103
+ */
104
+ export let destinations = (marks) => {
105
+ /** @type {Map<string, Mark>} */
106
+ let byLabel = new Map();
107
+ for (let mark of marks) if (!byLabel.has(mark.title)) byLabel.set(mark.title, mark);
108
+ return byLabel;
109
+ };
110
+
111
+ /**
112
+ * Where one `href` goes: out of the document, to a place in it, or nowhere.
113
+ *
114
+ * @param {string} href The destination the engine admitted.
115
+ * @param {Map<string, Mark>} byLabel
116
+ * @returns {{ url: string, mark: null } | { url: null, mark: Mark } | null}
117
+ */
118
+ export let destination = (href, byLabel) => {
119
+ if (!href.startsWith("#")) return { url: href, mark: null };
120
+ let mark = byLabel.get(href.slice(1));
121
+ return mark ? { url: null, mark } : null;
122
+ };
123
+
124
+ /**
125
+ * The layer of regions over one sheet.
126
+ *
127
+ * @param {{ sheet: HTMLElement, paged: any, ratio: () => number,
128
+ * view: () => { top: number, bottom: number },
129
+ * reveal: (page: number, y: number) => void }} of What the stage owns: the
130
+ * element the regions hang in, the sheet that places its pages, the scale on
131
+ * screen, the band the reader is looking at, and the step a named destination
132
+ * takes.
133
+ */
134
+ export let overlay = ({ sheet, paged, ratio, view, reveal }) => {
135
+ let layer = document.createElement("div");
136
+ layer.className = "qv-links";
137
+ sheet.append(layer);
138
+
139
+ /** @type {any} */
140
+ let list = null;
141
+ /** @type {Map<string, Mark>} */
142
+ let byLabel = new Map();
143
+ /** What is standing: the band it was built for and the scale it was placed
144
+ * at. A scroll inside one band and a repaint at one scale both cost the
145
+ * compare and nothing else. */
146
+ let standing = "";
147
+
148
+ /** @type {(el: HTMLElement, op: any, page: number) => void} */
149
+ let place = (el, op, page) => {
150
+ let px = ratio();
151
+ el.style.left = op.x * px + "px";
152
+ el.style.top = paged.topOf(page) + op.y * px + "px";
153
+ el.style.width = op.w * px + "px";
154
+ el.style.height = op.h * px + "px";
155
+ };
156
+
157
+ /** One region, or nothing where the destination is not in the document. The
158
+ * run's text is the region's name: the words are painted on the canvas,
159
+ * which is nothing but an image to a screen reader. */
160
+ /** @type {(found: Linked) => HTMLElement | null} */
161
+ let region = ({ op, page }) => {
162
+ let where = destination(op.href, byLabel);
163
+ if (!where) return null;
164
+ let el = where.url === null ? step(where.mark) : anchor(where.url);
165
+ el.className = "qv-link";
166
+ el.setAttribute("aria-label", op.text);
167
+ place(el, op, page);
168
+ return el;
169
+ };
170
+
171
+ /** @type {(url: string) => HTMLElement} */
172
+ let anchor = (url) => {
173
+ let el = document.createElement("a");
174
+ el.href = url;
175
+ el.target = "_blank";
176
+ el.rel = "noopener noreferrer";
177
+ return el;
178
+ };
179
+
180
+ /** @type {(mark: Mark) => HTMLElement} */
181
+ let step = (mark) => {
182
+ let el = document.createElement("button");
183
+ el.type = "button";
184
+ el.addEventListener("click", () => reveal(mark.page, mark.y));
185
+ return el;
186
+ };
187
+
188
+ let update = () => {
189
+ let band = view();
190
+ let span = paged.pagesIn(band.top, band.bottom, PAD);
191
+ // Nothing on the sheet has nothing to lay a region over. The resize
192
+ // observer the stage starts reports once as it begins watching, which is
193
+ // before any list has arrived.
194
+ if (!span) return;
195
+ let wanted = span.first + ":" + span.last + ":" + ratio();
196
+ if (wanted === standing) return;
197
+ standing = wanted;
198
+ layer.replaceChildren();
199
+ for (let found of linksOn(list, span.first, span.last)) {
200
+ let el = region(found);
201
+ if (el) layer.append(el);
202
+ }
203
+ };
204
+
205
+ return {
206
+ /** Take the list the sheet is now showing, and build the regions under the
207
+ * reader. A list with nothing to show leaves an empty layer. */
208
+ /** @type {(next: any) => void} */
209
+ swap(next) {
210
+ list = next;
211
+ byLabel = destinations(next.marks);
212
+ // The band and the scale may be the very ones the last list stood at,
213
+ // so the key is dropped rather than compared: what changed is the list.
214
+ standing = "";
215
+ layer.replaceChildren();
216
+ update();
217
+ },
218
+ /** Rebuild where the band or the scale has moved, and nowhere else. */
219
+ update,
220
+ };
221
+ };
package/lib/menu.js CHANGED
@@ -74,14 +74,12 @@ export let MENU = css`
74
74
  cursor: pointer;
75
75
  }
76
76
 
77
- .qv-menuitem:hover {
78
- background: var(--_hover);
79
- color: var(--_icon-active);
80
- }
81
-
82
- /* Inset, so the ring stays inside the menu's own padding. */
83
- .qv-menuitem:focus-visible {
84
- outline: 2px solid var(--_focus);
77
+ /* A row's hover, active and focus treatment is \`button.js\`'s, shared with
78
+ every other <button> the viewer draws. The one difference is the ring's
79
+ side: inset, so it stays inside the menu's own padding. Narrower on
80
+ specificity rather than on where this sheet sits in \`static styles\` —
81
+ reordering that array must not silently put the ring back outside. */
82
+ .qv-viewer .qv-menuitem:focus-visible {
85
83
  outline-offset: -2px;
86
84
  }
87
85
 
package/lib/outline.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The outline panel: the report's group tree as a list of rows beside the
3
+ * sheet, each one a step to where that instance begins. The tree is the
4
+ * layout list's own `marks` — the entries the PDF target writes as bookmarks
5
+ * — so the panel derives it exactly as that target does, and an author
6
+ * declares nothing for it (`docs/adr/0044` for the icon, ADR 0047 for the
7
+ * chrome). A hollow instance is not a mark, so it is not a row.
8
+ *
9
+ * Optional: a host turns it on with the `outline` property, and the bar then
10
+ * carries one toggle. The reader opens and closes the panel; the property
11
+ * says only whether the module is there at all.
12
+ *
13
+ * A title goes in as a template value, never as markup — a group's key is
14
+ * report data — so this module has no markup edge to escape at (hard
15
+ * constraint 4).
16
+ */
17
+ import { css, html } from "lit";
18
+ import { button } from "./button.js";
19
+ import { listTree } from "./icons.js";
20
+
21
+ export let OUTLINE = css`
22
+ .qv-outline {
23
+ flex: none;
24
+ width: 220px;
25
+ min-width: 0;
26
+ overflow: auto;
27
+ padding: 6px;
28
+ background: var(--_bar);
29
+ border-right: 1px solid var(--_border);
30
+ }
31
+
32
+ .qv-outline ol {
33
+ margin: 0;
34
+ padding: 0;
35
+ list-style: none;
36
+ }
37
+
38
+ /* Each level steps in by one indent, written on the list rather than the
39
+ row, so a row's own box stays the full width the hover fills. */
40
+ .qv-outline ol ol {
41
+ padding-left: 12px;
42
+ }
43
+
44
+ .qv-outline-row {
45
+ display: block;
46
+ width: 100%;
47
+ padding: 4px 8px;
48
+ border: none;
49
+ border-radius: 5px;
50
+ background: transparent;
51
+ color: inherit;
52
+ font: inherit;
53
+ text-align: left;
54
+ white-space: nowrap;
55
+ overflow: hidden;
56
+ text-overflow: ellipsis;
57
+ cursor: pointer;
58
+ }
59
+
60
+ .qv-outline-empty {
61
+ margin: 0;
62
+ padding: 4px 8px;
63
+ color: var(--_icon);
64
+ }
65
+ `;
66
+
67
+ /**
68
+ * @typedef {{ title: string, depth: number, page: number, x: number, y: number }} Mark
69
+ * @typedef {{ mark: Mark, children: Node[] }} Node
70
+ */
71
+
72
+ /**
73
+ * The marks as a tree: a mark is a child of the nearest mark before it at
74
+ * the depth above. Depth is the group's nesting, so the walk that emitted
75
+ * the marks in document order has already put every child after its parent.
76
+ *
77
+ * @param {readonly Mark[]} marks
78
+ * @returns {Node[]}
79
+ */
80
+ export let tree = (marks) => {
81
+ /** @type {Node[]} */
82
+ let roots = [];
83
+ /** @type {Node[]} */
84
+ let open = [];
85
+ for (let mark of marks) {
86
+ /** @type {Node} */
87
+ let node = { mark, children: [] };
88
+ open.length = mark.depth;
89
+ (open[mark.depth - 1]?.children ?? roots).push(node);
90
+ open.push(node);
91
+ }
92
+ return roots;
93
+ };
94
+
95
+ /** @type {(event: HTMLElement) => HTMLElement[]} */
96
+ let rows = (panel) => /** @type {HTMLElement[]} */ ([...panel.querySelectorAll(".qv-outline-row")]);
97
+
98
+ /** @type {Record<string, (list: HTMLElement[], at: number) => HTMLElement>} */
99
+ let MOVE = {
100
+ ArrowDown: (list, at) => list[Math.min(at + 1, list.length - 1)],
101
+ ArrowUp: (list, at) => list[Math.max(at - 1, 0)],
102
+ Home: (list) => list[0],
103
+ End: (list) => list[list.length - 1],
104
+ };
105
+
106
+ /**
107
+ * Arrow keys walk the rows in document order, whatever their depth; Tab
108
+ * leaves the panel as it left the bar. The platform owns activation.
109
+ *
110
+ * @param {KeyboardEvent} event
111
+ */
112
+ let navigate = (event) => {
113
+ let move = MOVE[event.key];
114
+ if (!move) return;
115
+ event.preventDefault();
116
+ let list = rows(/** @type {HTMLElement} */ (event.currentTarget));
117
+ let at = list.indexOf(/** @type {HTMLElement} */ (event.target));
118
+ move(list, at).focus();
119
+ };
120
+
121
+ /**
122
+ * @param {Node[]} nodes
123
+ * @param {(mark: Mark) => void} go
124
+ * @returns {import('lit').TemplateResult}
125
+ */
126
+ let branch = (nodes, go) => html`
127
+ <ol>
128
+ ${nodes.map(
129
+ (node) => html`
130
+ <li>
131
+ <button type="button" class="qv-outline-row" @click=${() => go(node.mark)}>
132
+ ${node.mark.title}
133
+ </button>
134
+ ${node.children.length ? branch(node.children, go) : ""}
135
+ </li>
136
+ `,
137
+ )}
138
+ </ol>
139
+ `;
140
+
141
+ /**
142
+ * The bar's toggle, drawn only while the module is on.
143
+ *
144
+ * @param {{ open: boolean, toggle: () => void }} state
145
+ */
146
+ export let outlineToggle = ({ open, toggle }) => html`
147
+ <div class="qv-outline-toggle">
148
+ ${button({ title: open ? "Hide outline" : "Show outline", click: toggle, content: listTree })}
149
+ </div>
150
+ `;
151
+
152
+ /**
153
+ * The panel itself. A report with no groups has no tree, and the panel says
154
+ * so rather than standing empty.
155
+ *
156
+ * @param {{ marks: readonly Mark[], go: (mark: Mark) => void }} state
157
+ */
158
+ export let outlinePanel = ({ marks, go }) => html`
159
+ <nav class="qv-outline" aria-label="Outline" @keydown=${navigate}>
160
+ ${
161
+ marks.length
162
+ ? branch(tree(marks), go)
163
+ : html`<p class="qv-outline-empty">This report has no groups.</p>`
164
+ }
165
+ </nav>
166
+ `;
package/lib/stage.js CHANGED
@@ -37,6 +37,12 @@
37
37
  * sees swapped under it — accepted, because the alternative is a second
38
38
  * element per page on a sheet ADR 0043 exists to keep cheap.
39
39
  *
40
+ * The linked runs get a **focusable region each**, in `links.js`, hung on the
41
+ * sheet over the rectangles the painter drew them in and rebuilt as the band
42
+ * under the reader or the scale moves. They follow the reach for the reason the
43
+ * pages do, and this file owns the two numbers they need: the band, and the
44
+ * scale on screen.
45
+ *
40
46
  * What the stage does not decide is which percentage to show: `fit()`
41
47
  * measures what would make one page span the width available, and `zoom.js`
42
48
  * owns the policy over that answer. `percent()` reports what is on screen, so
@@ -46,6 +52,7 @@
46
52
  import { css } from "lit";
47
53
  import { GUTTER, sheet } from "@quario/landing";
48
54
  import { PX_PER_POINT } from "@quario/layout";
55
+ import { overlay } from "./links.js";
49
56
 
50
57
  // `GUTTER` comes from `@quario/landing`, which walks the reach over it: the CSS
51
58
  // below and the sheet's own arithmetic have to agree about where a page sits,
@@ -101,6 +108,7 @@ export let SURFACE = css`
101
108
  * percent: () => number, scale: (percent: number) => void,
102
109
  * resize: (geometry: PageBox) => void,
103
110
  * swap: (list: any, fonts: any) => Promise<void>,
111
+ * reveal: (page: number, y: number) => void,
104
112
  * watch: (changed: () => void) => () => void }} Stage
105
113
  */
106
114
 
@@ -140,7 +148,42 @@ export let stage = () => {
140
148
  * the elements above stay this file's, because the CSS is the viewer's own
141
149
  * public surface. */
142
150
  let paged = sheet(scroll, sheetEl, ratio);
143
- return {
151
+
152
+ /** The band of the sheet the reader is looking at, in the sheet's own CSS
153
+ * pixels: the frame a page is placed in. The sheet starts one gutter down
154
+ * the scroll extent, which is the same offset `reveal` scrolls through. */
155
+ let view = () => {
156
+ let top = scroll.scrollTop - GUTTER;
157
+ return { top, bottom: top + scroll.clientHeight };
158
+ };
159
+
160
+ /** The focusable regions over the linked runs. The viewer's own and not the
161
+ * editor's, which is the whole of ADR 0085's "a link in the editor's
162
+ * preview is not clickable". */
163
+ let links = overlay({
164
+ sheet: sheetEl,
165
+ paged,
166
+ ratio,
167
+ view,
168
+ reveal: (page, y) => api.reveal(page, y),
169
+ });
170
+
171
+ /** One region pass a frame, however many scrolls ask — the same coalescing
172
+ * the sheet's own reach pass uses, and a separate listener because the two
173
+ * answer different questions about the same scroll. */
174
+ let following = false;
175
+ let follow = () => {
176
+ if (following) return;
177
+ following = true;
178
+ requestAnimationFrame(() => {
179
+ following = false;
180
+ links.update();
181
+ });
182
+ };
183
+ scroll.addEventListener("scroll", follow);
184
+
185
+ /** @type {Stage} */
186
+ let api = {
144
187
  element: scroll,
145
188
 
146
189
  /**
@@ -174,7 +217,7 @@ export let stage = () => {
174
217
  * Only the reach is repainted, and only where the scale actually changed.
175
218
  */
176
219
  scale: (percent) => {
177
- let held = paged.count() > 0 && {
220
+ let held = paged.width() !== null && {
178
221
  top: scroll.scrollTop,
179
222
  left: scroll.scrollLeft,
180
223
  height: scroll.clientHeight,
@@ -189,6 +232,7 @@ export let stage = () => {
189
232
  scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
190
233
  }
191
234
  void paged.repaint();
235
+ links.update();
192
236
  },
193
237
 
194
238
  /**
@@ -213,7 +257,7 @@ export let stage = () => {
213
257
  swap: async (next, faces) => {
214
258
  // Reading the offsets flushes layout, so only an actual reswap pays for
215
259
  // it: with no list standing there is nothing scrolled to preserve.
216
- let held = paged.count() > 0 && {
260
+ let held = paged.width() !== null && {
217
261
  top: scroll.scrollTop,
218
262
  left: scroll.scrollLeft,
219
263
  };
@@ -222,6 +266,24 @@ export let stage = () => {
222
266
  scroll.scrollTop = held.top;
223
267
  scroll.scrollLeft = held.left;
224
268
  }
269
+ // After the offsets go back, so the band the regions are built for is
270
+ // the one the reader is left looking at.
271
+ links.swap(next);
272
+ },
273
+
274
+ /**
275
+ * Scroll so that a point on a page sits at the top of the view: the
276
+ * outline's step to where an instance begins. The offset is the sheet's
277
+ * own — `topOf()` is where the sheet places that page, and the point
278
+ * scales as the page does — so this restates no stacking (ADR 0070).
279
+ * Horizontal stays where the reader left it.
280
+ *
281
+ * @param {number} page
282
+ * @param {number} y In points, down that page.
283
+ */
284
+ reveal: (page, y) => {
285
+ scroll.scrollTop = GUTTER + paged.topOf(page) + y * ratio();
286
+ paged.follow();
225
287
  },
226
288
 
227
289
  /**
@@ -235,10 +297,12 @@ export let stage = () => {
235
297
  // A resize that changes the fit repaints through `changed()`; one
236
298
  // that does not still moved the viewport the reach is measured in.
237
299
  paged.follow();
300
+ follow();
238
301
  changed();
239
302
  });
240
303
  observer.observe(scroll);
241
304
  return () => observer.disconnect();
242
305
  },
243
306
  };
307
+ return api;
244
308
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/viewer",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Tiny, embeddable report viewer for quario. A custom element that pages on screen and exports what you hand it.",
5
5
  "keywords": [
6
6
  "csp",
@@ -40,19 +40,20 @@
40
40
  "access": "public"
41
41
  },
42
42
  "scripts": {
43
- "check": "npm run size && npm test && npm run test:browser",
43
+ "check": "npm run size && npm test",
44
+ "coverage:check": "c8 report --src lib/ --temp-directory=../../coverage/tmp --reporter=text --check-coverage --100",
44
45
  "size": "size-limit",
45
- "test": "npm run test:unit && npm run test:types",
46
+ "test": "npm run test:unit && npm run test:browser && npm run test:types && npm run coverage:check",
46
47
  "test:browser": "node test/browser/setup.js",
47
48
  "test:types": "tsc && attw --pack . --profile esm-only",
48
- "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
49
+ "test:unit": "c8 --clean=false --src lib/ --reporter=none --temp-directory=../../coverage/tmp node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
49
50
  "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
50
51
  "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
51
52
  },
52
53
  "dependencies": {
53
54
  "@lit/task": "^1.0.3",
54
- "@quario/landing": "^0.3.0",
55
- "@quario/layout": "^0.7.0",
55
+ "@quario/landing": "^0.3.1",
56
+ "@quario/layout": "^0.8.0",
56
57
  "lit": "^3.3.3"
57
58
  },
58
59
  "devDependencies": {
@@ -60,12 +61,12 @@
60
61
  "@cantoo/pdf-lib": "~2.9.1",
61
62
  "@size-limit/preset-small-lib": "^13.0.3",
62
63
  "esbuild": "^0.28.2",
63
- "quario": "^0.10.0",
64
+ "quario": "^0.11.0",
64
65
  "size-limit": "^13.0.3",
65
66
  "typescript": "^7.0.2"
66
67
  },
67
68
  "peerDependencies": {
68
- "quario": "^0.10.0"
69
+ "quario": "^0.11.0"
69
70
  },
70
71
  "size-limit": [
71
72
  {
@@ -77,7 +78,7 @@
77
78
  "lit",
78
79
  "@lit/task"
79
80
  ],
80
- "limit": "8.5 kB"
81
+ "limit": "9.5 kB"
81
82
  }
82
83
  ],
83
84
  "engines": {