@zinejs/core 0.8.0 → 0.9.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.
Files changed (41) hide show
  1. package/README.md +27 -49
  2. package/dist/{bundled-Cp43MIt0.js → bundled-ESapkNsS.js} +34 -34
  3. package/dist/bundled-ESapkNsS.js.map +1 -0
  4. package/dist/controls/contextMenu.d.ts +16 -0
  5. package/dist/controls/controls.d.ts +10 -3
  6. package/dist/controls/icons.d.ts +2 -2
  7. package/dist/controls/menu.d.ts +68 -0
  8. package/dist/controls/search.d.ts +15 -2
  9. package/dist/controls/styles.d.ts +1 -1
  10. package/dist/controls/toolbar.d.ts +1 -1
  11. package/dist/controls/types.d.ts +13 -2
  12. package/dist/{controls-Djjk8Yac.js → controls-CcOu_19g.js} +355 -199
  13. package/dist/controls-CcOu_19g.js.map +1 -0
  14. package/dist/curls.js +1 -1
  15. package/dist/geometry/curls/cone.d.ts +4 -3
  16. package/dist/index.js +644 -450
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.umd.js +61 -13
  19. package/dist/index.umd.js.map +1 -1
  20. package/dist/loading/loading.d.ts +1 -1
  21. package/dist/{loading-C0qbANt2.js → loading-DF88K9yJ.js} +2 -2
  22. package/dist/loading-DF88K9yJ.js.map +1 -0
  23. package/dist/renderer/gl/texture.d.ts +13 -2
  24. package/dist/renderer/types.d.ts +6 -0
  25. package/dist/renderer/webglRenderer.d.ts +1 -0
  26. package/dist/{share-DXAfK9rJ.js → share-BrQa6aYm.js} +2 -2
  27. package/dist/{share-DXAfK9rJ.js.map → share-BrQa6aYm.js.map} +1 -1
  28. package/dist/source/types.d.ts +5 -0
  29. package/dist/styles-D_zOGk2q.js +40 -0
  30. package/dist/styles-D_zOGk2q.js.map +1 -0
  31. package/dist/{webglRenderer-nGRruZk2.js → webglRenderer-CmQ_mUrX.js} +113 -101
  32. package/dist/webglRenderer-CmQ_mUrX.js.map +1 -0
  33. package/dist/zine.d.ts +52 -1
  34. package/options.schema.json +49 -0
  35. package/package.json +2 -2
  36. package/dist/bundled-Cp43MIt0.js.map +0 -1
  37. package/dist/controls-Djjk8Yac.js.map +0 -1
  38. package/dist/loading-C0qbANt2.js.map +0 -1
  39. package/dist/styles-CgCzjFUk.js +0 -40
  40. package/dist/styles-CgCzjFUk.js.map +0 -1
  41. package/dist/webglRenderer-nGRruZk2.js.map +0 -1
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # @zinejs/core
2
2
 
3
- Framework-agnostic flipbook engine: turn images or PDFs into a page-flipping book. Zero dependencies. Ships a **CSS renderer** (works everywhere, no GPU) and a **WebGL2 renderer** (page-curl, auto-selected where a GPU is available), behind one API — you never choose a renderer.
3
+ Framework-agnostic flipbook engine for the web. Turn a list of images (or a PDF, via [`@zinejs/pdf`](https://www.npmjs.com/package/@zinejs/pdf)) into a page-flip book. Zero dependencies.
4
4
 
5
- > Pre-1.0 and under active development. Licensed **PolyForm Noncommercial 1.0.0** (free for non-commercial use).
5
+ Where WebGL2 is available it uses the GPU renderer (page-curl, lighting). Elsewhere it falls back to CSS on its own.
6
+
7
+ > **Docs, live demos, and guides:** [zinejs.com/docs](https://zinejs.com/docs/) · [examples](https://zinejs.com/examples)
6
8
 
7
9
  ## Install
8
10
 
@@ -10,79 +12,55 @@ Framework-agnostic flipbook engine: turn images or PDFs into a page-flipping boo
10
12
  npm install @zinejs/core
11
13
  ```
12
14
 
13
- Also ships a **UMD** build at `dist/index.umd.js` (global `ZineJS`) for CDN / `<script>` hosts. The ESM build code-splits the CSS and WebGL2 renderers; the UMD build inlines both so a single script is enough.
15
+ Also ships a **UMD** build at `dist/index.umd.js` (global `ZineJS`) for [CDN / `<script>` hosts](https://zinejs.com/examples/html-cdn). The ESM build code-splits the CSS and WebGL2 renderers (and the toolbar); the UMD build inlines the renderers so a single script is enough.
14
16
 
15
17
  ## Image book
16
18
 
17
19
  ```js
18
- import { Zine, ImageSource } from '@zinejs/core';
20
+ import { Zine, ImageSource } from '@zinejs/core'
19
21
 
20
- const book = new Zine(document.getElementById('book'), {
22
+ new Zine(document.getElementById('book'), {
21
23
  source: new ImageSource([
22
24
  'page-01.png',
23
25
  'page-02.png',
24
26
  'page-03.png',
25
27
  'page-04.png',
26
28
  ]),
27
- });
29
+ })
28
30
  ```
29
31
 
30
- Give the container a size (e.g. `#book { width: 900px; height: 560px; }`). Flip by dragging a corner or clicking near an edge; zoom with double-click, Ctrl/⌘+wheel, or pinch.
32
+ Drag a corner or click near an edge to turn. Double-click, pinch, or Ctrl/Cmd+wheel to zoom.
31
33
 
32
34
  ## PDF book
33
35
 
34
- Install the plugin (see [`@zinejs/pdf`](https://www.npmjs.com/package/@zinejs/pdf)):
36
+ Install [`@zinejs/pdf`](https://www.npmjs.com/package/@zinejs/pdf) as well:
35
37
 
36
38
  ```js
37
- import { Zine } from '@zinejs/core';
38
- import { PdfSource } from '@zinejs/pdf';
39
+ import { Zine } from '@zinejs/core'
40
+ import { PdfSource } from '@zinejs/pdf'
39
41
 
40
42
  new Zine(document.getElementById('book'), {
41
43
  source: new PdfSource('document.pdf'),
42
- });
43
- ```
44
-
45
- ## Options
46
-
47
- ```ts
48
- new Zine(container, {
49
- source, // ImageSource | PdfSource (required)
50
- direction: 'ltr', // 'ltr' | 'rtl'
51
- spreadMode: 'cover', // 'double' | 'single' | 'cover' (lone first page) | 'book' (lone first & last)
52
- curl: 'cone', // WebGL2 only: 'cone' | 'roll' | 'leaf' | 'flick' | 'silk' | 'simple'
53
- frontCover: 'front.jpg', // optional image prepended as a lone front cover
54
- backCover: 'back.jpg', // optional image appended as a lone back cover
55
- pages: { 4: 'ad.jpg' }, // optional: replace source pages with images (0-based; -1 = last)
56
- startPage: 0,
57
- flipDuration: 800, // ms (spread timing; a lone page stretches it and eases out)
58
- singlePageThreshold: 640, // px: below this, show one page at a time
59
- clickToFlip: 'edge', // 'edge' | 'half' | 'off'
60
- zoom: { enabled: true, max: 4, wheel: true, doubleClick: [1, 2, 4] },
61
- renderer: 'auto', // 'auto' | 'css' | 'webgl2' | a custom Renderer
62
- controls: true, // built-in toolbar; false for none, or { position, items, … }
63
- });
44
+ })
64
45
  ```
65
46
 
66
- ## Controls
67
-
68
- A toolbar (paging, page number, zoom, search, share, a `⋮` menu with download, and fullscreen)
69
- is rendered below the book by default. Turn it off with `controls: false`, move it with
70
- `controls: { position: 'top' }`, float it over the book with `controls: { docked: false }`, or
71
- choose the buttons with `controls: { items: [...] }`. Register your own with `defineControl`.
47
+ See [PdfSource](https://zinejs.com/docs) for worker setup, `legacy`, and CDN.
72
48
 
73
- The toolbar and its icons load as a separate chunk, so a book with `controls: false` never
74
- downloads them. See the [full docs](https://github.com/awecode/zinejs#controls).
49
+ ## Options and API
75
50
 
76
- See the [root README](../../README.md#curl-models) for what each curl model does.
77
- ## Methods & events
51
+ Only `source` is required. Common options: `spreadMode`, `curl` (`'cone'` or `'simple'` by name; other curls from `@zinejs/core/curls`), `zoom`, `controls`, `renderer` (`'auto'` prefers GPU). A JSON Schema is at `@zinejs/core/options.schema.json`.
78
52
 
79
53
  ```js
80
- book.flipNext(); book.flipPrev(); book.flipTo(12);
81
- book.getPage(); book.getPageCount();
82
- book.setZoom(2); book.resetZoom();
83
- book.update(); book.destroy();
84
-
85
- book.on('ready' | 'pageChanged' | 'flipStart' | 'flipEnd' | 'zoomChanged' | 'sourceError' | 'rendererFallback', handler);
54
+ const zine = new Zine(el, {
55
+ source,
56
+ spreadMode: 'cover',
57
+ zoom: { max: 4 },
58
+ })
59
+
60
+ await zine.ready
61
+ zine.on('pageChanged', ({ page }) => console.log(page))
62
+ zine.flipNext()
63
+ zine.flipTo(12)
86
64
  ```
87
65
 
88
- A JSON Schema for the options is published at `@zinejs/core/options.schema.json`.
66
+ The toolbar loads as a separate chunk, so `controls: false` never downloads it. Full options, methods, events, and curl models: [zinejs.com/docs](https://zinejs.com/docs/) and the [GitHub README](https://github.com/awecode/zinejs#readme).
@@ -25,45 +25,45 @@ function n(e) {
25
25
  }
26
26
  //#endregion
27
27
  //#region src/geometry/curls/cone.ts
28
- var r = 30 * Math.PI / 180, i = 44 * Math.PI / 180, a = 1.35, o = .7, s = 1.85, c = 1.15, l = .42, u = 1.08;
29
- function d(e, t, d, f, p) {
30
- let m = e.cols + 1, h = e.rows + 1, g = e.positions, _ = !!p.fill, v = Math.sin(Math.PI * f) ** .85, y = _ ? i : r, b = Math.PI / 2 - v * (Math.PI / 2 - y), x = Math.sin(b), S = Math.cos(b), C = 1 / Math.max(x, 1e-6), w = _ ? s : a, T = d * (w + ((_ ? c : o) - w) * v), E = (p.y - .5) * 2, D = E >= 0, O = Math.min(1, Math.abs(E) / l), k = 1 - O, A = D ? d + T : -T, j = Math.hypot(t, .5 * d - A), M = Math.max(j * x, t / Math.PI), N = _ ? f ** +u : f, P = -Math.PI * N, F = Math.cos(P), I = Math.sin(P);
31
- if (v < 1e-4) {
32
- for (let n = 0; n < h; n++) {
33
- let r = n / e.rows * d;
34
- for (let i = 0; i < m; i++) {
35
- let a = i / e.cols * t, o = (n * m + i) * 3;
36
- g[o] = a * F, g[o + 1] = r, g[o + 2] = -a * I;
28
+ var r = 30 * Math.PI / 180, i = 1.35, a = .7, o = .42, s = .84, c = .88;
29
+ function l(e, t, l, u, d) {
30
+ let f = e.cols + 1, p = e.rows + 1, m = e.positions, h = u >= s ? 1 : u / s, g = Math.sin(Math.PI * h), _ = Math.PI / 2 - g * (Math.PI / 2 - r), v = Math.sin(_), y = Math.cos(_), b = 1 / Math.max(v, 1e-6), x = l * (i + (a - i) * g), S = (d.y - .5) * 2, C = S >= 0, w = Math.min(1, Math.abs(S) / o), T = 1 - w, E = C ? l + x : -x, D = Math.hypot(t, .5 * l - E), O = Math.max(D * v, t / Math.PI), k = Math.min(1, u / c), A = -Math.PI * k, j = Math.cos(A), M = Math.sin(A);
31
+ if (g < 1e-4) {
32
+ for (let n = 0; n < p; n++) {
33
+ let r = n / e.rows * l;
34
+ for (let i = 0; i < f; i++) {
35
+ let a = i / e.cols * t, o = (n * f + i) * 3;
36
+ m[o] = a * j, m[o + 1] = r, m[o + 2] = -a * M;
37
37
  }
38
38
  }
39
39
  n(e);
40
40
  return;
41
41
  }
42
- for (let n = 0; n < h; n++) {
43
- let r = n / e.rows * d;
44
- for (let i = 0; i < m; i++) {
42
+ for (let n = 0; n < p; n++) {
43
+ let r = n / e.rows * l;
44
+ for (let i = 0; i < f; i++) {
45
45
  let a = i / e.cols * t, o = a, s = r, c = 0;
46
- if (O > 1e-5) {
47
- let e = D ? d - r : r, t = -T, n = Math.hypot(a, e - t);
46
+ if (w > 1e-5) {
47
+ let e = C ? l - r : r, t = -x, n = Math.hypot(a, e - t);
48
48
  if (n > 1e-8) {
49
- let e = n * x, r = Math.asin(Math.min(1, Math.max(0, a / n))) * C, i = 1 - Math.cos(r), l = n + t - e * i * x;
50
- o = e * Math.sin(r), s = D ? d - l : l, c = e * i * S;
49
+ let e = n * v, r = Math.asin(Math.min(1, Math.max(0, a / n))) * b, i = 1 - Math.cos(r), u = n + t - e * i * v;
50
+ o = e * Math.sin(r), s = C ? l - u : u, c = e * i * y;
51
51
  }
52
52
  }
53
- let l = o, u = s, f = c;
54
- if (k > 1e-5) {
55
- let e = a / M, t = M * Math.sin(e), n = M * (1 - Math.cos(e));
56
- l = O * o + k * t, u = O * s + k * r, f = O * c + k * n;
53
+ let u = o, d = s, p = c;
54
+ if (T > 1e-5) {
55
+ let e = a / O, t = O * Math.sin(e), n = O * (1 - Math.cos(e));
56
+ u = w * o + T * t, d = w * s + T * r, p = w * c + T * n;
57
57
  }
58
- let p = (n * m + i) * 3;
59
- g[p] = l * F + f * I, g[p + 1] = u, g[p + 2] = -l * I + f * F;
58
+ let h = (n * f + i) * 3;
59
+ m[h] = u * j + p * M, m[h + 1] = d, m[h + 2] = -u * M + p * j;
60
60
  }
61
61
  }
62
62
  n(e);
63
63
  }
64
64
  //#endregion
65
65
  //#region src/geometry/curls/simple.ts
66
- function f(e, t, r, i) {
66
+ function u(e, t, r, i) {
67
67
  let a = e.cols + 1, o = e.rows + 1, s = e.positions, c = i * Math.PI, l = Math.cos(c), u = Math.sin(c);
68
68
  for (let n = 0; n < a; n++) {
69
69
  let i = n / e.cols * t, c = i * l, d = i * u;
@@ -76,25 +76,25 @@ function f(e, t, r, i) {
76
76
  }
77
77
  //#endregion
78
78
  //#region src/geometry/curls/bundled.ts
79
- var p = {
80
- deform: d,
79
+ var d = {
80
+ deform: l,
81
81
  anchored: !0
82
- }, m = {
83
- deform: f,
82
+ }, f = {
83
+ deform: u,
84
84
  anchored: !1,
85
85
  flat: !0,
86
86
  gloss: !1
87
- }, h = {
88
- cone: p,
89
- simple: m
87
+ }, p = {
88
+ cone: d,
89
+ simple: f
90
90
  };
91
- function g(t) {
91
+ function m(t) {
92
92
  if (typeof t != "string") return t;
93
- let n = h[t];
93
+ let n = p[t];
94
94
  if (n) return n;
95
95
  throw e.includes(t) ? Error(`Zine: the '${t}' curl is not bundled. Import it and pass the model: import { ${t} } from '@zinejs/core/curls' → curl: ${t}`) : Error(`Zine: unknown curl ${JSON.stringify(t)}.`);
96
96
  }
97
97
  //#endregion
98
- export { n as a, m as i, p as n, t as o, g as r, h as t };
98
+ export { n as a, f as i, d as n, t as o, m as r, p as t };
99
99
 
100
- //# sourceMappingURL=bundled-Cp43MIt0.js.map
100
+ //# sourceMappingURL=bundled-ESapkNsS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundled-ESapkNsS.js","names":[],"sources":["../src/geometry/curls/mesh.ts","../src/geometry/curls/cone.ts","../src/geometry/curls/simple.ts","../src/geometry/curls/bundled.ts"],"sourcesContent":["/**\n * Shared grid mesh + normal utilities for the page-curl models.\n *\n * A curl model fills `positions`/`normals` in place each frame; UVs are static grid\n * coordinates (no texture swimming). All models bend the same (cols+1)×(rows+1) grid.\n */\n\nexport interface PageMesh {\n readonly cols: number;\n readonly rows: number;\n /** ((cols+1)*(rows+1))*2 — static grid UVs (u across width, v down height). */\n readonly uvs: Float32Array;\n /** *3 — deformed positions, updated by the curl model. */\n readonly positions: Float32Array;\n /** *3 — per-vertex normals, updated by the curl model. */\n readonly normals: Float32Array;\n}\n\n/** Allocate a (cols+1)×(rows+1) grid mesh with static UVs (positions/normals filled by a curl). */\nexport function createPageMesh(cols: number, rows: number): PageMesh {\n const nx = cols + 1;\n const ny = rows + 1;\n const count = nx * ny;\n const uvs = new Float32Array(count * 2);\n for (let j = 0; j < ny; j++) {\n for (let i = 0; i < nx; i++) {\n const k = j * nx + i;\n uvs[k * 2] = i / cols;\n uvs[k * 2 + 1] = j / rows;\n }\n }\n return {\n cols,\n rows,\n uvs,\n positions: new Float32Array(count * 3),\n normals: new Float32Array(count * 3),\n };\n}\n\n/** Per-vertex normals from central differences of the deformed grid (front = +z when flat). */\nexport function computeNormals(mesh: PageMesh): void {\n const nx = mesh.cols + 1;\n const ny = mesh.rows + 1;\n const pos = mesh.positions;\n const nrm = mesh.normals;\n\n for (let j = 0; j < ny; j++) {\n for (let i = 0; i < nx; i++) {\n const iPrev = i > 0 ? i - 1 : i;\n const iNext = i < nx - 1 ? i + 1 : i;\n const jPrev = j > 0 ? j - 1 : j;\n const jNext = j < ny - 1 ? j + 1 : j;\n\n const a = (j * nx + iNext) * 3;\n const b = (j * nx + iPrev) * 3;\n const c = (jNext * nx + i) * 3;\n const e = (jPrev * nx + i) * 3;\n\n const tuX = pos[a]! - pos[b]!;\n const tuY = pos[a + 1]! - pos[b + 1]!;\n const tuZ = pos[a + 2]! - pos[b + 2]!;\n const tvX = pos[c]! - pos[e]!;\n const tvY = pos[c + 1]! - pos[e + 1]!;\n const tvZ = pos[c + 2]! - pos[e + 2]!;\n\n let nx0 = tuY * tvZ - tuZ * tvY;\n let ny0 = tuZ * tvX - tuX * tvZ;\n let nz0 = tuX * tvY - tuY * tvX;\n const l = Math.hypot(nx0, ny0, nz0) || 1;\n nx0 /= l;\n ny0 /= l;\n nz0 /= l;\n\n const k = (j * nx + i) * 3;\n nrm[k] = nx0;\n nrm[k + 1] = ny0;\n nrm[k + 2] = nz0;\n }\n }\n}\n","import { computeNormals, type PageMesh } from './mesh';\nimport type { CurlAnchor } from './types';\n\n/**\n * Cone angle at peak curl. π/2 is flat; smaller values wind the sheet tighter.\n * ~30° gives a soft, paper-like ridge without self-intersection on typical page\n * aspect ratios (Hong / Nuon / Project Austin all land in the 18–35° band).\n */\nconst THETA_MIN = (30 * Math.PI) / 180;\n\n/**\n * How far past the page the cone apex sits, as a fraction of height, at rest.\n * Closer mid-turn sharpens the diagonal; farther reads more cylindrical.\n * Kept moderately distant so the sheet stays page-like without cloth-y sliding.\n */\nconst APEX_REST = 1.35;\nconst APEX_PEAK = 0.7;\n\n/**\n * Above this |anchor.y − 0.5| the deform is a pure cone; at the mid-edge it\n * blends fully to a cylinder (Project Austin’s conicContribution).\n */\nconst CORNER_PURE = 0.42;\n\n/**\n * Curl envelope ends before t=1 so the last stretch is a flat spine settle.\n * Holding sin(πt) curl into the 0.9s left a tall residual ridge that deflated\n * while the free edge was already nearly home — that read as a rebound bounce.\n */\nconst CURL_END = 0.84;\n\n/**\n * Spine rotation reaches ±π before t=1 and holds. Flip easing decelerates hard into\n * the end (a near-still tail after PAGE_LEAD); finishing the flop early keeps that\n * tail from slowly dropping the leaf into place (the settle/rebound feel).\n * Same schedule for lone pages: the dissolve then covers a still, landed sheet\n * rather than a late whip off the hinge.\n */\nconst RHO_END = 0.88;\n\n/**\n * Natural conical page curl — the Xerox PARC / iBooks model.\n *\n * The leaf wraps an imaginary right circular cone whose apex rides the spine and\n * whose opening angle θ breathes over the turn (Hong, Card & Chen, 2004). A corner\n * grab yields a diagonal cone; a mid-edge grab blends toward a cylinder so the\n * free edge lifts as one. The curled sheet then rotates about the spine and settles\n * flat on the far side. Arc length from the apex is preserved, so the paper never\n * stretches — the motion reads as stiff, premium stock rather than cloth.\n *\n * Lone pages (`anchor.fill`) use the same cone and flop as a spread half; the\n * renderer hinges at the container edge and dissolves the leaf as it finishes.\n * Softening or delaying the fill path made single-page turns read as a peel-fade\n * instead of a page turn.\n */\nexport function deformCone(mesh: PageMesh, W: number, H: number, t: number, anchor: CurlAnchor): void {\n const nx = mesh.cols + 1;\n const ny = mesh.rows + 1;\n const pos = mesh.positions;\n\n // Curl envelope: 0 at the ends (flat), 1 near mid-turn. Span ends at CURL_END\n // so the ridge is gone before the spine holds the landed pose through the ease tail.\n const curlT = t >= CURL_END ? 1 : t / CURL_END;\n const curl = Math.sin(Math.PI * curlT);\n\n // θ: π/2 (identity) → THETA_MIN (pronounced curl) → π/2.\n const theta = Math.PI / 2 - curl * (Math.PI / 2 - THETA_MIN);\n const sinTheta = Math.sin(theta);\n const cosTheta = Math.cos(theta);\n const invSinTheta = 1 / Math.max(sinTheta, 1e-6);\n\n // Apex distance: pulled in as the curl develops for a tighter diagonal.\n const apexDist = H * (APEX_REST + (APEX_PEAK - APEX_REST) * curl);\n\n // Corner bias: −1 = top edge, +1 = bottom. Near 0 → cylinder blend.\n const bias = (anchor.y - 0.5) * 2;\n const fromBottom = bias >= 0;\n const absBias = Math.abs(bias);\n let conic = Math.min(1, absBias / CORNER_PURE);\n const cylWeight = 1 - conic;\n\n // Cylinder radius matched to the cone’s free-edge cross-section (Austin), so\n // the blend is continuous as the grab slides from corner to mid-edge.\n // Apex sits past the *leading* edge (below for a bottom grab, above for top).\n const AyRef = fromBottom ? H + apexDist : -apexDist;\n const cylR = Math.hypot(W, 0.5 * H - AyRef);\n const cylRadius = Math.max(cylR * sinTheta, W / Math.PI);\n\n // Spine rotation: concurrent with the cone so the page flops while it curls.\n // Same x–z convention as `roll` (ρ: 0 → −π). Finish by RHO_END and hold so the\n // ease-out / dissolve tail stays still (spread land or lone fade alike).\n // `anchor.fill` is a renderer concern; geometry matches a spread half.\n const rhoT = Math.min(1, t / RHO_END);\n const rho = -Math.PI * rhoT;\n const cosR = Math.cos(rho);\n const sinR = Math.sin(rho);\n\n // Near the rests the cone is the identity plane. A finite-radius cylinder blend\n // still carries a tiny residual arc — skip it and rotate the flat sheet.\n if (curl < 1e-4) {\n for (let j = 0; j < ny; j++) {\n const y = (j / mesh.rows) * H;\n for (let i = 0; i < nx; i++) {\n const x0 = (i / mesh.cols) * W;\n const k = (j * nx + i) * 3;\n pos[k] = x0 * cosR;\n pos[k + 1] = y;\n pos[k + 2] = -x0 * sinR;\n }\n }\n computeNormals(mesh);\n return;\n }\n\n for (let j = 0; j < ny; j++) {\n const y0 = (j / mesh.rows) * H;\n for (let i = 0; i < nx; i++) {\n const x0 = (i / mesh.cols) * W;\n\n let cx = x0;\n let cy = y0;\n let cz = 0;\n\n if (conic > 1e-5) {\n // Canonical cone: apex above the page (A < 0) makes the *near* edge lead.\n // In our y-down leaf, that means the top. For a bottom grab we flip into\n // that space, deform, then flip back — one code path for both corners.\n const yIn = fromBottom ? H - y0 : y0;\n const A = -apexDist;\n const R = Math.hypot(x0, yIn - A);\n if (R > 1e-8) {\n const r = R * sinTheta;\n const beta = Math.asin(Math.min(1, Math.max(0, x0 / R))) * invSinTheta;\n const oneCos = 1 - Math.cos(beta);\n const yCone = R + A - r * oneCos * sinTheta;\n cx = r * Math.sin(beta);\n cy = fromBottom ? H - yCone : yCone;\n cz = r * oneCos * cosTheta;\n }\n }\n\n let bx = cx;\n let by = cy;\n let bz = cz;\n\n if (cylWeight > 1e-5) {\n const beta = x0 / cylRadius;\n const sx = cylRadius * Math.sin(beta);\n const sz = cylRadius * (1 - Math.cos(beta));\n bx = conic * cx + cylWeight * sx;\n by = conic * cy + cylWeight * y0;\n bz = conic * cz + cylWeight * sz;\n }\n\n const k = (j * nx + i) * 3;\n pos[k] = bx * cosR + bz * sinR;\n pos[k + 1] = by;\n pos[k + 2] = -bx * sinR + bz * cosR;\n }\n }\n\n computeNormals(mesh);\n}\n","import { computeNormals, type PageMesh } from './mesh';\n\n/**\n * Flat page turn — the plainest flip, matching the CSS renderer. The leaf stays a rigid\n * flat sheet and rotates around the spine (angle 0→π), going edge-on at the midpoint. No\n * cylinder bend; GPU-rendered here with the shared perspective so it reads like the CSS fold.\n */\nexport function deformSimple(mesh: PageMesh, W: number, H: number, t: number): void {\n const nx = mesh.cols + 1;\n const ny = mesh.rows + 1;\n const pos = mesh.positions;\n\n const angle = t * Math.PI;\n const cosA = Math.cos(angle);\n const sinA = Math.sin(angle);\n\n for (let i = 0; i < nx; i++) {\n const x0 = (i / mesh.cols) * W; // distance from the spine\n const X = x0 * cosA;\n const Z = x0 * sinA;\n for (let j = 0; j < ny; j++) {\n const k = (j * nx + i) * 3;\n pos[k] = X;\n pos[k + 1] = (j / mesh.rows) * H;\n pos[k + 2] = Z;\n }\n }\n\n computeNormals(mesh);\n}\n","import { deformCone } from './cone';\nimport { deformSimple } from './simple';\nimport { IMPORTABLE_CURLS, type CurlModel, type CurlSpec, type CurlType } from './types';\n\n/**\n * The curl models carried in the bundle, and the only ones nameable by string.\n *\n * Kept apart from `./index` so importing them does not drag in the other four: the barrel names\n * every model, which is right for a caller picking one but wrong for the renderer, which must\n * only ever pull these two.\n */\nexport const cone: CurlModel = { deform: deformCone, anchored: true };\nexport const simple: CurlModel = {\n deform: deformSimple,\n anchored: false,\n flat: true,\n gloss: false,\n};\n\nexport const BUNDLED_CURLS: Record<CurlType, CurlModel> = { cone, simple };\n\n/**\n * Turn a `curl` option into a model. Strings name a bundled curl; anything else is already a\n * model, imported from `@zinejs/core/curls` or written by the caller.\n */\nexport function resolveCurl(spec: CurlSpec): CurlModel {\n if (typeof spec !== 'string') return spec;\n const model = BUNDLED_CURLS[spec];\n if (model) return model;\n // Reached only when validation was bypassed (a renderer used directly, say), so name the fix\n // rather than the mistake: these curls are real, they just are not bundled.\n if (IMPORTABLE_CURLS.includes(spec)) {\n throw new Error(\n `Zine: the '${spec}' curl is not bundled. Import it and pass the model: ` +\n `import { ${spec} } from '@zinejs/core/curls' → curl: ${spec}`,\n );\n }\n throw new Error(`Zine: unknown curl ${JSON.stringify(spec)}.`);\n}\n"],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import type { ContextMenuOptions } from './types';
2
+ import type { Zine } from '../zine';
3
+ /**
4
+ * A right-click menu of reading controls, opened at the cursor over the book.
5
+ *
6
+ * It reuses the toolbar's control definitions and its {@link ControlMenu} body, so the same icons
7
+ * and actions appear whether a reader reaches them from the toolbar or a right-click. It is opt-in
8
+ * (`contextMenu` off by default), because overriding the browser's own menu is a real cost to pay
9
+ * only when asked. Works with `controls: false` too, which is the point: a bare book with no
10
+ * toolbar can still offer a right-click menu.
11
+ */
12
+ export declare class ContextMenu {
13
+ #private;
14
+ constructor(zine: Zine, container: HTMLElement, options?: ContextMenuOptions, hidden?: ReadonlySet<string>);
15
+ destroy(): void;
16
+ }
@@ -1,8 +1,8 @@
1
- import type { ControlsOptions } from './types';
1
+ import type { ContextMenuOptions, ControlsOptions } from './types';
2
2
  import type { Zine } from '../zine';
3
3
  export { defineControl, getControl, DEFAULT_ITEMS } from './registry';
4
4
  export { ICONS, createIcon } from './icons';
5
- export type { ControlContext, ControlDef, ControlItem, ControlsColorScheme, ControlsOptions, ControlsPosition, } from './types';
5
+ export type { ContextMenuOptions, ControlContext, ControlDef, ControlItem, ControlsColorScheme, ControlsOptions, ControlsPosition, } from './types';
6
6
  /**
7
7
  * Build the toolbar inside `container` and return its teardown.
8
8
  *
@@ -11,4 +11,11 @@ export type { ControlContext, ControlDef, ControlItem, ControlsColorScheme, Cont
11
11
  * named `controls.ts` rather than `index.ts` so the emitted chunk is `controls-<hash>.js`, which
12
12
  * is what the size-limit budget watches.
13
13
  */
14
- export declare function mountControls(zine: Zine, container: HTMLElement, options?: ControlsOptions): () => void;
14
+ export declare function mountControls(zine: Zine, container: HTMLElement, options?: ControlsOptions, hidden?: ReadonlySet<string>): () => void;
15
+ /**
16
+ * Attach the right-click menu to `container` and return its teardown.
17
+ *
18
+ * Shares this lazy chunk with {@link mountControls}, so a book that wants only the context menu
19
+ * (`controls: false`, `contextMenu: true`) still reuses the same icons and control definitions.
20
+ */
21
+ export declare function mountContextMenu(zine: Zine, container: HTMLElement, options?: ContextMenuOptions, hidden?: ReadonlySet<string>): () => void;
@@ -18,9 +18,9 @@ export declare const ICONS: {
18
18
  first: string;
19
19
  /** chevrons-right */
20
20
  last: string;
21
- /** zoom-in */
21
+ /** circle-plus: zoom in. A plus, not a magnifier, so it never reads as a third "search". */
22
22
  zoomIn: string;
23
- /** zoom-out */
23
+ /** circle-minus: zoom out, paired with zoomIn. */
24
24
  zoomOut: string;
25
25
  /** search */
26
26
  search: string;
@@ -0,0 +1,68 @@
1
+ import type { ControlContext, ControlDef, ControlItem } from './types';
2
+ /**
3
+ * Drop every control whose id is in `hidden`, the one filter behind the top-level `hideControls`
4
+ * option. Applied at each build point so a named control disappears from the toolbar, its overflow
5
+ * menu and the right-click menu at once. Separators are left in place; menus drop them anyway and
6
+ * the toolbar tidies its own runs after filtering.
7
+ */
8
+ export declare function filterHidden(items: readonly ControlItem[], hidden: ReadonlySet<string>): readonly ControlItem[];
9
+ /**
10
+ * The shared menu machinery.
11
+ *
12
+ * A `.zine-controls-menu` is built the same way in two places: the toolbar's overflow (the ⋮
13
+ * button) and the right-click context menu. Rather than let those drift, both build their items,
14
+ * read their state, and run their actions through the helpers here, so a menu looks and behaves
15
+ * identically wherever it is anchored. Placement and dismissal stay with each owner, because a
16
+ * popover hanging off a toolbar button and one opening at the cursor differ only there.
17
+ */
18
+ /** A control's label, which may depend on the current state. */
19
+ export declare function titleOf(def: ControlDef, ctx: ControlContext): string;
20
+ /** A control's glyph, which may likewise depend on state (an RTL mirror, say). */
21
+ export declare function iconOf(def: ControlDef, ctx: ControlContext): string | undefined;
22
+ /** The element itself if it can be disabled, else any inputs/buttons it wraps. */
23
+ export declare function formFields(el: HTMLElement): (HTMLButtonElement | HTMLInputElement)[];
24
+ /** Toggle the pending-action state on a button: a trailing spinner, disabled while it spins. */
25
+ export declare function setBusy(btn: HTMLButtonElement, busy: boolean): void;
26
+ /**
27
+ * Run a control's action, holding the button busy while a slow one (a cross-origin file fetched to
28
+ * save or print) settles. `onAfter` re-reads state once the action has run, and again when a
29
+ * pending one finishes. A disabled or already-busy button does nothing.
30
+ */
31
+ export declare function activateControl(def: ControlDef, trigger: HTMLButtonElement, ctx: ControlContext, onAfter: () => void): void;
32
+ /**
33
+ * A control button. Bar buttons are icon-only with the title as tooltip; menu items and any
34
+ * control without an icon also carry a visible label. `onActivate` is left to the caller so the
35
+ * toolbar can route a press through its submenu logic while a menu item just acts.
36
+ */
37
+ export declare function buildControlButton(doc: Document, def: ControlDef, className: string, ctx: ControlContext, onActivate: (def: ControlDef, btn: HTMLButtonElement) => void, withLabel?: boolean): HTMLButtonElement;
38
+ /**
39
+ * Re-read one control's state after the book changes and reflect it on its element.
40
+ *
41
+ * Applies to custom `render` widgets as well as plain buttons: the predicates live on `ControlDef`,
42
+ * so a widget may be any element and each piece of state is applied however that element supports
43
+ * it. `visible` overrides the `isVisible` predicate for a control whose visibility is decided
44
+ * elsewhere (a submenu shown only when it has visible children).
45
+ */
46
+ export declare function applyControlItemState(el: HTMLElement, def: ControlDef, ctx: ControlContext, doc: Document, visible?: boolean): void;
47
+ /**
48
+ * A `.zine-controls-menu`: a column of control items with `role=menu`. Owns the items it builds so
49
+ * it can re-read their state and clean up after itself. The owner supplies the context (whose
50
+ * `close()` dismisses this menu) and drives when to build, refresh, and destroy; where the menu is
51
+ * placed and how it is dismissed are the owner's business.
52
+ */
53
+ export declare class ControlMenu {
54
+ #private;
55
+ readonly el: HTMLElement;
56
+ constructor(doc: Document, context: () => ControlContext, onAfterAction: () => void, hidden?: ReadonlySet<string>);
57
+ /** Fill the menu from a list of controls, dropping separators and anything not applicable. */
58
+ build(children: readonly ControlItem[]): void;
59
+ /** Re-read every item's state. */
60
+ refresh(): void;
61
+ /** Whether the menu (or something inside it) contains `node`. */
62
+ contains(node: Node | null): boolean;
63
+ focusFirst(): void;
64
+ destroy(): void;
65
+ }
66
+ /** A submenu is only worth showing if something inside it is. Keeps a menu trigger from opening
67
+ * onto nothing when every entry has hidden itself. */
68
+ export declare function hasVisibleChildren(def: ControlDef, ctx: ControlContext, hidden?: ReadonlySet<string>): boolean;
@@ -1,5 +1,16 @@
1
1
  import { type PanelOptions } from './sidebar';
2
- import type { Zine } from '../zine';
2
+ import type { SearchHit, Zine } from '../zine';
3
+ /** The query and its results, so a closed panel can be reopened where the reader left it rather
4
+ * than blank. Held by the toolbar (which outlives the panel) and handed back on reopen. */
5
+ export interface SearchState {
6
+ query: string;
7
+ results: SearchHit[];
8
+ }
9
+ /** Panel options plus the optional state to reopen with. */
10
+ export interface SearchOptions extends PanelOptions {
11
+ /** Query and results from a previous open, put back so the reader continues where they left off. */
12
+ state?: SearchState;
13
+ }
3
14
  /**
4
15
  * Full-text search beside the book: a query field over its results.
5
16
  *
@@ -11,6 +22,8 @@ export declare class Search {
11
22
  #private;
12
23
  /** The panel's themed root, so the toolbar can stamp its colour scheme. */
13
24
  readonly root: HTMLElement;
14
- constructor(zine: Zine, container: HTMLElement, options?: PanelOptions);
25
+ constructor(zine: Zine, container: HTMLElement, options?: SearchOptions);
26
+ /** The query and results as they stand, for the toolbar to stash across a close. */
27
+ getState(): SearchState;
15
28
  destroy(): void;
16
29
  }
@@ -1,4 +1,4 @@
1
- export declare const CSS = "\n/* Docked mode wraps the book so the bar can sit beside it without shrinking the container,\n whose measured box the renderer and hit-testing both depend on.\n The wrapper deliberately does not stretch or grow its children: the container keeps whatever\n width, aspect-ratio and auto-margins the consumer's own CSS gave it. */\n.zine-controls-wrap { display: flex; }\n/* Stacked: children keep their own width (so a percentage or auto-margin still resolves against\n the wrapper) and take only the height they ask for. */\n.zine-controls-wrap-top,\n.zine-controls-wrap-bottom { flex-direction: column; align-items: stretch; }\n/* Side by side: the bar is only as wide as its buttons, and both are vertically centred. */\n.zine-controls-wrap-left,\n.zine-controls-wrap-right { flex-direction: row; align-items: center; }\n/* min-height:0 matters as much as min-width here. A flex item's automatic minimum size floors it\n at its content, so once the canvas has grown for a taller layout the book cannot shrink back \u2014\n its aspect-ratio is restored but ignored, leaving the extra height behind. */\n.zine-controls-wrap > * { flex: 0 0 auto; min-width: 0; min-height: 0; }\n\n.zine-controls {\n position: relative;\n display: flex;\n gap: 4px;\n padding: 5px;\n z-index: 2;\n box-sizing: border-box;\n font: 500 12px/1.2 system-ui, sans-serif;\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n touch-action: auto;\n -webkit-user-select: none;\n user-select: none;\n}\n.zine-controls-docked { flex: 0 0 auto; justify-content: center; align-items: center; }\n.zine-controls-floating {\n position: absolute;\n /* Transparent to the book except on the buttons themselves, so gaps stay draggable. */\n pointer-events: none;\n}\n.zine-controls-bar {\n display: flex;\n align-items: center;\n gap: 1px;\n padding: 3px;\n border-radius: 8px;\n pointer-events: auto;\n background: var(--zine-controls-bg, light-dark(rgba(255, 255, 255, 0.94), rgba(24, 24, 27, 0.82)));\n backdrop-filter: blur(8px);\n}\n/* Floating: lifted off the page it covers. Docked: flat, with an outline so it reads as a\n control strip rather than a shadow hanging in empty space. */\n.zine-controls-floating .zine-controls-bar { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.28); }\n.zine-controls-docked .zine-controls-bar {\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.14)));\n}\n.zine-controls-floating.zine-controls-bottom { inset: auto 0 0 0; justify-content: center; }\n.zine-controls-floating.zine-controls-top { inset: 0 0 auto 0; justify-content: center; }\n.zine-controls-floating.zine-controls-left { inset: 0 auto 0 0; align-items: center; }\n.zine-controls-floating.zine-controls-right { inset: 0 0 0 auto; align-items: center; }\n/* A bar on a vertical edge stacks its buttons, docked or floating. */\n.zine-controls-left .zine-controls-bar,\n.zine-controls-right .zine-controls-bar { flex-direction: column; }\n\n.zine-controls-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n font: inherit;\n}\n.zine-controls-btn svg { width: 15px; height: 15px; }\n.zine-controls-btn:hover:not(:disabled) { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n.zine-controls-btn:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 1px;\n}\n.zine-controls-btn:disabled { opacity: 0.38; cursor: default; }\n/* A custom widget cannot always be disabled itself, so it is marked instead and reads the same. */\n.zine-controls-off { opacity: 0.38; }\n.zine-controls-off[aria-disabled='true'] { pointer-events: none; }\n.zine-controls-btn[aria-pressed=\"true\"],\n.zine-controls-btn[aria-expanded=\"true\"] { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n\n.zine-controls-sep {\n width: 1px;\n align-self: stretch;\n margin: 3px 3px;\n background: currentColor;\n opacity: 0.22;\n}\n.zine-controls-left .zine-controls-sep,\n.zine-controls-right .zine-controls-sep { width: auto; height: 1px; margin: 3px 3px; }\n\n.zine-controls-page {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 0 3px;\n pointer-events: auto;\n white-space: nowrap;\n}\n.zine-controls-page input {\n width: 2.2em;\n padding: 2px 3px;\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0,0,0,0.14), rgba(255,255,255,0.2)));\n border-radius: 4px;\n background: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.25));\n color: inherit;\n font: inherit;\n text-align: center;\n -moz-appearance: textfield;\n}\n.zine-controls-page input::-webkit-outer-spin-button,\n.zine-controls-page input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }\n.zine-controls-page input:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 0;\n}\n\n.zine-controls-menu {\n position: absolute;\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 152px;\n padding: 4px;\n border-radius: 8px;\n pointer-events: auto;\n background: var(--zine-controls-bg, light-dark(rgba(255, 255, 255, 0.94), rgba(24, 24, 27, 0.94)));\n box-shadow: 0 6px 22px rgba(0, 0, 0, 0.36);\n backdrop-filter: blur(8px);\n}\n.zine-controls-menu .zine-controls-btn {\n width: 100%;\n height: auto;\n gap: 8px;\n padding: 6px 8px;\n justify-content: flex-start;\n}\n\n/* Search shares the rail: a query field pinned at the top over a scrolling list of hits. */\n.zine-search { gap: 0; overflow: hidden; }\n.zine-search-input {\n flex: 0 0 auto;\n width: 100%;\n padding: 6px 8px;\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0,0,0,0.14), rgba(255,255,255,0.2)));\n border-radius: 6px;\n background: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.25));\n color: inherit;\n font: inherit;\n box-sizing: border-box;\n}\n/* Replace the browser's default (white) focus ring with the accent ring the other controls use. */\n.zine-search-input:focus-visible {\n outline: none;\n border-color: var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n box-shadow: 0 0 0 1px var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n}\n.zine-search-hits {\n flex: 1 1 auto;\n overflow-y: auto;\n /* Chain overscroll up to the page: once the list is at its end (or too short to scroll at all),\n a further wheel scrolls the page behind, the same as scrolling over the book itself does. */\n overscroll-behavior: auto;\n margin-top: 5px;\n scrollbar-width: thin;\n}\n.zine-search-hit {\n display: block;\n width: 100%;\n padding: 6px 8px;\n border: 0;\n border-radius: 5px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n box-sizing: border-box;\n}\n.zine-search-hit:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n.zine-search-hit:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n.zine-search-page { display: block; font-size: 0.9em; opacity: 0.85; }\n.zine-search-hit small {\n display: block;\n margin-top: 2px;\n opacity: 0.62;\n line-height: 1.35;\n overflow-wrap: anywhere;\n}\n\n/* Page-turn arrows flanking the book. Like the docked toolbar and the side panels, they sit\n outside the container so they never shrink the book or intercept its gestures. */\n.zine-arrows-wrap {\n display: flex;\n align-items: center;\n gap: 4px;\n min-width: 0;\n}\n.zine-arrows-wrap > *:not(.zine-arrow) { flex: 1 1 auto; min-width: 0; min-height: 0; }\n.zine-arrow {\n flex: 0 0 auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 44px;\n height: 44px;\n padding: 0;\n border: 0;\n background: none;\n /* Light on a light page, dark on a dark one. The shadow below carries the contrast. */\n color: var(--zine-controls-fg, light-dark(#f4f4f5, #18181b));\n cursor: pointer;\n -webkit-user-select: none;\n user-select: none;\n /* No grey tap box on touch; the :active tint below is the press feedback instead. */\n -webkit-tap-highlight-color: transparent;\n}\n.zine-arrow svg {\n width: 36px;\n height: 36px;\n stroke-width: 2.25;\n /* No backing disc, so a subtle offset shadow keeps the chevron from disappearing into a\n matching background. It contrasts the fill: a light chevron (light mode) casts a soft dark\n shadow, a dark chevron (dark mode) a soft light one, offset rather than a glowing halo. */\n filter: drop-shadow(0 1px 2px light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.35)));\n}\n/* Feedback intensifies the chevron toward its extreme (white in light mode, black in dark)\n rather than tinting it, so no new colour is introduced. Not tied to --zine-controls-fg: that\n sets the resting fill, and hover has to differ from it to read as feedback. :active clears on\n release, so tap and click feel the same; :hover is behind (hover: hover) so it never sticks on\n touch. */\n.zine-arrow:active:not(:disabled) { color: light-dark(#ffffff, #000000); }\n@media (hover: hover) {\n .zine-arrow:hover:not(:disabled) { color: light-dark(#ffffff, #000000); }\n}\n.zine-arrow:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 2px;\n border-radius: 8px;\n}\n/* Nothing to turn to: invisible, but still occupying its slot so the book does not slide across\n as the reader reaches a cover. */\n.zine-arrow-hidden { visibility: hidden; }\n/* Device-scoped arrows (controls.arrows: 'desktop' | 'mobile'). The split follows the same 640px\n breakpoint as the flank/overlay layouts below, so the arrows appear and disappear live as the\n window crosses it. */\n@media (max-width: 640px) {\n .zine-arrows-desktop > .zine-arrow { display: none; }\n}\n@media (min-width: 641px) {\n .zine-arrows-mobile > .zine-arrow { display: none; }\n}\n/* Too narrow to flank without squeezing the pages, so overlay the arrows on the book's edges\n instead. The wrap sits outside the container, so painting them over it leaves the container's\n measured box (which sizes the book and maps taps) untouched. */\n@media (max-width: 640px) {\n .zine-arrows-wrap { position: relative; }\n .zine-arrow {\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n z-index: 2;\n /* Sit back over the page so the reader can look at the book. */\n opacity: 0.7;\n }\n .zine-arrow:first-child { left: -2px; }\n .zine-arrow:last-child { right: -2px; }\n}\n@media (prefers-reduced-motion: no-preference) {\n .zine-arrow { transition: background 120ms ease; }\n}\n\n/* Side panels (thumbnails, outline, search). The rail wraps the book and its docked toolbar so it\n sits beside them as a flex sibling. On a wide screen the book shrinks to make room (below); on a\n narrow one the rail becomes a drawer over the book.\n align-items:flex-start, not stretch: the book sizes its height from its width through its own\n aspect-ratio, and that height is 'auto' from flex's point of view, so a stretch would override it\n and blow the book (and the rail matched to it) up to the flex line's cross size. Left as auto, the\n book keeps its aspect height and the holder alone stretches down to meet it (below). */\n.zine-panel-wrap { display: flex; align-items: flex-start; gap: 8px; position: relative; }\n\n/* The book-side slot (the bare container, or the docked-toolbar wrapper around it) yields space to\n the fixed-width rail instead of overflowing: flex-shrink lets it fall below its own width, and\n min-width:0 removes the automatic content floor that would otherwise stop it. The book only\n actually shrinks if its width is elastic (a %, max-width, or the demo's min(...) with a % term);\n a hard-coded pixel width has nothing for the % to resolve smaller against. */\n.zine-panel-wrap > *:not(.zine-panel-holder):not(.zine-panel-scrim) {\n flex: 0 1 auto;\n min-width: 0;\n}\n/* With a docked toolbar the book lives inside .zine-controls-wrap, whose children are pinned\n flex:0 0 auto (so the bar keeps its size). Inside a panel wrap the book child \u2014 everything but\n the bar itself \u2014 must instead be allowed to shrink, so left/right docking flanks like the rest.\n Top/bottom docking already shrinks through the container's own % width. */\n.zine-panel-wrap .zine-controls-wrap > *:not(.zine-controls) {\n flex: 0 1 auto;\n min-width: 0;\n min-height: 0;\n}\n@media (prefers-reduced-motion: no-preference) {\n .zine-panel-wrap > *:not(.zine-panel-holder):not(.zine-panel-scrim) {\n transition: flex-basis 160ms ease, width 160ms ease;\n }\n}\n\n/* The holder stretches to the book's height; the rail is absolutely positioned inside it so a\n long list scrolls rather than growing the row and running past the bottom of the book. */\n.zine-panel-holder {\n position: relative;\n flex: 0 0 auto;\n align-self: stretch;\n min-height: 0;\n}\n/* The scrim dims the book behind the narrow-screen drawer. It exists on every screen but only\n shows under the breakpoint below, so on a wide screen it takes no flex slot and never intercepts\n a click. */\n.zine-panel-scrim { display: none; }\n.zine-panel {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n overflow-y: auto;\n /* Chain overscroll up to the page: once the list is at its end (or too short to scroll at all),\n a further wheel scrolls the page behind, the same as scrolling over the book itself does. */\n overscroll-behavior: auto;\n /* Reserve the scrollbar's lane whether or not it is showing, so rows never sit under the bar\n and the close button (inset past this lane below) has a fixed edge to clear. */\n scrollbar-gutter: stable;\n padding: 6px;\n box-sizing: border-box;\n border-radius: 8px;\n background: var(--zine-controls-bg, light-dark(rgba(255, 255, 255, 0.94), rgba(24, 24, 27, 0.82)));\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n font: 500 11px/1.2 system-ui, sans-serif;\n scrollbar-width: thin;\n touch-action: auto;\n -webkit-user-select: none;\n user-select: none;\n}\n/* The rail sits flush against the book's reading-start edge (left, or right in RTL), so its outer\n corners there would round away from that edge and leave a gap. Square them; keep the interior\n corners, which face the page, rounded. */\n.zine-panel { border-radius: 0 8px 8px 0; }\n.zine-panel-wrap-rtl .zine-panel { border-radius: 8px 0 0 8px; }\n/* Overlay fallback, used when the container has no parent to wrap. */\n.zine-panel-overlay { position: absolute; inset: 0 auto 0 0; z-index: 3; }\n.zine-panel-active { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.18))); }\n.zine-panel-note { padding: 8px; opacity: 0.66; line-height: 1.4; }\n/* Discoverable dismiss, floated in the rail's trailing-top (interior) corner, away from the book.\n It sits in the holder above the scrolling list so the list scrolls under it. A translucent\n backdrop keeps the glyph legible over a thumbnail or a line of text. */\n.zine-panel-close {\n position: absolute;\n top: 6px;\n /* Inset past the reserved scrollbar gutter so the button never overlaps the bar. */\n right: 12px;\n z-index: 1;\n display: inline-flex;\n width: 24px;\n height: 24px;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 0;\n border-radius: 6px;\n /* Opaque so a light thumbnail underneath never shows through and dims the glyph. */\n background: light-dark(#f4f4f5, #27272a);\n color: inherit;\n cursor: pointer;\n}\n.zine-panel-close svg { width: 15px; height: 15px; }\n/* Opaque hover, not the translucent hover token: a see-through backdrop over a light thumbnail\n would wash the glyph out. Solid neutrals keep the X readable in both themes over anything. */\n.zine-panel-close:hover { background: light-dark(#e4e4e7, #3f3f46); }\n.zine-panel-close:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n.zine-panel-wrap-rtl .zine-panel-close { right: auto; left: 12px; }\n/* End the search field just before the close button rather than running under it, so the two sit\n side by side and the field's border reads cleanly. The button's inner edge is ~30px from the\n panel's content edge (24px wide, its right edge 6px in once the reserved scrollbar gutter is\n accounted for), so 30px leaves them flush with a hair of breathing room. width:auto lets the\n column's stretch fill the rest, so the margin shortens the field instead of overflowing 100%. */\n.zine-search .zine-search-input { width: auto; align-self: stretch; margin-right: 30px; }\n.zine-panel-wrap-rtl .zine-search .zine-search-input { margin-right: 0; margin-left: 30px; }\n\n/* Overlay panels (outline, search) on a wide screen: float over the book's start edge instead of\n flanking it, so the book never resizes. The wrap drops to a block, laying the book out exactly as\n it was before the panel opened; the holder sits absolute over it at full book height, with a\n shadow so it reads as lifted off the page. thumbnails is not overlaid \u2014 it stays a flex flank and\n shrinks the book only when the two will not otherwise both fit. */\n@media (min-width: 641px) {\n .zine-panel-wrap-overlay { display: block; }\n .zine-panel-wrap-overlay .zine-panel-holder {\n position: absolute;\n inset: 0 auto 0 0;\n z-index: 4;\n box-shadow: 0 0 24px light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.55));\n }\n .zine-panel-wrap-overlay.zine-panel-wrap-rtl .zine-panel-holder { inset: 0 0 0 auto; }\n /* Opaque over the page it floats on, unlike a flanking rail that sits against empty margin. */\n .zine-panel-wrap-overlay .zine-panel {\n background: var(--zine-controls-bg, light-dark(#ffffff, #18181b));\n }\n /* A click-away layer over the book, so pressing the page behind the floating rail closes it the\n way tapping the scrim does on a narrow screen. Transparent here \u2014 the wide-screen rail only\n covers an edge, so there is no need to dim the page the reader is still looking at. It sits\n under the holder (z-index) so the rail's own rows still take their clicks. */\n .zine-panel-wrap-overlay .zine-panel-scrim {\n display: block;\n position: absolute;\n inset: 0;\n z-index: 3;\n background: transparent;\n }\n}\n@media (min-width: 641px) and (prefers-reduced-motion: no-preference) {\n .zine-panel-wrap-overlay .zine-panel-holder { animation: zine-drawer-in 180ms ease; }\n .zine-panel-wrap-overlay.zine-panel-wrap-rtl .zine-panel-holder { animation-name: zine-drawer-in-rtl; }\n}\n\n/* Narrow screen: no room to flank, so the rail becomes a drawer over the book and the scrim dims\n the page behind it. The wrap is position:relative, so the absolute holder and scrim below are\n measured against it \u2014 i.e. against the book's own box. */\n@media (max-width: 640px) {\n /* No flanking here \u2014 the rail and scrim both overlay absolutely \u2014 so drop the flex row entirely.\n As a plain block wrap the book keeps the exact box it had before a panel opened (its own width,\n margin and aspect-ratio height); a flex row would instead re-resolve the book's width and, with\n it, its aspect-derived height, enlarging the canvas and pushing the page down. */\n .zine-panel-wrap {\n display: block;\n }\n .zine-panel-holder {\n position: absolute;\n inset: 0 auto 0 0;\n z-index: 4;\n /* Cap the drawer so it never eats the whole book; its inline width still applies under this. */\n max-width: 80%;\n box-shadow: 0 0 24px light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.6));\n }\n /* RTL reads from the right, so the drawer enters from there. */\n .zine-panel-wrap-rtl .zine-panel-holder { inset: 0 0 0 auto; }\n /* Opaque over the page, unlike the wide-screen rail which can sit against empty margin. */\n .zine-panel {\n background: var(--zine-controls-bg, light-dark(#ffffff, #18181b));\n }\n .zine-panel-scrim {\n display: block;\n position: absolute;\n inset: 0;\n z-index: 3;\n background: rgba(0, 0, 0, 0.4);\n }\n}\n@media (max-width: 640px) and (prefers-reduced-motion: no-preference) {\n .zine-panel-holder { animation: zine-drawer-in 180ms ease; }\n .zine-panel-wrap-rtl .zine-panel-holder { animation-name: zine-drawer-in-rtl; }\n .zine-panel-scrim { animation: zine-scrim-in 180ms ease; }\n}\n@keyframes zine-drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }\n@keyframes zine-drawer-in-rtl { from { transform: translateX(100%); } to { transform: translateX(0); } }\n@keyframes zine-scrim-in { from { opacity: 0; } to { opacity: 1; } }\n\n.zine-thumbs { align-items: center; }\n\n.zine-thumbs-row {\n display: flex;\n flex-wrap: wrap;\n justify-content: center;\n gap: 2px;\n width: 100%;\n padding: 4px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: inherit;\n font: inherit;\n cursor: pointer;\n box-sizing: border-box;\n}\n.zine-thumbs-row:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n.zine-thumbs-row:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n.zine-thumbs-row.zine-panel-active .zine-thumbs-cell {\n outline: 1px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n}\n\n/* Outline: a single column of headings, indented by depth. */\n/* No gap: a hairline between rows is the separator instead, so a title that wraps to two lines\n still reads as one entry rather than blending into its neighbours. */\n.zine-outline { gap: 0; }\n.zine-outline-row {\n display: block;\n width: 100%;\n padding: 6px 8px;\n border: 0;\n border-bottom: 1px solid var(--zine-controls-divider, light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.1)));\n border-radius: 5px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n line-height: 1.35;\n cursor: pointer;\n box-sizing: border-box;\n overflow-wrap: anywhere;\n}\n.zine-outline-row:last-child { border-bottom: 0; }\n.zine-outline-row:hover:not(:disabled) {\n background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14)));\n}\n.zine-outline-row:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n/* A heading whose destination could not be resolved: shown, but nothing to click through to. */\n.zine-outline-row:disabled { opacity: 0.5; cursor: default; }\n\n.zine-thumbs-cell {\n flex: 1 1 0;\n min-width: 0;\n min-height: 24px;\n display: flex;\n background: light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.3));\n border-radius: 2px;\n overflow: hidden;\n}\n/* A lone page in a book that pairs elsewhere (a cover, a back page) keeps one page's width and\n centres, instead of stretching across both columns. */\n.zine-thumbs-row-lone .zine-thumbs-cell { flex: 0 0 calc(50% - 1px); }\n.zine-thumbs-img { display: block; width: 100%; height: auto; }\n.zine-thumbs-caption { flex: 1 0 100%; text-align: center; opacity: 0.7; }\n\n/* Share dialog. Centred over the page rather than in the side rail: a QR and six destinations\n need more room than the rail gives, and sharing is a brief interruption, not a browsing mode. */\n.zine-share-backdrop {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n background: rgba(0, 0, 0, 0.55);\n font: 500 13px/1.4 system-ui, sans-serif;\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n}\n.zine-share {\n width: min(320px, 100%);\n max-height: 100%;\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 14px;\n border-radius: 12px;\n background: var(--zine-share-bg, light-dark(#ffffff, #1c1c20));\n box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);\n box-sizing: border-box;\n}\n.zine-share-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n.zine-share-head strong { font-size: 15px; }\n.zine-share-close {\n display: inline-flex;\n width: 26px;\n height: 26px;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n.zine-share-close svg { width: 16px; height: 16px; }\n.zine-share-close:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n\n.zine-share-qr {\n align-self: center;\n line-height: 0;\n padding: 8px;\n border-radius: 8px;\n background: #fff;\n}\n.zine-share-qr svg { display: block; }\n\n.zine-share-link { display: flex; gap: 6px; }\n.zine-share-link input {\n flex: 1 1 auto;\n min-width: 0;\n padding: 7px 9px;\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0,0,0,0.14), rgba(255,255,255,0.2)));\n border-radius: 7px;\n background: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.3));\n color: inherit;\n font: inherit;\n}\n.zine-share-copy {\n flex: 0 0 auto;\n min-width: 74px;\n padding: 7px 12px;\n border: 0;\n border-radius: 7px;\n background: var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n color: light-dark(#ffffff, #06202c);\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n}\n\n.zine-share-socials { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }\n.zine-share-social {\n display: inline-flex;\n width: 38px;\n height: 38px;\n align-items: center;\n justify-content: center;\n border-radius: 8px;\n background: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.08));\n color: inherit;\n text-decoration: none;\n}\n.zine-share-social svg { width: 19px; height: 19px; }\n.zine-share-social:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.12), rgba(255,255,255,0.18))); }\n.zine-share-close:focus-visible,\n.zine-share-copy:focus-visible,\n.zine-share-social:focus-visible,\n.zine-share-link input:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .zine-controls-btn { transition: background 120ms ease; }\n}\n";
1
+ export declare const CSS = "\n/* Docked mode wraps the book so the bar can sit beside it without shrinking the container,\n whose measured box the renderer and hit-testing both depend on.\n The wrapper deliberately does not stretch or grow its children: the container keeps whatever\n width, aspect-ratio and auto-margins the consumer's own CSS gave it. */\n.zine-controls-wrap { display: flex; }\n/* Stacked: children keep their own width (so a percentage or auto-margin still resolves against\n the wrapper) and take only the height they ask for. */\n.zine-controls-wrap-top,\n.zine-controls-wrap-bottom { flex-direction: column; align-items: stretch; }\n/* Side by side: the bar is only as wide as its buttons, and both are vertically centred. */\n.zine-controls-wrap-left,\n.zine-controls-wrap-right { flex-direction: row; align-items: center; }\n/* min-height:0 matters as much as min-width here. A flex item's automatic minimum size floors it\n at its content, so once the canvas has grown for a taller layout the book cannot shrink back \u2014\n its aspect-ratio is restored but ignored, leaving the extra height behind. */\n.zine-controls-wrap > * { flex: 0 0 auto; min-width: 0; min-height: 0; }\n\n.zine-controls {\n position: relative;\n display: flex;\n gap: 4px;\n padding: 5px;\n z-index: 2;\n box-sizing: border-box;\n font: 500 12px/1.2 system-ui, sans-serif;\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n touch-action: auto;\n -webkit-user-select: none;\n user-select: none;\n}\n.zine-controls-docked { flex: 0 0 auto; justify-content: center; align-items: center; }\n.zine-controls-floating {\n position: absolute;\n /* Transparent to the book except on the buttons themselves, so gaps stay draggable. */\n pointer-events: none;\n}\n.zine-controls-bar {\n display: flex;\n align-items: center;\n gap: 1px;\n padding: 3px;\n border-radius: 8px;\n pointer-events: auto;\n background: var(--zine-controls-bg, light-dark(rgba(255, 255, 255, 0.94), rgba(24, 24, 27, 0.82)));\n backdrop-filter: blur(8px);\n}\n/* Floating: lifted off the page it covers. Docked: flat, with an outline so it reads as a\n control strip rather than a shadow hanging in empty space. */\n.zine-controls-floating .zine-controls-bar { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.28); }\n.zine-controls-docked .zine-controls-bar {\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.14)));\n}\n.zine-controls-floating.zine-controls-bottom { inset: auto 0 0 0; justify-content: center; }\n.zine-controls-floating.zine-controls-top { inset: 0 0 auto 0; justify-content: center; }\n.zine-controls-floating.zine-controls-left { inset: 0 auto 0 0; align-items: center; }\n.zine-controls-floating.zine-controls-right { inset: 0 0 0 auto; align-items: center; }\n/* A bar on a vertical edge stacks its buttons, docked or floating. */\n.zine-controls-left .zine-controls-bar,\n.zine-controls-right .zine-controls-bar { flex-direction: column; }\n\n.zine-controls-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n font: inherit;\n}\n.zine-controls-btn svg { width: 15px; height: 15px; }\n.zine-controls-btn:hover:not(:disabled) { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n.zine-controls-btn:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 1px;\n}\n.zine-controls-btn:disabled { opacity: 0.38; cursor: default; }\n/* A button mid-slow-action (fetching a file to save or print): a spinner, kept full-strength\n rather than dimmed so it reads as \"working\", not \"disabled\". */\n.zine-controls-btn.zine-controls-busy { opacity: 1; cursor: default; position: relative; }\n.zine-controls-btn.zine-controls-busy::after {\n content: '';\n box-sizing: border-box;\n width: 14px;\n height: 14px;\n border: 2px solid currentColor;\n border-top-color: transparent;\n border-radius: 50%;\n opacity: 0.7;\n animation: zine-controls-spin 0.7s linear infinite;\n}\n/* Icon-only bar button: hide the glyph and center the spinner over its place. */\n.zine-controls-btn.zine-controls-busy > svg { visibility: hidden; }\n.zine-controls-btn.zine-controls-busy::after {\n position: absolute;\n top: 50%;\n left: 50%;\n margin: -7px 0 0 -7px;\n}\n/* Menu item (glyph + label): let the glyph and label stand, and trail the spinner after them. */\n.zine-controls-menu .zine-controls-btn.zine-controls-busy > svg { visibility: visible; }\n.zine-controls-menu .zine-controls-btn.zine-controls-busy::after { position: static; }\n@keyframes zine-controls-spin { to { transform: rotate(360deg); } }\n@media (prefers-reduced-motion: reduce) {\n .zine-controls-btn.zine-controls-busy::after { animation-duration: 1.6s; }\n}\n/* A custom widget cannot always be disabled itself, so it is marked instead and reads the same. */\n.zine-controls-off { opacity: 0.38; }\n.zine-controls-off[aria-disabled='true'] { pointer-events: none; }\n.zine-controls-btn[aria-pressed=\"true\"],\n.zine-controls-btn[aria-expanded=\"true\"] { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n\n.zine-controls-sep {\n width: 1px;\n align-self: stretch;\n margin: 3px 3px;\n background: currentColor;\n opacity: 0.22;\n}\n.zine-controls-left .zine-controls-sep,\n.zine-controls-right .zine-controls-sep { width: auto; height: 1px; margin: 3px 3px; }\n\n.zine-controls-page {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 0 3px;\n pointer-events: auto;\n white-space: nowrap;\n}\n.zine-controls-page input {\n width: 2.2em;\n padding: 2px 3px;\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0,0,0,0.14), rgba(255,255,255,0.2)));\n border-radius: 4px;\n background: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.25));\n color: inherit;\n font: inherit;\n text-align: center;\n -moz-appearance: textfield;\n}\n.zine-controls-page input::-webkit-outer-spin-button,\n.zine-controls-page input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }\n.zine-controls-page input:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 0;\n}\n\n.zine-controls-menu {\n position: absolute;\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 152px;\n padding: 4px;\n border-radius: 8px;\n pointer-events: auto;\n background: var(--zine-controls-bg, light-dark(rgba(255, 255, 255, 0.94), rgba(24, 24, 27, 0.94)));\n box-shadow: 0 6px 22px rgba(0, 0, 0, 0.36);\n backdrop-filter: blur(8px);\n}\n.zine-controls-menu .zine-controls-btn {\n width: 100%;\n height: auto;\n gap: 8px;\n padding: 6px 8px;\n justify-content: flex-start;\n}\n\n/* The right-click menu opens in a fixed, viewport-spanning layer so the menu's absolute left/top\n are plain client coordinates \u2014 it lands under the cursor without the container having to be a\n positioning context. The layer itself is click-through; only the menu inside it takes events. */\n.zine-context-layer {\n position: fixed;\n inset: 0;\n z-index: 5;\n pointer-events: none;\n font: 500 12px/1.2 system-ui, sans-serif;\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n -webkit-user-select: none;\n user-select: none;\n}\n\n/* Search shares the rail: a query field pinned at the top over a scrolling list of hits. */\n.zine-search { gap: 0; overflow: hidden; }\n.zine-search-input {\n flex: 0 0 auto;\n width: 100%;\n padding: 6px 8px;\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0,0,0,0.14), rgba(255,255,255,0.2)));\n border-radius: 6px;\n background: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.25));\n color: inherit;\n font: inherit;\n box-sizing: border-box;\n}\n/* Replace the browser's default (white) focus ring with the accent ring the other controls use. */\n.zine-search-input:focus-visible {\n outline: none;\n border-color: var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n box-shadow: 0 0 0 1px var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n}\n.zine-search-hits {\n flex: 1 1 auto;\n overflow-y: auto;\n /* Chain overscroll up to the page: once the list is at its end (or too short to scroll at all),\n a further wheel scrolls the page behind, the same as scrolling over the book itself does. */\n overscroll-behavior: auto;\n margin-top: 5px;\n scrollbar-width: thin;\n}\n.zine-search-hit {\n display: block;\n width: 100%;\n padding: 6px 8px;\n border: 0;\n border-radius: 5px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n box-sizing: border-box;\n}\n.zine-search-hit:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n.zine-search-hit:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n.zine-search-page { display: block; font-size: 0.9em; opacity: 0.85; }\n.zine-search-hit small {\n display: block;\n margin-top: 2px;\n opacity: 0.62;\n line-height: 1.35;\n overflow-wrap: anywhere;\n}\n\n/* Page-turn arrows flanking the book. Like the docked toolbar and the side panels, they sit\n outside the container so they never shrink the book or intercept its gestures. */\n.zine-arrows-wrap {\n display: flex;\n align-items: center;\n gap: 4px;\n min-width: 0;\n}\n.zine-arrows-wrap > *:not(.zine-arrow) { flex: 1 1 auto; min-width: 0; min-height: 0; }\n.zine-arrow {\n flex: 0 0 auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 44px;\n height: 44px;\n padding: 0;\n border: 0;\n background: none;\n /* Light on a light page, dark on a dark one. The shadow below carries the contrast. */\n color: var(--zine-controls-fg, light-dark(#f4f4f5, #18181b));\n cursor: pointer;\n -webkit-user-select: none;\n user-select: none;\n /* No grey tap box on touch; the :active tint below is the press feedback instead. */\n -webkit-tap-highlight-color: transparent;\n}\n.zine-arrow svg {\n width: 36px;\n height: 36px;\n stroke-width: 2.25;\n /* No backing disc, so a subtle offset shadow keeps the chevron from disappearing into a\n matching background. It contrasts the fill: a light chevron (light mode) casts a soft dark\n shadow, a dark chevron (dark mode) a soft light one, offset rather than a glowing halo. */\n filter: drop-shadow(0 1px 2px light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.35)));\n}\n/* Feedback intensifies the chevron toward its extreme (white in light mode, black in dark)\n rather than tinting it, so no new colour is introduced. Not tied to --zine-controls-fg: that\n sets the resting fill, and hover has to differ from it to read as feedback. :active clears on\n release, so tap and click feel the same; :hover is behind (hover: hover) so it never sticks on\n touch. */\n.zine-arrow:active:not(:disabled) { color: light-dark(#ffffff, #000000); }\n@media (hover: hover) {\n .zine-arrow:hover:not(:disabled) { color: light-dark(#ffffff, #000000); }\n}\n.zine-arrow:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 2px;\n border-radius: 8px;\n}\n/* Nothing to turn to: invisible, but still occupying its slot so the book does not slide across\n as the reader reaches a cover. */\n.zine-arrow-hidden { visibility: hidden; }\n/* Device-scoped arrows (controls.arrows: 'desktop' | 'mobile'). The split follows the same 640px\n breakpoint as the flank/overlay layouts below, so the arrows appear and disappear live as the\n window crosses it. */\n@media (max-width: 640px) {\n .zine-arrows-desktop > .zine-arrow { display: none; }\n}\n@media (min-width: 641px) {\n .zine-arrows-mobile > .zine-arrow { display: none; }\n}\n/* Too narrow to flank without squeezing the pages, so overlay the arrows on the book's edges\n instead. The wrap sits outside the container, so painting them over it leaves the container's\n measured box (which sizes the book and maps taps) untouched. */\n@media (max-width: 640px) {\n .zine-arrows-wrap { position: relative; }\n .zine-arrow {\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n z-index: 2;\n /* Sit back over the page so the reader can look at the book. */\n opacity: 0.7;\n }\n .zine-arrow:first-child { left: -2px; }\n .zine-arrow:last-child { right: -2px; }\n}\n@media (prefers-reduced-motion: no-preference) {\n .zine-arrow { transition: background 120ms ease; }\n}\n\n/* Side panels (thumbnails, outline, search). The rail wraps the book and its docked toolbar so it\n sits beside them as a flex sibling. On a wide screen the book shrinks to make room (below); on a\n narrow one the rail becomes a drawer over the book.\n align-items:flex-start, not stretch: the book sizes its height from its width through its own\n aspect-ratio, and that height is 'auto' from flex's point of view, so a stretch would override it\n and blow the book (and the rail matched to it) up to the flex line's cross size. Left as auto, the\n book keeps its aspect height and the holder alone stretches down to meet it (below). */\n.zine-panel-wrap { display: flex; align-items: flex-start; gap: 8px; position: relative; }\n\n/* The book-side slot (the bare container, or the docked-toolbar wrapper around it) yields space to\n the fixed-width rail instead of overflowing: flex-shrink lets it fall below its own width, and\n min-width:0 removes the automatic content floor that would otherwise stop it. The book only\n actually shrinks if its width is elastic (a %, max-width, or the demo's min(...) with a % term);\n a hard-coded pixel width has nothing for the % to resolve smaller against. */\n.zine-panel-wrap > *:not(.zine-panel-holder):not(.zine-panel-scrim) {\n flex: 0 1 auto;\n min-width: 0;\n}\n/* With a docked toolbar the book lives inside .zine-controls-wrap, whose children are pinned\n flex:0 0 auto (so the bar keeps its size). Inside a panel wrap the book child \u2014 everything but\n the bar itself \u2014 must instead be allowed to shrink, so left/right docking flanks like the rest.\n Top/bottom docking already shrinks through the container's own % width. */\n.zine-panel-wrap .zine-controls-wrap > *:not(.zine-controls) {\n flex: 0 1 auto;\n min-width: 0;\n min-height: 0;\n}\n@media (prefers-reduced-motion: no-preference) {\n .zine-panel-wrap > *:not(.zine-panel-holder):not(.zine-panel-scrim) {\n transition: flex-basis 160ms ease, width 160ms ease;\n }\n}\n\n/* The holder stretches to the book's height; the rail is absolutely positioned inside it so a\n long list scrolls rather than growing the row and running past the bottom of the book. */\n.zine-panel-holder {\n position: relative;\n flex: 0 0 auto;\n align-self: stretch;\n min-height: 0;\n}\n/* The scrim dims the book behind the narrow-screen drawer. It exists on every screen but only\n shows under the breakpoint below, so on a wide screen it takes no flex slot and never intercepts\n a click. */\n.zine-panel-scrim { display: none; }\n.zine-panel {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n overflow-y: auto;\n /* Chain overscroll up to the page: once the list is at its end (or too short to scroll at all),\n a further wheel scrolls the page behind, the same as scrolling over the book itself does. */\n overscroll-behavior: auto;\n /* Reserve the scrollbar's lane whether or not it is showing, so rows never sit under the bar\n and the close button (inset past this lane below) has a fixed edge to clear. */\n scrollbar-gutter: stable;\n padding: 6px;\n box-sizing: border-box;\n border-radius: 8px;\n background: var(--zine-controls-bg, light-dark(rgba(255, 255, 255, 0.94), rgba(24, 24, 27, 0.82)));\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n font: 500 11px/1.2 system-ui, sans-serif;\n scrollbar-width: thin;\n touch-action: auto;\n -webkit-user-select: none;\n user-select: none;\n}\n/* The rail sits flush against the book's reading-start edge (left, or right in RTL), so its outer\n corners there would round away from that edge and leave a gap. Square them; keep the interior\n corners, which face the page, rounded. */\n.zine-panel { border-radius: 0 8px 8px 0; }\n.zine-panel-wrap-rtl .zine-panel { border-radius: 8px 0 0 8px; }\n/* Overlay fallback, used when the container has no parent to wrap. */\n.zine-panel-overlay { position: absolute; inset: 0 auto 0 0; z-index: 3; }\n.zine-panel-active { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.18))); }\n.zine-panel-note { padding: 8px; opacity: 0.66; line-height: 1.4; }\n/* Discoverable dismiss, floated in the rail's trailing-top (interior) corner, away from the book.\n It sits in the holder above the scrolling list so the list scrolls under it. A translucent\n backdrop keeps the glyph legible over a thumbnail or a line of text. */\n.zine-panel-close {\n position: absolute;\n top: 6px;\n /* Inset past the reserved scrollbar gutter so the button never overlaps the bar. */\n right: 12px;\n z-index: 1;\n display: inline-flex;\n width: 24px;\n height: 24px;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 0;\n border-radius: 6px;\n /* Opaque so a light thumbnail underneath never shows through and dims the glyph. */\n background: light-dark(#f4f4f5, #27272a);\n color: inherit;\n cursor: pointer;\n}\n.zine-panel-close svg { width: 15px; height: 15px; }\n/* Opaque hover, not the translucent hover token: a see-through backdrop over a light thumbnail\n would wash the glyph out. Solid neutrals keep the X readable in both themes over anything. */\n.zine-panel-close:hover { background: light-dark(#e4e4e7, #3f3f46); }\n.zine-panel-close:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n.zine-panel-wrap-rtl .zine-panel-close { right: auto; left: 12px; }\n/* End the search field just before the close button rather than running under it, so the two sit\n side by side and the field's border reads cleanly. The button's inner edge is ~30px from the\n panel's content edge (24px wide, its right edge 6px in once the reserved scrollbar gutter is\n accounted for), so 30px leaves them flush with a hair of breathing room. width:auto lets the\n column's stretch fill the rest, so the margin shortens the field instead of overflowing 100%. */\n.zine-search .zine-search-input { width: auto; align-self: stretch; margin-right: 30px; }\n.zine-panel-wrap-rtl .zine-search .zine-search-input { margin-right: 0; margin-left: 30px; }\n\n/* Overlay panels (outline, search) on a wide screen: float over the book's start edge instead of\n flanking it, so the book never resizes. The wrap drops to a block, laying the book out exactly as\n it was before the panel opened; the holder sits absolute over it at full book height, with a\n shadow so it reads as lifted off the page. thumbnails is not overlaid \u2014 it stays a flex flank and\n shrinks the book only when the two will not otherwise both fit. */\n@media (min-width: 641px) {\n .zine-panel-wrap-overlay { display: block; }\n .zine-panel-wrap-overlay .zine-panel-holder {\n position: absolute;\n inset: 0 auto 0 0;\n z-index: 4;\n box-shadow: 0 0 24px light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.55));\n }\n .zine-panel-wrap-overlay.zine-panel-wrap-rtl .zine-panel-holder { inset: 0 0 0 auto; }\n /* Opaque over the page it floats on, unlike a flanking rail that sits against empty margin. */\n .zine-panel-wrap-overlay .zine-panel {\n background: var(--zine-controls-bg, light-dark(#ffffff, #18181b));\n }\n /* A click-away layer over the book, so pressing the page behind the floating rail closes it the\n way tapping the scrim does on a narrow screen. Transparent here \u2014 the wide-screen rail only\n covers an edge, so there is no need to dim the page the reader is still looking at. It sits\n under the holder (z-index) so the rail's own rows still take their clicks. */\n .zine-panel-wrap-overlay .zine-panel-scrim {\n display: block;\n position: absolute;\n inset: 0;\n z-index: 3;\n background: transparent;\n }\n}\n@media (min-width: 641px) and (prefers-reduced-motion: no-preference) {\n .zine-panel-wrap-overlay .zine-panel-holder { animation: zine-drawer-in 180ms ease; }\n .zine-panel-wrap-overlay.zine-panel-wrap-rtl .zine-panel-holder { animation-name: zine-drawer-in-rtl; }\n}\n\n/* Narrow screen: no room to flank, so the rail becomes a drawer over the book and the scrim dims\n the page behind it. The wrap is position:relative, so the absolute holder and scrim below are\n measured against it \u2014 i.e. against the book's own box. */\n@media (max-width: 640px) {\n /* No flanking here \u2014 the rail and scrim both overlay absolutely \u2014 so drop the flex row entirely.\n As a plain block wrap the book keeps the exact box it had before a panel opened (its own width,\n margin and aspect-ratio height); a flex row would instead re-resolve the book's width and, with\n it, its aspect-derived height, enlarging the canvas and pushing the page down. */\n .zine-panel-wrap {\n display: block;\n }\n .zine-panel-holder {\n position: absolute;\n inset: 0 auto 0 0;\n z-index: 4;\n /* Cap the drawer so it never eats the whole book; its inline width still applies under this. */\n max-width: 80%;\n box-shadow: 0 0 24px light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.6));\n }\n /* RTL reads from the right, so the drawer enters from there. */\n .zine-panel-wrap-rtl .zine-panel-holder { inset: 0 0 0 auto; }\n /* Opaque over the page, unlike the wide-screen rail which can sit against empty margin. */\n .zine-panel {\n background: var(--zine-controls-bg, light-dark(#ffffff, #18181b));\n }\n .zine-panel-scrim {\n display: block;\n position: absolute;\n inset: 0;\n z-index: 3;\n background: rgba(0, 0, 0, 0.4);\n }\n}\n@media (max-width: 640px) and (prefers-reduced-motion: no-preference) {\n .zine-panel-holder { animation: zine-drawer-in 180ms ease; }\n .zine-panel-wrap-rtl .zine-panel-holder { animation-name: zine-drawer-in-rtl; }\n .zine-panel-scrim { animation: zine-scrim-in 180ms ease; }\n}\n@keyframes zine-drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }\n@keyframes zine-drawer-in-rtl { from { transform: translateX(100%); } to { transform: translateX(0); } }\n@keyframes zine-scrim-in { from { opacity: 0; } to { opacity: 1; } }\n\n.zine-thumbs { align-items: center; }\n\n.zine-thumbs-row {\n display: flex;\n flex-wrap: wrap;\n justify-content: center;\n gap: 2px;\n width: 100%;\n padding: 4px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: inherit;\n font: inherit;\n cursor: pointer;\n box-sizing: border-box;\n}\n.zine-thumbs-row:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n.zine-thumbs-row:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n.zine-thumbs-row.zine-panel-active .zine-thumbs-cell {\n outline: 1px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n}\n\n/* Outline: a single column of headings, indented by depth. */\n/* No gap: a hairline between rows is the separator instead, so a title that wraps to two lines\n still reads as one entry rather than blending into its neighbours. */\n.zine-outline { gap: 0; }\n.zine-outline-row {\n display: block;\n width: 100%;\n padding: 6px 8px;\n border: 0;\n border-bottom: 1px solid var(--zine-controls-divider, light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.1)));\n border-radius: 5px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n line-height: 1.35;\n cursor: pointer;\n box-sizing: border-box;\n overflow-wrap: anywhere;\n}\n.zine-outline-row:last-child { border-bottom: 0; }\n.zine-outline-row:hover:not(:disabled) {\n background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14)));\n}\n.zine-outline-row:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: -2px;\n}\n/* A heading whose destination could not be resolved: shown, but nothing to click through to. */\n.zine-outline-row:disabled { opacity: 0.5; cursor: default; }\n\n.zine-thumbs-cell {\n flex: 1 1 0;\n min-width: 0;\n min-height: 24px;\n display: flex;\n background: light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.3));\n border-radius: 2px;\n overflow: hidden;\n}\n/* A lone page in a book that pairs elsewhere (a cover, a back page) keeps one page's width and\n centres, instead of stretching across both columns. */\n.zine-thumbs-row-lone .zine-thumbs-cell { flex: 0 0 calc(50% - 1px); }\n.zine-thumbs-img { display: block; width: 100%; height: auto; }\n.zine-thumbs-caption { flex: 1 0 100%; text-align: center; opacity: 0.7; }\n\n/* Share dialog. Centred over the page rather than in the side rail: a QR and six destinations\n need more room than the rail gives, and sharing is a brief interruption, not a browsing mode. */\n.zine-share-backdrop {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n background: rgba(0, 0, 0, 0.55);\n font: 500 13px/1.4 system-ui, sans-serif;\n color: var(--zine-controls-fg, light-dark(#18181b, #f4f4f5));\n}\n.zine-share {\n width: min(320px, 100%);\n max-height: 100%;\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 14px;\n border-radius: 12px;\n background: var(--zine-share-bg, light-dark(#ffffff, #1c1c20));\n box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);\n box-sizing: border-box;\n}\n.zine-share-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n.zine-share-head strong { font-size: 15px; }\n.zine-share-close {\n display: inline-flex;\n width: 26px;\n height: 26px;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n.zine-share-close svg { width: 16px; height: 16px; }\n.zine-share-close:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14))); }\n\n.zine-share-qr {\n align-self: center;\n line-height: 0;\n padding: 8px;\n border-radius: 8px;\n background: #fff;\n}\n.zine-share-qr svg { display: block; }\n\n.zine-share-link { display: flex; gap: 6px; }\n.zine-share-link input {\n flex: 1 1 auto;\n min-width: 0;\n padding: 7px 9px;\n border: 1px solid var(--zine-controls-hover, light-dark(rgba(0,0,0,0.14), rgba(255,255,255,0.2)));\n border-radius: 7px;\n background: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.3));\n color: inherit;\n font: inherit;\n}\n.zine-share-copy {\n flex: 0 0 auto;\n min-width: 74px;\n padding: 7px 12px;\n border: 0;\n border-radius: 7px;\n background: var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n color: light-dark(#ffffff, #06202c);\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n}\n\n.zine-share-socials { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }\n.zine-share-social {\n display: inline-flex;\n width: 38px;\n height: 38px;\n align-items: center;\n justify-content: center;\n border-radius: 8px;\n background: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.08));\n color: inherit;\n text-decoration: none;\n}\n.zine-share-social svg { width: 19px; height: 19px; }\n.zine-share-social:hover { background: var(--zine-controls-hover, light-dark(rgba(0,0,0,0.12), rgba(255,255,255,0.18))); }\n.zine-share-close:focus-visible,\n.zine-share-copy:focus-visible,\n.zine-share-social:focus-visible,\n.zine-share-link input:focus-visible {\n outline: 2px solid var(--zine-controls-accent, light-dark(#0284c7, #7dd3fc));\n outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .zine-controls-btn { transition: background 120ms ease; }\n}\n";
2
2
  /** Add the stylesheet to `doc` if it is not already there. */
3
3
  export declare function ensureStyles(doc: Document): void;
4
4
  /**
@@ -12,7 +12,7 @@ import type { ControlsOptions } from './types';
12
12
  import type { Zine } from '../zine';
13
13
  export declare class Toolbar {
14
14
  #private;
15
- constructor(zine: Zine, container: HTMLElement, options: ControlsOptions);
15
+ constructor(zine: Zine, container: HTMLElement, options: ControlsOptions, hidden?: ReadonlySet<string>);
16
16
  /** Build the layout. Separate from construction so instance-bound widgets (the page field, the
17
17
  * search panel) can be registered against this toolbar first. */
18
18
  mount(options: ControlsOptions): void;
@@ -35,8 +35,13 @@ export interface ControlDef {
35
35
  readonly icon?: string | ((ctx: ControlContext) => string);
36
36
  /** Nested controls. A control with children opens a submenu instead of acting. */
37
37
  readonly children?: readonly ControlItem[];
38
- /** What the button does. Ignored when `children` is present. */
39
- action?(ctx: ControlContext): void;
38
+ /**
39
+ * What the button does. Ignored when `children` is present. May return a promise: while it is
40
+ * pending the toolbar marks the button busy (a spinner, disabled) and — for a control in a
41
+ * submenu — holds the menu open until it settles, so a slow action (fetching a cross-origin file
42
+ * to download or print) shows progress rather than appearing to do nothing.
43
+ */
44
+ action?(ctx: ControlContext): void | Promise<void>;
40
45
  /** Build a custom element instead of a button — for inputs and other non-button widgets. */
41
46
  render?(ctx: ControlContext): HTMLElement;
42
47
  /**
@@ -60,6 +65,12 @@ export interface ControlDef {
60
65
  export type ControlItem = string | ControlDef | (Partial<ControlDef> & {
61
66
  id: string;
62
67
  });
68
+ export interface ContextMenuOptions {
69
+ /** The menu layout. Omit for the default set; pass an array to replace it outright. */
70
+ items?: readonly ControlItem[];
71
+ /** Which palette the menu uses; follows the toolbar's when mounted alongside one. */
72
+ colorScheme?: ControlsColorScheme;
73
+ }
63
74
  export interface ControlsOptions {
64
75
  /** Which edge of the book the toolbar sits on; default 'bottom'. */
65
76
  position?: ControlsPosition;