@quario/viewer 0.3.0 → 0.5.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,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ ### Changed
13
+
14
+ - **Previewing with `fonts` now needs `fontkit`, not `@pdf-lib/fontkit`.**
15
+ Install `fontkit` instead. The viewer measures host TrueType faces through
16
+ `@quario/layout`, whose optional parser this is; the old package crashed on
17
+ any OpenType face needing a shaping state machine, so those faces now
18
+ preview where they used to throw.
19
+
20
+ - **The bar's controls are drawn icons.** The zoom trigger, the menu's check
21
+ and the error panel's dismiss are drawn from Lucide's set rather than by
22
+ hand, at one weight and one grid, and the dismiss no longer relies on the
23
+ host's font to draw a multiplication sign. They paint in `currentColor`, so
24
+ `--qv-icon` and `--qv-icon-active` still recolour them.
25
+
26
+ - **An export button names its format in words.** Each one is a download icon
27
+ with `PDF`, `XLSX` or `CSV` beside it, in place of the sheet drawing that
28
+ lettered the format inside itself. The buttons are wider; the accessible
29
+ name is unchanged.
30
+
31
+ ### Fixed
32
+
33
+ - **A zoom no longer leaves a page part-drawn.** Changing the zoom while a
34
+ page was still painting could leave that page carrying content at the old
35
+ scale on a canvas sized for the new one — the rest of it blank — and nothing
36
+ repainted it afterwards: scrolling away and back left it as it was. A page
37
+ whose paint is still in flight is now replaced rather than reused, so the
38
+ superseded paint has nowhere to land.
39
+
40
+ ## [0.4.0] - 2026-09-03
41
+
42
+ ### Added
43
+
44
+ - **`fonts`.** The font mapping a host passes to `pdf({ fonts })`, so the
45
+ preview measures and draws in the same TrueType faces the document embeds.
46
+
47
+ ### Changed
48
+
49
+ - **`renderComplete` and `rendered` settle once the pages on screen are
50
+ painted.** Both already answered for the newest render reaching the sheet;
51
+ what that means is now stated: the pages the reader can see carry their
52
+ pixels, and the pages further down the report do not hold the promise up.
53
+ A screenshot or a pixel-reading test taken at that moment sees what the
54
+ reader sees.
55
+
56
+ - **A split slot's box now fills the split's height.** A slot's background and
57
+ border stopped at that slot's own content and now take the split's height,
58
+ the way a table cell's take their row's. See the `@quario/layout` changelog
59
+ for the rule and for what it costs a report that relied on the short box.
60
+
61
+ - **The sheet has pages, and they break where the PDF's do.** The viewer
62
+ depends on `@quario/layout` and paints the same display list the pdf target
63
+ writes, one canvas per page, in the faces the document itself uses.
64
+ Zoom is a repaint, so text stays crisp at 200%; the menu's first row reads
65
+ "Fit page" and fit sizes one page to the width. Breaking:
66
+ `targets` is exports only — no `"html"` target is required or read — and a
67
+ `page` change re-lays the report out rather than resizing a sheet. The
68
+ unlicensed marking is painted per page from the list; the `--qv-mark`
69
+ token is gone with the DOM stamps.
70
+
71
+ ### Fixed
72
+
73
+ - **A long report no longer asks for a backing store per page.** The viewer
74
+ paints the pages near the viewport — the ones on screen and one screenful
75
+ either side — and keeps every other page sized but blank. A report of a
76
+ few hundred pages used to ask the browser for gigabytes of canvas; past
77
+ what it would grant, pages came up blank with nothing reported, which on a
78
+ HiDPI display took only a moderately long report. Every page still holds
79
+ its place, so the scrollbar and the scroll extent are the whole report from
80
+ the first frame, and a zoom step now repaints a handful of pages instead of
81
+ walking the document.
82
+
10
83
  ## [0.3.0] - 2026-09-02
11
84
 
12
85
  ### Changed
package/README.md CHANGED
@@ -1,24 +1,25 @@
1
1
  # @quario/viewer
2
2
 
3
3
  **The embeddable report viewer for [quario](https://www.npmjs.com/package/quario), as a custom
4
- element.** Write `<quario-viewer>`, assign it a compiled report, the targets to render with, and
5
- data; it displays the rendered report on a white sheet in its shadow root and offers export
6
- downloads for the exportable targets you passed.
4
+ element.** Write `<quario-viewer>`, assign it a compiled report, the export targets you want, and
5
+ data; it lays the report out on pages the same pages the PDF target writes — paints them on a
6
+ white sheet in its shadow root, and offers export downloads for the targets you passed.
7
7
 
8
- The viewer is not a render target and compiles nothing. You pass the configured targets you want;
9
- the viewer displays the `"html"` target's output and wires an export button to each exportable
10
- target in the list (`"pdf"`, `"xlsx"`, `"csv"`). The bar always carries the zoom control; with no
11
- exportable target it carries nothing else.
8
+ The viewer is not a render target and compiles nothing. The sheet is `@quario/layout`'s display
9
+ list, painted page by page; you pass the export targets you want, and the viewer wires a button
10
+ to each (`"pdf"`, `"xlsx"`, `"csv"`). The bar always carries the zoom control; with no exportable
11
+ target it carries nothing else.
12
12
 
13
13
  ## Install
14
14
 
15
15
  ```bash
16
- npm install quario @quario/html @quario/viewer
16
+ npm install quario @quario/viewer
17
17
  ```
18
18
 
19
19
  The engine is a peer; the viewer itself depends on no target package. Install the targets you want
20
- beside it (`@quario/pdf`, `@quario/xlsx`, `@quario/csv`) and pass them in. Its own runtime dependency
21
- is [Lit](https://lit.dev) (`lit` + `@lit/task`), plain ESM like everything else here. ESM-only, and
20
+ to export with (`@quario/pdf`, `@quario/xlsx`, `@quario/csv`) and pass them in. Its own runtime
21
+ dependencies are [Lit](https://lit.dev) (`lit` + `@lit/task`) and `@quario/layout`, the paged
22
+ layout it paints, plain ESM like everything else here. ESM-only, and
22
23
  browser-only by nature: the element needs a DOM. CSP-safe like the rest of quario. No
23
24
  string-to-code paths, chrome styled through constructed stylesheets, so your `style-src` never
24
25
  sees a style tag.
@@ -27,7 +28,6 @@ sees a style tag.
27
28
 
28
29
  ```js
29
30
  import { csv } from "@quario/csv";
30
- import { html } from "@quario/html";
31
31
  import { pdf } from "@quario/pdf";
32
32
  import { xlsx } from "@quario/xlsx";
33
33
  import { quario } from "quario";
@@ -35,14 +35,17 @@ import "@quario/viewer/register";
35
35
 
36
36
  const view = document.querySelector("quario-viewer");
37
37
  view.report = quario().report(schema, funcs);
38
- view.targets = [html(), pdf(), xlsx(), csv()];
38
+ view.targets = [pdf(), xlsx(), csv()];
39
39
  view.data = data;
40
40
  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`.
@@ -52,50 +55,51 @@ package to install. See [Using with React, Vue and Svelte](#using-with-react-vue
52
55
 
53
56
  ## Properties
54
57
 
55
- | Property | Takes | Default |
56
- | ------------- | -------------------------------------------- | ----------- |
57
- | `report` | A compiled report from a quario instance | - |
58
- | `targets` | Target objects; one named `"html"` required | - |
59
- | `data` | The render document | `undefined` |
60
- | `zoom` | `"fit"` or a percentage between 25 and 200 | `"fit"` |
61
- | `page` | Sheet geometry (the pdf target's vocabulary) | A4, 54pt |
62
- | `filename` | Export download name, without extension | `"report"` |
63
- | `colorScheme` | `"light"`, `"dark"`, or `"auto"` | `"light"` |
64
-
65
- Properties, not attributes: `report`, `targets`, `data` and `page` are values no attribute could
66
- carry. `targets` mirrors `report.render(target, data)`: the `"html"` target renders the sheet, and
67
- `"pdf"`/`"xlsx"`/`"csv"` targets become export buttons, in the order given. The quario instance (and
68
- with it the license and the registry) stays yours: the element takes the compiled report, never a
69
- schema.
58
+ | Property | Takes | Default |
59
+ | ------------- | ----------------------------------------------- | ----------- |
60
+ | `report` | A compiled report from a quario instance | - |
61
+ | `targets` | Export targets, possibly none | - |
62
+ | `data` | The render document | `undefined` |
63
+ | `zoom` | `"fit"` or a percentage between 25 and 200 | `"fit"` |
64
+ | `page` | Page geometry, as passed to `pdf({ page })` | A4, 54pt |
65
+ | `fonts` | The font mapping, as passed to `pdf({ fonts })` | none |
66
+ | `filename` | Export download name, without extension | `"report"` |
67
+ | `colorScheme` | `"light"`, `"dark"`, or `"auto"` | `"light"` |
68
+
69
+ Properties, not attributes: `report`, `targets`, `data`, `page` and `fonts` are values no attribute
70
+ could carry. `targets` mirrors `report.render(target, data)` for the exports: `"pdf"`/`"xlsx"`/`"csv"`
71
+ targets become export buttons, in the order given, and the sheet needs none of them. The quario
72
+ instance (and with it the license and the registry) stays yours: the element takes the compiled
73
+ report, never a schema.
70
74
 
71
75
  Assigning `data` (or any of the others) re-renders. Rapid successive writes render only the newest
72
76
  state: a slow render can never overwrite a newer one, and a superseded render fires no event.
73
77
  Assignments are compared by identity, so to re-render from the same object, assign a fresh one
74
78
  (`view.data = { ...data }`).
75
79
 
76
- `page` is the pdf target's vocabulary and defaults, and it is **purely visual**: it gives the white
77
- sheet the browser lays the report out on its width, its padding, and at least one page of height. The PDF target's own geometry lives
78
- in the host's `pdf({ page })` configuration. Pass the same values to both so preview and export
79
- agree.
80
+ `page` and `fonts` are the pdf target's own options: the viewer lays the report out on them, so
81
+ passing the same values to the element and to `pdf({ page, fonts })` is what makes the preview
82
+ page where the document pages, in the same faces. A `page` or `fonts` write re-lays the report
83
+ out.
80
84
 
81
85
  ## Events and `renderComplete`
82
86
 
83
87
  ```js
84
88
  view.addEventListener("rendered", () => {});
85
89
  view.addEventListener("error", ({ detail: { error, kind } }) => {});
86
- await view.renderComplete; // true when the newest render reached the sheet
90
+ await view.renderComplete; // true when the newest render landed on the sheet
87
91
  ```
88
92
 
89
- `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
90
94
  know about, with `detail.kind` naming which: `"mount-render"` until a render has ever landed,
91
95
  `"update-render"` after, `"export"` for a download that could not be produced. Both events are
92
96
  non-bubbling, like `<img>`'s. Listen on the element. (Because the event is named `error`, an
93
97
  inline `onerror` attribute on the element would fire too; `window.onerror` never sees it.)
94
98
 
95
- `renderComplete` awaits the newest render settling: `true` when it reached the sheet, `false` when
96
- it failed or there was nothing to render. It never rejects; failures arrive on the `error` event.
97
- Like every outcome here, it answers for the newest render only. A superseded render's failure is
98
- reported to no one.
99
+ `renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
100
+ on screen painted, `false` when it failed or there was nothing to render. It never rejects; failures
101
+ arrive on the `error` event. `rendered` fires at that same moment. Like every outcome here, it
102
+ answers for the newest render only. A superseded render's failure is reported to no one.
99
103
 
100
104
  Your own mistakes surface on the same channel: a report that is not compiled, a missing
101
105
  `"html"` target, or a malformed option property becomes a `TypeError` naming the property, on
@@ -200,54 +204,59 @@ listeners:
200
204
 
201
205
  ## Zoom
202
206
 
203
- The bar's magnifier opens the zoom menu: **Fit sheet** on its own, then 25%, 50%, 75%, 100%, 150%
204
- and 200%. A check marks the current mode — Fit sheet whenever the viewer is fitting, whatever
207
+ The bar's magnifier opens the zoom menu: **Fit page** on its own, then 25%, 50%, 75%, 100%, 150%
208
+ and 200%. A check marks the current mode — Fit page whenever the viewer is fitting, whatever
205
209
  percentage that came out at — and the percentage on screen is what the trigger is named after
206
210
  ("Zoom, 62%"), so nothing in the bar changes width as it moves. The `zoom` property picks the mode
207
211
  the viewer opens in: a percentage between 25 and 200 — continuous, not one of the menu's stops — or
208
212
  `"fit"`, the default. A percentage the menu does not offer leaves every row unchecked.
209
213
 
210
- Fit sizes the sheet to the viewer's width and **only ever shrinks**: given room to spare it stops at
214
+ Fit sizes one page to the viewer's width and **only ever shrinks**: given room to spare it stops at
211
215
  100%, so the report is shown at its true point size rather than blown up. It has no floor, so a
212
216
  narrow pane fits at whatever percentage that takes — below 25% the menu has no row to return to it,
213
- and **Fit sheet** is the only way back. A fitted viewer follows its own box, so a collapsing panel
217
+ and **Fit page** is the only way back. A fitted viewer follows its own box, so a collapsing panel
214
218
  or a resized window re-fits on its own.
215
219
 
216
- The preview **scales; it never reflows.** Zooming shrinks or enlarges the rendered sheet like a
217
- photograph. Line breaks, column widths and point sizes stay what they are at 100%, so the sheet
218
- stays the document the pdf target will page. Pass the same `page` values to preview and export so
219
- they agree.
220
+ The preview **scales; it never reflows.** Zooming repaints the pages larger or smaller, like a
221
+ photograph that stays sharp. Line breaks, column widths and point sizes stay what they are at
222
+ 100%, because the layout never changes under a zoom.
220
223
 
221
- ## What the preview is
222
-
223
- On-screen layout is the browser's: one continuous sheet, no pagination. The PDF export paginates
224
- independently in the pdf target. The preview approximates; the exports are exact.
224
+ Only the pages near the viewport are painted — the ones on screen and one screenful either side.
225
+ Every page keeps its size, so the scrollbar and the scroll extent are the whole report from the
226
+ start; a page further off is blank paper until you scroll to it, which is why a zoom step costs the
227
+ same on a thousand-page report as on a five-page one.
225
228
 
226
- The sheet is page-shaped: `page` gives it both its width and at least one page of height, so a
227
- report shorter than a page sits on paper the shape of the page you configured rather than on a band
228
- the height of its own content. It is a minimum, so a longer report keeps growing on the one sheet.
229
- Sizing a container to a viewer showing a short report therefore reserves a full page — set `zoom`
230
- to a percentage small enough where the box has to stay small. The sheet still draws no page
231
- boundaries: only the pdf target knows where pages fall.
232
-
233
- The sheet is the html target's fragment, so a report with image items shows them through that
234
- target's `data:` URIs. A host page with a Content Security Policy needs `img-src data:` for them
235
- to display, the same deployment requirement the fragment carries anywhere else.
229
+ ## What the preview is
236
230
 
237
- Rendering an unlicensed evaluation, rotated watermarks sit on the sheet one per page-sized band
238
- of the continuous preview and scroll and zoom with the report, the way the PDF export marks every
239
- page. The wording arrives inside the html target's output, on its `.q-unlicensed` badge; the viewer
240
- hides that element from sight (keeping it readable by assistive technology) and draws the stamps on
241
- the sheet itself. A licensed render carries neither.
231
+ The pages on screen are the pages the PDF export writes: both consume one layout, `@quario/layout`'s
232
+ display list, laid out on your `page` and `fonts`. A report shorter than a page is one page; a
233
+ longer one is as many as the layout breaks it into, stacked down the sheet. Sizing a container to a
234
+ viewer showing a short report therefore reserves a full page set `zoom` to a percentage small
235
+ enough where the box has to stay small.
236
+
237
+ Each page is a canvas, and text on it is drawn in the face the document will use. A TrueType
238
+ family you pass as `fonts` is registered from your own bytes and drawn as the browser shapes it —
239
+ the same shaping the PDF gets from the same file, so ligatures, joined scripts and accents look
240
+ here the way they will on paper. Text in the built-in families is drawn character by character at
241
+ the advances the layout measured, because the font a browser has for Helvetica, Times or Courier
242
+ only stands in for the one the PDF writes, and the correction is what makes a line fill the same
243
+ width and break in the same place the document does. Inside one run of a family you supply, a
244
+ browser may kern by a fraction more than the document; where lines break and pages end is the
245
+ layout's, and identical. Images are decoded from their bytes — no `data:` URIs, so a host page's
246
+ Content Security Policy needs no `img-src` grant for them.
247
+
248
+ Rendering an unlicensed evaluation, the marking is painted across every page, the way the PDF
249
+ export marks every page: it rides on the layout, so what you see is what the document carries. A
250
+ licensed render carries none.
242
251
 
243
252
  While a render is in flight a thin indeterminate bar sits on the toolbar's bottom edge, and the
244
253
  viewer reads `aria-busy="true"`. It reports that the viewer is working, not how far along. The
245
254
  engine streams events and cannot know how many are still coming.
246
255
 
247
256
  There is no Print button, because the sheet is the wrong thing to print. Your page's stylesheets do
248
- not cross into the shadow root, so `@media print` rules never reach the report; and above or below
249
- 100% the sheet is under a `transform: scale()`, so printing the page puts the viewer's chrome on
250
- paper at whatever zoom the reader happened to leave it. What a printer wants is the pdf target's
257
+ not cross into the shadow root, so `@media print` rules never reach the report; and the pages are
258
+ painted at whatever zoom the reader happened to leave them, so printing the page puts the viewer's
259
+ chrome on paper at that zoom. What a printer wants is the pdf target's
251
260
  document, the same bytes the PDF export hands over. A host that wants its own Print button owns
252
261
  two lines:
253
262
 
@@ -265,7 +274,7 @@ the export is, watermark and all.
265
274
 
266
275
  When a render or an export fails, the viewer says so on the **error panel**, a strip across the top
267
276
  of the sheet carrying a short label and the error's own message. It replaces rather than stacks, is
268
- dismissed by its own button, and is cleared by the next render that reaches the sheet. A successful
277
+ dismissed by its own button, and is cleared by the next render that lands on the sheet. A successful
269
278
  export leaves it up, because the panel describes what you are looking at and a download says
270
279
  nothing about that.
271
280
 
@@ -284,18 +293,18 @@ part of this: `q.report(schema)` raises those before the viewer is ever handed a
284
293
 
285
294
  `colorScheme` paints the **chrome** — backdrop, bar, controls, progress strip, error
286
295
  panel — and never the sheet. `"light"` (the default) and `"dark"` pin; `"auto"` follows
287
- the OS via CSS `color-scheme`. The sheet stays white, watermark included.
296
+ the OS via CSS `color-scheme`. The pages stay white, marking included.
288
297
 
289
298
  Chrome styles live on `--qv-*` custom properties under stable `qv-*` class names. A
290
299
  token set on the element (or an ancestor) always wins over the pin. The names are a
291
300
  reference seam, not a frozen vocabulary: backdrop and bar (`--qv-backdrop`, `--qv-bar`,
292
301
  `--qv-border`, `--qv-text`), controls (`--qv-icon`, `--qv-icon-active`, `--qv-hover`,
293
302
  `--qv-active`, `--qv-focus`), progress (`--qv-progress`), the error panel (`--qv-error`,
294
- `--qv-error-text`, `--qv-error-border`, `--qv-error-hover`), and the sheet's edge
295
- (`--qv-sheet-shadow`). `--qv-mark` is the watermark face and does not follow the scheme.
303
+ `--qv-error-text`, `--qv-error-border`, `--qv-error-hover`), and the pages' edge
304
+ (`--qv-sheet-shadow`).
296
305
 
297
- The report itself uses the viewer's built-in `q-*` stylesheet, the screen sibling of
298
- `@quario/html/style.css`.
306
+ The report itself is paint, not markup: there is no report stylesheet to restyle, because what
307
+ you see is the layout the PDF target writes.
299
308
 
300
309
  ## License
301
310
 
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
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * What the host got wrong, named. The element's own mistakes — a report that
3
- * is not compiled, a missing html target, a malformed option property — are
4
- * definition-shaped: they describe the embedding, not the report, so each
5
- * check here throws a `TypeError` naming the property and the expectation.
6
- * The element rethrows them from its render task, which is how one channel
7
- * (the error event and the panel) carries every failure a host can cause.
3
+ * is not compiled, a malformed option property — are definition-shaped: they
4
+ * describe the embedding, not the report, so each check here throws a
5
+ * `TypeError` naming the property and the expectation. The element rethrows
6
+ * them from its render task, which is how one channel (the error event and
7
+ * the panel) carries every failure a host can cause.
8
8
  *
9
9
  * Pure policy, no DOM: this module is what the Node suite pins.
10
10
  */
11
11
 
12
+ import { pageBox } from "@quario/layout";
12
13
  import { STEPS } from "./zoom.js";
13
14
 
14
15
  /** @type {(message: string) => never} */
@@ -16,12 +17,6 @@ let fail = (message) => {
16
17
  throw new TypeError("quario-viewer: " + message);
17
18
  };
18
19
 
19
- // The PdfPage vocabulary and defaults, mirrored from the pdf target: the
20
- // sheet is a visual stand-in for the page the host configured there, so the
21
- // same values must mean the same geometry.
22
- /** @type {Record<string, [number, number]>} */
23
- let SIZES = { A4: [595.28, 841.89], letter: [612, 792] };
24
-
25
20
  let REPORT = [
26
21
  (/** @type {any} */ report) => report && typeof report === "object",
27
22
  (/** @type {any} */ report) => typeof report.stream === "function",
@@ -31,69 +26,33 @@ let REPORT = [
31
26
  /** @type {(report: any) => boolean} */
32
27
  let compiled = (report) => REPORT.every((check) => check(report));
33
28
 
34
- /** @type {(target: any) => boolean} */
35
- let htmlTarget = (target) => target?.name === "html";
36
-
37
29
  /**
38
- * The target the sheet displays: the one named `"html"`, checked along with
39
- * the shapes around it.
30
+ * The shapes a render needs: a compiled report, and the export targets as an
31
+ * array — possibly empty, since the sheet is the layout's own and needs no
32
+ * target of the host's.
40
33
  *
41
34
  * @param {any} report
42
35
  * @param {any} targets
43
- * @returns {any} The html target.
44
36
  */
45
- export let display = (report, targets) => {
37
+ export let exports = (report, targets) => {
46
38
  if (!compiled(report)) fail("report: expected a compiled report from a quario instance");
47
39
  if (!Array.isArray(targets)) fail("targets: expected an array of render targets");
48
- let found = targets.find(htmlTarget);
49
- if (!found) fail('targets: no "html" target — include html() to display');
50
- return found;
51
- };
52
-
53
- /** @type {(size: unknown) => [number, number] | false} */
54
- let named = (size) =>
55
- Object.hasOwn(SIZES, /** @type {string} */ (size)) && SIZES[/** @type {string} */ (size)];
56
-
57
- /** @type {(size: unknown) => any} */
58
- let measure = (size) => (Array.isArray(size) ? size : named(size));
59
-
60
- /** @type {(n: number) => boolean} */
61
- let positive = (n) => Number.isFinite(n) && n > 0;
62
-
63
- /** @type {(dimensions: any) => { width: number, height: number }} */
64
- let extents = (dimensions) => {
65
- let width = +dimensions[0];
66
- let height = +dimensions[1];
67
- if (!positive(width) || !positive(height)) fail("page.size: expected finite positive dimensions");
68
- return { width, height };
69
- };
70
-
71
- /** @type {(margin: number, width: number, height: number) => boolean} */
72
- let fits = (margin, width, height) =>
73
- [Number.isFinite(margin), margin >= 0, 2 * margin < Math.min(width, height)].every(Boolean);
74
-
75
- /** @type {(page: any, width: number, height: number) => number} */
76
- let inset = (page, width, height) => {
77
- let margin = page?.margin ?? 54;
78
- if (!fits(margin, width, height))
79
- fail("page.margin: expected a non-negative number smaller than half the page");
80
- return margin;
81
40
  };
82
41
 
83
42
  /**
84
- * The sheet's geometry from the `page` property. Width and margin size the
85
- * sheet; height is the stamp period for the unlicensed watermark (full page
86
- * box, same as the PDF target margin does not shrink it).
43
+ * The page geometry from the `page` property: the layout's own vocabulary and
44
+ * defaults, so the same values mean the same pages on screen and on paper.
45
+ * The layout names the mistake; this names the property.
87
46
  *
88
47
  * @param {any} page
89
48
  * @returns {{ width: number, height: number, margin: number }}
90
49
  */
91
50
  export let geometry = (page) => {
92
- let size = page?.size ?? "A4";
93
- let dimensions = measure(size);
94
- if (!dimensions) fail('page.size: unknown page size "' + size + '"');
95
- let { width, height } = extents(dimensions);
96
- return { width, height, margin: inset(page, width, height) };
51
+ try {
52
+ return pageBox(page, "page");
53
+ } catch (error) {
54
+ return fail(/** @type {Error} */ (error).message);
55
+ }
97
56
  };
98
57
 
99
58
  /** @type {(mode: any, floor: number, ceiling: number) => boolean} */
@@ -135,8 +94,9 @@ export let name = (filename) => {
135
94
  return base;
136
95
  };
137
96
 
138
- /** @type {Record<string, string>} */
139
- 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"];
140
100
 
141
101
  /**
142
102
  * The used `color-scheme` from the `colorScheme` property. Light is the
@@ -149,6 +109,6 @@ let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
149
109
  */
150
110
  export let scheme = (colorScheme) => {
151
111
  let pin = colorScheme ?? "light";
152
- if (!Object.hasOwn(SCHEME, pin)) fail('colorScheme: expected "light", "dark", or "auto"');
153
- return SCHEME[pin];
112
+ if (!SCHEMES.includes(pin)) fail('colorScheme: expected "light", "dark", or "auto"');
113
+ return pin === "auto" ? "light dark" : pin;
154
114
  };
package/lib/chrome.js CHANGED
@@ -24,10 +24,10 @@
24
24
  * purpose: `:host` outranks that same host rule, so nothing outside can
25
25
  * shadow an alias by accident.
26
26
  *
27
- * Two of the sixteen are the sheet's rather than the chrome's: a color scheme
27
+ * One of the fifteen is the sheet's rather than the chrome's: a color scheme
28
28
  * paints chrome only (ADR 0018), so `--qv-sheet-shadow` is the sheet's edge
29
- * against the backdrop and `--qv-mark` is scheme-free by design. They are in
30
- * the block because it has no exceptions, not because they follow the pin.
29
+ * against the backdrop. It is in the block because it has no exceptions, not
30
+ * because it follows the pin.
31
31
  * `--qv-focus` and `--qv-progress` repeat the accent rather than chain a third
32
32
  * alias — a host is free to move one alone.
33
33
  *
@@ -68,7 +68,6 @@ export let CHROME = css`
68
68
  0 1px 2px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)),
69
69
  0 6px 24px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.45))
70
70
  );
71
- --_mark: var(--qv-mark, #999);
72
71
  }
73
72
 
74
73
  .qv-viewer {
@@ -167,6 +166,25 @@ export let CHROME = css`
167
166
  display: flex;
168
167
  gap: 2px;
169
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
+ }
170
188
  `;
171
189
 
172
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"/>`);