@quario/viewer 0.9.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/lib/index.js CHANGED
@@ -14,15 +14,13 @@
14
14
  * and `@quario/viewer/register` performs the one-line define for hosts that
15
15
  * want it (docs/adr/0005-the-surfaces-are-custom-elements.md).
16
16
  *
17
- * Nothing here is markup: the sheet is a stack of canvases, and the list's
18
- * text reaches them as strings the painter draws, so the viewer has no
19
- * markup edge and adds no report content of its own. The unlicensed marking
20
- * rides on the list, one op per page, painted last — the same placement the
21
- * pdf target writes (docs/adr/0017-the-viewer-watermarks-the-sheet.md).
17
+ * Nothing here is markup: the sheet is a stack of canvases, so the viewer has
18
+ * no markup edge. The unlicensed marking rides on the list, one op per page,
19
+ * painted last (docs/adr/0017).
22
20
  *
23
- * The error panel is the only wording the viewer authors, and it is not
24
- * report content: it names which failure occurred, because no error knows
25
- * whether it was a mount, an update or an export (panel.js).
21
+ * The error panel is the only wording the viewer authors: it names which
22
+ * failure occurred, because no error knows whether it was a mount, an update
23
+ * or an export (panel.js).
26
24
  */
27
25
  import { Task, TaskStatus } from "@lit/task";
28
26
  import { LitElement, html } from "lit";
@@ -30,8 +28,10 @@ import { Landing, failures, options } from "@quario/landing";
30
28
  import { layout } from "@quario/layout";
31
29
  import { BUTTON } from "./button.js";
32
30
  import { CHROME, progress } from "./chrome.js";
33
- 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";
34
33
  import { MENU, zoomMenu } from "./menu.js";
34
+ import { OUTLINE, outlinePanel, outlineToggle } from "./outline.js";
35
35
  import { PANEL, label, panel } from "./panel.js";
36
36
  import { SURFACE, stage } from "./stage.js";
37
37
  import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
@@ -50,21 +50,19 @@ export const TAG = "quario-viewer";
50
50
  /**
51
51
  * The viewer element. Hosts assign `report`, `targets` and `data` (plus the
52
52
  * option properties `zoom`, `page`, `fonts`, `filename` and `colorScheme`)
53
- * and listen for `rendered` and `error`; rapid successive writes render only
54
- * the newest state, because the render pipeline is a task keyed on those
55
- * properties and the task's own call-id guard drops every superseded run
56
- * whole — including its failure, which is reported to no one (ADR 0005's
57
- * narrowed guarantee).
53
+ * and listen for `rendered` and `error`. Rapid successive writes render only
54
+ * the newest state: the task's call-id guard drops every superseded run
55
+ * whole, including its failure, which is reported to no one (ADR 0005).
58
56
  *
59
- * Removal is not destruction: disconnecting abandons in-flight work and
57
+ * Removal is not destruction: disconnecting stops the work in flight and
60
58
  * releases the resize observer, the properties persist, and reconnecting
61
- * re-renders from them. There is no `destroy()`.
59
+ * renders them again unless the last render landed. There is no `destroy()`.
62
60
  */
63
61
  export class QuarioViewer extends LitElement {
64
62
  // CHROME declares the default palette every other sheet paints from, so it
65
63
  // has to be in this list -- but anywhere in it: a custom property resolves
66
64
  // down the inherited chain, not by stylesheet order.
67
- static styles = [CHROME, BUTTON, MENU, SURFACE, PANEL];
65
+ static styles = [CHROME, BUTTON, MENU, OUTLINE, SURFACE, LINKS, PANEL];
68
66
 
69
67
  // Properties only, no attributes: `report`, `targets`, `data` and `page`
70
68
  // are values no attribute could carry, and one door beats two.
@@ -77,6 +75,7 @@ export class QuarioViewer extends LitElement {
77
75
  fonts: { attribute: false },
78
76
  filename: { attribute: false },
79
77
  colorScheme: { attribute: false },
78
+ outline: { attribute: false },
80
79
  };
81
80
 
82
81
  // The stage is built once and never rebuilt: it is the one imperative thing
@@ -105,57 +104,65 @@ export class QuarioViewer extends LitElement {
105
104
  #reapply = false;
106
105
  /** @type {Set<string>} The exports in flight; their buttons disable. */
107
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 = [];
108
114
  /** @type {(() => void) | undefined} */
109
115
  #unwatch;
110
- /**
111
- * The newest swap's paint, which `renderComplete` waits behind. Bare, with
112
- * no catch of its own: the stage settles a page it cannot draw rather than
113
- * rejecting, so the chain below is reached whatever the pixels did.
114
- *
115
- * @type {Promise<void>}
116
- */
117
- #painting = Promise.resolve();
118
-
119
116
  // The whole async pipeline: keyed on the render properties, re-run when one
120
117
  // changes — `page` and `fonts` among them, since either moves where the
121
- // pages break. The task's call-id guard is the latest-wins machinery; side
122
- // effects the stage swap and both events live only in the callbacks
123
- // below, which that guard restricts to the newest run, never in the task
124
- // body a stale run still executes to completion. `null` is the "nothing to
125
- // show" result: unlike the task primitive's own initial-state symbol it
126
- // settles `taskComplete`, which is what lets `renderComplete` always answer.
118
+ // pages break. Side effects live only in the callbacks below, which the
119
+ // task's call-id guard restricts to the newest run, never in the task body a
120
+ // stale run still executes to completion. `null` is the "nothing to show"
121
+ // result: unlike the initial-state symbol it settles `taskComplete`, which
122
+ // is what lets `renderComplete` always answer.
127
123
  #task = new Task(this, {
128
124
  args: () => [this.report, this.targets, this.data, this.page, this.fonts, this.#landing.epoch],
129
- task: async ([report, targets, data, page, fonts], { signal }) => {
125
+ task: async ([report, targets, data, page, fonts]) => {
126
+ // First, before anything that can throw: the debt opens by starting, so
127
+ // every way of not landing keeps it without saying so (ADR 0075). A
128
+ // host-option throw out of `#begin` is one of them.
129
+ this.#landing.owe();
130
130
  if (!this.#begin(report, targets)) return null;
131
131
  // The sheet's own target: the layout list, on the host's page and
132
132
  // fonts — the same two a host passes to `pdf()`, which is what makes
133
133
  // the preview page where the document pages.
134
134
  let target = layout({ page: /** @type {any} */ (page), fonts: /** @type {any} */ (fonts) });
135
135
  let list = await /** @type {any} */ (report).render(target, data);
136
- // The engine takes no signal, so abandonment is the guards around this
137
- // body; the check only spares the swap when the answer arrives after a
138
- // disconnect mid-render.
139
- if (this.#landing.dropped(signal, this.isConnected)) return this.#landing.abandon();
140
136
  return { list, fonts };
141
137
  },
142
138
  onComplete: (result) => {
143
- if (result === null || !this.isConnected) return;
139
+ if (result === null || !this.isConnected) return this.#landing.nothing();
144
140
  // The swap sizes every page before it settles, so the reader's place
145
141
  // is held; the paint it awaits is what `rendered` waits for.
146
- this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
147
- this.#landing.land();
148
- // A render that landed on the sheet takes the panel down: the panel
149
- // says what is wrong with what the reader is looking at, and this is
150
- // the moment that stops being true. A successful export is not that
151
- // moment.
152
- this.#failures.clear();
153
- // After the paint, so the update the task queued has already run.
154
- this.requestUpdate();
155
- this.dispatchEvent(new CustomEvent("rendered"));
156
- });
142
+ // The boundary takes the paint rather than a promise to report back
143
+ // with: it reads which run this is here, while this run is still the
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
+ );
157
163
  },
158
164
  onError: (error) => {
165
+ this.#landing.fail();
159
166
  if (!this.isConnected) return;
160
167
  this.#announce(error, this.#kindOf(error));
161
168
  },
@@ -182,6 +189,8 @@ export class QuarioViewer extends LitElement {
182
189
  this.filename = undefined;
183
190
  /** @type {"light" | "dark" | "auto" | undefined} */
184
191
  this.colorScheme = undefined;
192
+ /** @type {boolean | undefined} */
193
+ this.outline = undefined;
185
194
  }
186
195
 
187
196
  /**
@@ -191,19 +200,17 @@ export class QuarioViewer extends LitElement {
191
200
  * render still landed, so `true` promises the sheet is done changing, not
192
201
  * that every page carries pixels. It never rejects — a failed
193
202
  * render is handled, on the panel and through the error event — and like
194
- * 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.
195
209
  *
196
210
  * @returns {Promise<boolean>}
197
211
  */
198
212
  get renderComplete() {
199
- return this.updateComplete.then(() =>
200
- this.#task.status === TaskStatus.INITIAL
201
- ? this.#landing.landed
202
- : this.#task.taskComplete.then(
203
- () => this.#painting.then(() => this.#landing.landed),
204
- () => false,
205
- ),
206
- );
213
+ return this.updateComplete.then(() => this.#landing.complete);
207
214
  }
208
215
 
209
216
  /** @param {Map<string, unknown>} changed */
@@ -229,6 +236,7 @@ export class QuarioViewer extends LitElement {
229
236
  <div class="qv-viewer" aria-busy=${String(busy)}>
230
237
  <div class="qv-bar">
231
238
  ${progress(busy)}
239
+ ${this.#outlineControl()}
232
240
  ${zoomMenu({
233
241
  mode: this.#mode,
234
242
  percent: Math.round(this.#stage.percent()),
@@ -241,6 +249,7 @@ export class QuarioViewer extends LitElement {
241
249
  </div>
242
250
  <div class="qv-body">
243
251
  ${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
252
+ ${this.#outlineAside()}
244
253
  ${this.#stage.element}
245
254
  </div>
246
255
  </div>
@@ -264,9 +273,9 @@ export class QuarioViewer extends LitElement {
264
273
  this.#unwatch ??= this.#stage.watch(() => {
265
274
  if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
266
275
  });
267
- // Reconnect re-renders from the current properties — but only when the
268
- // sheet stopped matching them: a render was abandoned mid-flight, or a
269
- // property write landed while disconnected. The task's arguments did not
276
+ // Reconnect re-renders from the current properties — but only where a
277
+ // render is owed, which is every run that did not reach the sheet
278
+ // (ADR 0075). The task's arguments did not
270
279
  // change, so the epoch is what re-runs it; left alone, reparenting a
271
280
  // settled viewer costs nothing.
272
281
  }
@@ -283,16 +292,17 @@ export class QuarioViewer extends LitElement {
283
292
  * A property the host got wrong fails loudly even while there is nothing to
284
293
  * render yet: a misconfigured host is caught in development, not when the
285
294
  * report arrives. Nothing assigned yet is not a failure. Disconnected
286
- * elements keep processing updates, so the guard is what makes removal
287
- * abandon work rather than merely hide it.
295
+ * elements keep processing updates, so the guard is what stops a removed
296
+ * element working where nobody is looking; the debt the run opened stands
297
+ * either way, and reconnecting collects it.
288
298
  *
289
299
  * @param {unknown} report
290
300
  * @param {unknown} targets
301
+ * @returns {boolean} Whether there is a render to do.
291
302
  */
292
303
  #begin(report, targets) {
293
304
  if (this.#options.invalid) throw this.#options.invalid;
294
- if (vacant(report, targets)) return null;
295
- if (!this.isConnected) return this.#landing.abandon();
305
+ if (vacant(report, targets) || !this.isConnected) return false;
296
306
  exports(report, targets);
297
307
  return true;
298
308
  }
@@ -343,6 +353,15 @@ export class QuarioViewer extends LitElement {
343
353
  let used = scheme(this.colorScheme);
344
354
  if (gate("colorScheme")) this.style.colorScheme = used;
345
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
+ },
346
365
  ]);
347
366
  if (moved) this.#landing.epoch++;
348
367
  }
@@ -406,6 +425,29 @@ export class QuarioViewer extends LitElement {
406
425
  this.requestUpdate();
407
426
  }
408
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
+
409
451
  /**
410
452
  * Report one failure, wherever it came from: the panel for the reader, the
411
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