@delightstack/components 0.1.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1793 @@
1
+ <script lang="ts" module>
2
+ export interface PDFAnnotation {
3
+ /** The kind of annotation */
4
+ type: 'highlight' | 'note';
5
+ /** The 1-based page number the annotation belongs to */
6
+ page: number;
7
+ /** Arbitrary annotation payload (position, text, etc.) */
8
+ data: unknown;
9
+ }
10
+ </script>
11
+
12
+ <script lang="ts">
13
+ import { untrack } from 'svelte';
14
+ import { DelightError } from '@delightstack/utilities';
15
+ import { scrollbar } from '../actions/scrollbar';
16
+
17
+ const propId = $props.id();
18
+
19
+ let {
20
+ /** PDF source: URL string, ArrayBuffer, or Uint8Array */
21
+ src,
22
+
23
+ /** Current page number (1-based) */
24
+ page = $bindable(1),
25
+
26
+ /** Zoom level (1 = 100%) */
27
+ zoom = $bindable(1),
28
+
29
+ /** Rotation in degrees (0, 90, 180, 270) */
30
+ rotation = 0,
31
+
32
+ /** Initial fit mode */
33
+ fit = 'width' as 'width' | 'height' | 'page',
34
+
35
+ /** Show toolbar */
36
+ show_toolbar = true,
37
+
38
+ /** Show download button in toolbar */
39
+ show_download = true,
40
+
41
+ /** Enable text search */
42
+ searchable = true,
43
+
44
+ /** Enable annotations */
45
+ annotatable = false,
46
+
47
+ /** Container height */
48
+ height = '600px',
49
+
50
+ /**
51
+ * When true, only the current `page` is rendered (others hidden), the
52
+ * internal scroll container is disabled, and the page is centered to
53
+ * fill the container. The toolbar is typically also hidden via
54
+ * `show_toolbar={false}`. Used when an external orchestrator (e.g. the
55
+ * Carousel component) drives page navigation.
56
+ */
57
+ single_page = false,
58
+
59
+ /**
60
+ * In single_page mode, automatically translate the stacked page slots so
61
+ * the bound `page` is shown. Defaults to true for standalone use. The
62
+ * Carousel sets this false because it drives the slide translation itself.
63
+ */
64
+ auto_paginate = true,
65
+
66
+ /**
67
+ * Multiplier applied to the rendered canvas resolution without changing
68
+ * the displayed CSS size. Lets an external orchestrator (Carousel pinch
69
+ * zoom) push more pixels into the canvas so a magnified page stays
70
+ * crisp. Defaults to 1; bumping to 2 quadruples the rasterized pixel
71
+ * count of each rendered page.
72
+ */
73
+ pixel_density = 1,
74
+
75
+ /**
76
+ * Show a loading skeleton while the document loads (including before
77
+ * `src` is available). It dismisses itself as soon as the document is
78
+ * ready — set to `false` to disable the built-in loading state.
79
+ */
80
+ skeleton = true,
81
+
82
+ /**
83
+ * Render the selectable/searchable text layer over each page. Disable it
84
+ * (e.g. inside a Carousel) to skip `getTextContent()` and per-glyph span
85
+ * layout on every page — a meaningful render-perf win when text selection
86
+ * isn't needed. Search still works (it extracts text separately).
87
+ */
88
+ text_layer = true,
89
+
90
+ /** Element ID */
91
+ id = propId,
92
+
93
+ /** Additional CSS classes */
94
+ class: class_name = '',
95
+
96
+ /** Bindable element reference */
97
+ element = $bindable(undefined as HTMLElement | undefined),
98
+
99
+ /** Fired when the current page changes */
100
+ onpagechange = undefined as
101
+ | ((detail: { page: number; total_pages: number }) => void)
102
+ | undefined,
103
+
104
+ /** Fired when the PDF finishes loading */
105
+ onload = undefined as ((detail: { total_pages: number }) => void) | undefined,
106
+
107
+ /** Fired when the PDF fails to load */
108
+ onerror = undefined as ((detail: { error: Error }) => void) | undefined,
109
+
110
+ /** Fired when download is clicked */
111
+ ondownload = undefined as (() => void) | undefined,
112
+
113
+ /** Fired when an annotation is created */
114
+ onannotation = undefined as ((detail: PDFAnnotation) => void) | undefined,
115
+ }: {
116
+ src?: string | ArrayBuffer | Uint8Array;
117
+ page?: number;
118
+ zoom?: number;
119
+ rotation?: number;
120
+ fit?: 'width' | 'height' | 'page';
121
+ show_toolbar?: boolean;
122
+ show_download?: boolean;
123
+ searchable?: boolean;
124
+ annotatable?: boolean;
125
+ height?: string;
126
+ single_page?: boolean;
127
+ auto_paginate?: boolean;
128
+ pixel_density?: number;
129
+ skeleton?: boolean;
130
+ text_layer?: boolean;
131
+ id?: string;
132
+ class?: string;
133
+ element?: HTMLElement | undefined;
134
+ onpagechange?: (detail: { page: number; total_pages: number }) => void;
135
+ onload?: (detail: { total_pages: number }) => void;
136
+ onerror?: (detail: { error: Error }) => void;
137
+ ondownload?: () => void;
138
+ onannotation?: (detail: PDFAnnotation) => void;
139
+ } = $props();
140
+
141
+ /* ------------------------------------------------------------------ */
142
+ /* Types */
143
+ /* ------------------------------------------------------------------ */
144
+
145
+ interface PageInfo {
146
+ width: number;
147
+ height: number;
148
+ rendered: boolean;
149
+ }
150
+
151
+ interface SearchMatch {
152
+ page: number;
153
+ index: number;
154
+ }
155
+
156
+ /* ------------------------------------------------------------------ */
157
+ /* State */
158
+ /* ------------------------------------------------------------------ */
159
+
160
+ let pdf_doc = $state<unknown>(undefined);
161
+ let total_pages = $state(0);
162
+ let loading = $state(true);
163
+ let error_message = $state('');
164
+ let page_infos = $state<PageInfo[]>([]);
165
+ let page_elements = $state<(HTMLDivElement | null)[]>([]);
166
+ let pages_container = $state<HTMLDivElement | null>(null);
167
+ let page_input_value = $state('1');
168
+
169
+ // Search state
170
+ let search_open = $state(false);
171
+ let search_query = $state('');
172
+ let search_matches = $state<SearchMatch[]>([]);
173
+ let search_current = $state(0);
174
+ let page_texts = $state<string[]>([]);
175
+ let search_input_el = $state<HTMLInputElement | null>(null);
176
+
177
+ // Annotation state
178
+ let annotation_mode = $state<'highlight' | 'note' | null>(null);
179
+
180
+ // PDF.js library reference
181
+ let pdfjs_lib: unknown = undefined;
182
+
183
+ // Track which pages are rendered
184
+ let rendered_pages = $state(new Set<number>());
185
+
186
+ // Track in-flight render tasks per page so rapid page switches don't
187
+ // fire concurrent renderPage() calls against the same canvas — that race
188
+ // causes `canvas.width = ...` to clear pixels in the middle of an
189
+ // already-running pdfjs render, making the page momentarily transparent.
190
+ const rendering_pages = new Map<number, Promise<void>>();
191
+
192
+ // Track the current fit mode for cycling
193
+ let current_fit = $state(fit);
194
+
195
+ // Prevent scroll observer feedback loop
196
+ let programmatic_scroll = false;
197
+
198
+ /* ------------------------------------------------------------------ */
199
+ /* Derived */
200
+ /* ------------------------------------------------------------------ */
201
+
202
+ const zoom_percent = $derived(Math.round(zoom * 100));
203
+
204
+ const search_count_text = $derived.by(() => {
205
+ if (search_matches.length === 0) return search_query ? 'No matches' : '';
206
+ return `${search_current + 1} of ${search_matches.length}`;
207
+ });
208
+
209
+ /* ------------------------------------------------------------------ */
210
+ /* PDF.js lazy loader */
211
+ /* ------------------------------------------------------------------ */
212
+
213
+ async function loadPdfJs(): Promise<unknown> {
214
+ if (pdfjs_lib) return pdfjs_lib;
215
+ // @ts-ignore — pdfjs-dist is an optional peer dependency
216
+ const lib = await import('pdfjs-dist');
217
+ const version = (lib as { version: string }).version;
218
+ // pdfjs-dist >= 5 auto-initializes GlobalWorkerOptions.workerSrc to a
219
+ // placeholder relative path ("./pdf.worker.mjs") at import time, which
220
+ // 404s in any non-bundled context. Always set it to a known-good CDN URL
221
+ // for our version. (Consumers who want a different worker can set it
222
+ // themselves before mounting <PDF> — we only overwrite the placeholder.)
223
+ const workerOptions = (lib as { GlobalWorkerOptions: { workerSrc: string } })
224
+ .GlobalWorkerOptions;
225
+ const cdnUrl = `https://unpkg.com/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
226
+ const isPlaceholder =
227
+ !workerOptions.workerSrc ||
228
+ workerOptions.workerSrc === './pdf.worker.mjs' ||
229
+ workerOptions.workerSrc === 'pdf.worker.mjs';
230
+ if (workerOptions && isPlaceholder) {
231
+ workerOptions.workerSrc = cdnUrl;
232
+ }
233
+ pdfjs_lib = lib;
234
+ return lib;
235
+ }
236
+
237
+ /* ------------------------------------------------------------------ */
238
+ /* Document loading */
239
+ /* ------------------------------------------------------------------ */
240
+
241
+ async function loadDocument(source: string | ArrayBuffer | Uint8Array) {
242
+ loading = true;
243
+ error_message = '';
244
+ pdf_doc = undefined;
245
+ total_pages = 0;
246
+ page_infos = [];
247
+ page_texts = [];
248
+ rendered_pages = new Set();
249
+
250
+ try {
251
+ const lib = (await loadPdfJs()) as Record<string, unknown>;
252
+ const getDocument = lib.getDocument as (params: Record<string, unknown>) => {
253
+ promise: Promise<unknown>;
254
+ };
255
+
256
+ const params: Record<string, unknown> = {};
257
+ if (typeof source === 'string') {
258
+ params.url = source;
259
+ } else {
260
+ params.data = source instanceof ArrayBuffer ? new Uint8Array(source) : source;
261
+ }
262
+
263
+ const doc = await getDocument(params).promise;
264
+ const typedDoc = doc as {
265
+ numPages: number;
266
+ getPage: (n: number) => Promise<unknown>;
267
+ };
268
+ pdf_doc = doc;
269
+ total_pages = typedDoc.numPages;
270
+
271
+ // Gather page dimensions
272
+ const infos: PageInfo[] = [];
273
+ for (let i = 1; i <= total_pages; i++) {
274
+ const pg = await typedDoc.getPage(i);
275
+ const typedPage = pg as {
276
+ getViewport: (opts: { scale: number; rotation: number }) => {
277
+ width: number;
278
+ height: number;
279
+ };
280
+ };
281
+ const vp = typedPage.getViewport({ scale: 1, rotation });
282
+ infos.push({ width: vp.width, height: vp.height, rendered: false });
283
+ }
284
+ page_infos = infos;
285
+ page_elements = Array.from({ length: total_pages }, () => null);
286
+
287
+ loading = false;
288
+ onload?.({ total_pages });
289
+
290
+ // Extract text for search (skipped in single_page mode where there's no search UI)
291
+ if (searchable && !single_page) {
292
+ extractAllText(typedDoc);
293
+ }
294
+ } catch (err) {
295
+ loading = false;
296
+ const e = err instanceof Error ? err : new DelightError(String(err));
297
+ error_message = e.message;
298
+ onerror?.({ error: e });
299
+ }
300
+ }
301
+
302
+ async function extractAllText(doc: {
303
+ numPages: number;
304
+ getPage: (n: number) => Promise<unknown>;
305
+ }) {
306
+ const texts: string[] = [];
307
+ for (let i = 1; i <= doc.numPages; i++) {
308
+ try {
309
+ const pg = await doc.getPage(i);
310
+ const typedPage = pg as {
311
+ getTextContent: () => Promise<{ items: { str?: string }[] }>;
312
+ };
313
+ const content = await typedPage.getTextContent();
314
+ const text = content.items.map((item) => item.str || '').join(' ');
315
+ texts.push(text);
316
+ } catch {
317
+ texts.push('');
318
+ }
319
+ }
320
+ page_texts = texts;
321
+ }
322
+
323
+ /* ------------------------------------------------------------------ */
324
+ /* Page rendering */
325
+ /* ------------------------------------------------------------------ */
326
+
327
+ async function renderPage(page_num: number) {
328
+ if (!pdf_doc || rendered_pages.has(page_num)) return;
329
+ // If a render for this page is already in flight, let it finish —
330
+ // reissuing now would clear the canvas mid-paint and race the first
331
+ // render's `pdfjs.render(...).promise` against a fresh one.
332
+ const inFlight = rendering_pages.get(page_num);
333
+ if (inFlight) return inFlight;
334
+
335
+ const task = renderPageImpl(page_num);
336
+ rendering_pages.set(page_num, task);
337
+ try {
338
+ await task;
339
+ } finally {
340
+ rendering_pages.delete(page_num);
341
+ }
342
+ }
343
+
344
+ async function renderPageImpl(page_num: number) {
345
+ if (!pdf_doc) return;
346
+ const typedDoc = pdf_doc as { getPage: (n: number) => Promise<unknown> };
347
+ const pg = await typedDoc.getPage(page_num);
348
+ const typedPage = pg as {
349
+ getViewport: (opts: { scale: number; rotation: number }) => {
350
+ width: number;
351
+ height: number;
352
+ transform: number[];
353
+ };
354
+ render: (opts: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => {
355
+ promise: Promise<void>;
356
+ };
357
+ getTextContent: () => Promise<{
358
+ items: { str?: string; transform?: number[]; width?: number; height?: number }[];
359
+ }>;
360
+ };
361
+
362
+ // Display viewport drives the CSS size; the render viewport multiplies
363
+ // `pixel_density` so the canvas holds extra pixels for crisp display
364
+ // when an external orchestrator scales the canvas up (e.g. pinch zoom).
365
+ const display_viewport = typedPage.getViewport({ scale: zoom, rotation });
366
+ const effective_density = Math.max(1, pixel_density || 1);
367
+ const render_viewport =
368
+ effective_density === 1
369
+ ? display_viewport
370
+ : typedPage.getViewport({ scale: zoom * effective_density, rotation });
371
+ const container_el = page_elements[page_num - 1];
372
+ if (!container_el) return;
373
+
374
+ // Render into an offscreen canvas so the currently-displayed canvas
375
+ // (if any) keeps its pixels until the new draw is complete. Then swap
376
+ // the offscreen into the DOM atomically. Without this, setting
377
+ // `canvas.width` on an in-DOM canvas clears it to transparent and the
378
+ // page background appears to "vanish" until pdfjs finishes painting.
379
+ const next_canvas = document.createElement('canvas');
380
+ next_canvas.classList.add('pdf-page-canvas');
381
+ next_canvas.width = render_viewport.width;
382
+ next_canvas.height = render_viewport.height;
383
+ next_canvas.style.width = `${display_viewport.width}px`;
384
+ next_canvas.style.height = `${display_viewport.height}px`;
385
+
386
+ const ctx = next_canvas.getContext('2d');
387
+ if (!ctx) return;
388
+
389
+ try {
390
+ await typedPage.render({ canvasContext: ctx, viewport: render_viewport }).promise;
391
+ } catch {
392
+ // render cancelled or failed, ignore
393
+ return;
394
+ }
395
+
396
+ // Swap: remove the old canvas (if any), insert the freshly painted one.
397
+ const old_canvas = container_el.querySelector('canvas');
398
+ if (old_canvas) old_canvas.remove();
399
+ container_el.insertBefore(next_canvas, container_el.firstChild);
400
+
401
+ // Text layer (anchored to display size so its hit targets stay aligned
402
+ // with the visible canvas, not the higher-resolution backing store).
403
+ // Skipped entirely when `text_layer` is false (e.g. inside a Carousel) —
404
+ // avoids getTextContent() + per-glyph span layout on every page.
405
+ const existing_layer = container_el.querySelector(
406
+ '.pdf-text-layer',
407
+ ) as HTMLDivElement | null;
408
+ if (!text_layer) {
409
+ if (existing_layer) existing_layer.remove();
410
+ } else {
411
+ const layer_el = existing_layer ?? document.createElement('div');
412
+ if (!existing_layer) {
413
+ layer_el.classList.add('pdf-text-layer');
414
+ container_el.appendChild(layer_el);
415
+ }
416
+ layer_el.innerHTML = '';
417
+ layer_el.style.width = `${display_viewport.width}px`;
418
+ layer_el.style.height = `${display_viewport.height}px`;
419
+
420
+ try {
421
+ const content = await typedPage.getTextContent();
422
+ for (const item of content.items) {
423
+ if (!item.str) continue;
424
+ const span = document.createElement('span');
425
+ span.textContent = item.str;
426
+ if (item.transform) {
427
+ const tx = item.transform[4] * zoom;
428
+ const ty = display_viewport.height - item.transform[5] * zoom;
429
+ const font_size = Math.abs(item.transform[3]) * zoom;
430
+ span.style.position = 'absolute';
431
+ span.style.left = `${tx}px`;
432
+ span.style.top = `${ty - font_size}px`;
433
+ span.style.fontSize = `${font_size}px`;
434
+ span.style.fontFamily = 'sans-serif';
435
+ span.style.whiteSpace = 'pre';
436
+ }
437
+ layer_el.appendChild(span);
438
+ }
439
+ } catch {
440
+ // text extraction failed, ignore
441
+ }
442
+ }
443
+
444
+ // Update container size — skipped in single_page mode where the slot
445
+ // is sized to fill the slide and the canvas is centered inside it.
446
+ if (!single_page) {
447
+ container_el.style.width = `${display_viewport.width}px`;
448
+ container_el.style.height = `${display_viewport.height}px`;
449
+ }
450
+
451
+ rendered_pages = new Set([...rendered_pages, page_num]);
452
+ }
453
+
454
+ function clearRenderedPages() {
455
+ rendered_pages = new Set();
456
+ // Drop the in-flight render registrations too — any pending render
457
+ // will still complete and swap its (possibly stale) canvas into the
458
+ // DOM, but the next render call for that page will not be short-
459
+ // circuited by a stale promise.
460
+ rendering_pages.clear();
461
+ // Note: deliberately DO NOT remove the existing <canvas> nodes here.
462
+ // renderPage paints into an offscreen canvas and only swaps it into
463
+ // the DOM once the new render is complete, so leaving the old one
464
+ // in place prevents the page from going transparent during the
465
+ // re-render (which is what happens when zoom or pixel_density
466
+ // changes while the user is mid-interaction).
467
+ }
468
+
469
+ /* ------------------------------------------------------------------ */
470
+ /* Virtualized rendering with IntersectionObserver */
471
+ /* ------------------------------------------------------------------ */
472
+
473
+ function setupObserver() {
474
+ if (!pages_container || typeof IntersectionObserver === 'undefined') return;
475
+ // In single_page mode the consumer drives the page index externally,
476
+ // so we skip the observer (it would fight the external `page` prop).
477
+ if (single_page) return;
478
+
479
+ const observer = new IntersectionObserver(
480
+ (entries) => {
481
+ for (const entry of entries) {
482
+ const el = entry.target as HTMLDivElement;
483
+ const page_num = parseInt(el.dataset.page || '0', 10);
484
+ if (!page_num) continue;
485
+
486
+ if (entry.isIntersecting) {
487
+ renderPage(page_num);
488
+
489
+ // Also render neighbors
490
+ if (page_num > 1) renderPage(page_num - 1);
491
+ if (page_num < total_pages) renderPage(page_num + 1);
492
+
493
+ // Update current page from scroll
494
+ if (!programmatic_scroll) {
495
+ if (entry.intersectionRatio > 0.3 || entry.isIntersecting) {
496
+ const new_page = page_num;
497
+ if (new_page !== page) {
498
+ page = new_page;
499
+ page_input_value = String(new_page);
500
+ onpagechange?.({ page: new_page, total_pages });
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+ },
507
+ {
508
+ root: pages_container,
509
+ rootMargin: '200px 0px',
510
+ threshold: [0, 0.3, 0.5, 1],
511
+ },
512
+ );
513
+
514
+ for (const el of page_elements) {
515
+ if (el) observer.observe(el);
516
+ }
517
+
518
+ return () => observer.disconnect();
519
+ }
520
+
521
+ /* ------------------------------------------------------------------ */
522
+ /* Navigation */
523
+ /* ------------------------------------------------------------------ */
524
+
525
+ function goToPage(target: number) {
526
+ const clamped = Math.max(1, Math.min(target, total_pages));
527
+ page = clamped;
528
+ page_input_value = String(clamped);
529
+ onpagechange?.({ page: clamped, total_pages });
530
+ scrollToPage(clamped);
531
+ }
532
+
533
+ function scrollToPage(page_num: number) {
534
+ const el = page_elements[page_num - 1];
535
+ if (!el || !pages_container) return;
536
+ programmatic_scroll = true;
537
+ // Scroll the pages container directly. Compute the offset using
538
+ // bounding rects so it works regardless of which ancestor is the
539
+ // element's offsetParent — `el.scrollIntoView` walks all scrollable
540
+ // ancestors (including the window), which can result in the outer page
541
+ // scrolling instead of the PDF viewer when both are scrollable.
542
+ const elRect = el.getBoundingClientRect();
543
+ const containerRect = pages_container.getBoundingClientRect();
544
+ const target = pages_container.scrollTop + (elRect.top - containerRect.top);
545
+ pages_container.scrollTo({ top: target, behavior: 'smooth' });
546
+ setTimeout(() => {
547
+ programmatic_scroll = false;
548
+ }, 500);
549
+ }
550
+
551
+ function handlePageInput(e: Event) {
552
+ const input = e.target as HTMLInputElement;
553
+ const val = parseInt(input.value, 10);
554
+ if (!isNaN(val) && val >= 1 && val <= total_pages) {
555
+ goToPage(val);
556
+ }
557
+ }
558
+
559
+ function handlePageInputKeydown(e: KeyboardEvent) {
560
+ if (e.key === 'Enter') {
561
+ handlePageInput(e);
562
+ (e.target as HTMLInputElement).blur();
563
+ }
564
+ }
565
+
566
+ /* ------------------------------------------------------------------ */
567
+ /* Zoom */
568
+ /* ------------------------------------------------------------------ */
569
+
570
+ function setZoom(new_zoom: number) {
571
+ const clamped = Math.max(0.25, Math.min(4, new_zoom));
572
+ // Tolerate floating-point drift so applyFit() — which is called on
573
+ // every page change — doesn't wipe the rendered canvases when the
574
+ // computed fit zoom is "the same" but differs by a hair.
575
+ if (Math.abs(clamped - zoom) < 0.001) return;
576
+ zoom = clamped;
577
+ clearRenderedPages();
578
+ requestAnimationFrame(() => {
579
+ renderVisiblePages();
580
+ });
581
+ }
582
+
583
+ function zoomIn() {
584
+ const steps = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
585
+ const next = steps.find((s) => s > zoom);
586
+ setZoom(next ?? 4);
587
+ }
588
+
589
+ function zoomOut() {
590
+ const steps = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
591
+ const prev = [...steps].reverse().find((s) => s < zoom);
592
+ setZoom(prev ?? 0.25);
593
+ }
594
+
595
+ function resetZoom() {
596
+ setZoom(1);
597
+ }
598
+
599
+ /* ------------------------------------------------------------------ */
600
+ /* Fit mode */
601
+ /* ------------------------------------------------------------------ */
602
+
603
+ function cycleFit() {
604
+ const modes: ('width' | 'height' | 'page')[] = ['width', 'page'];
605
+ const idx = modes.indexOf(current_fit);
606
+ current_fit = modes[(idx + 1) % modes.length];
607
+ applyFit();
608
+ }
609
+
610
+ function applyFit() {
611
+ if (!pages_container || page_infos.length === 0) return;
612
+ const container_rect = pages_container.getBoundingClientRect();
613
+ // In single_page mode the active page may be a different size than page 1.
614
+ const ref_page = single_page
615
+ ? page_infos[Math.max(0, Math.min(page_infos.length - 1, page - 1))]
616
+ : page_infos[0];
617
+ if (!ref_page) return;
618
+
619
+ // In single_page mode the parent (e.g. Carousel) handles its own framing;
620
+ // don't reserve internal padding.
621
+ const padding = single_page ? 0 : 32;
622
+ const available_width = container_rect.width - padding;
623
+ const available_height = container_rect.height - padding;
624
+
625
+ if (current_fit === 'width') {
626
+ setZoom(available_width / ref_page.width);
627
+ } else if (current_fit === 'height') {
628
+ setZoom(available_height / ref_page.height);
629
+ } else {
630
+ // 'page' - fit the whole page
631
+ const scale_w = available_width / ref_page.width;
632
+ const scale_h = available_height / ref_page.height;
633
+ setZoom(Math.min(scale_w, scale_h));
634
+ }
635
+ }
636
+
637
+ /* ------------------------------------------------------------------ */
638
+ /* Render visible pages helper */
639
+ /* ------------------------------------------------------------------ */
640
+
641
+ function renderVisiblePages() {
642
+ if (!pages_container) return;
643
+ // In single_page mode, render the active page plus its immediate
644
+ // neighbors so that a vertical swipe in the parent Carousel can
645
+ // reveal the next/prev page mid-gesture without a flash of empty
646
+ // space. Distant pages stay unrendered to keep memory in check.
647
+ if (single_page) {
648
+ renderPage(page);
649
+ if (page > 1) renderPage(page - 1);
650
+ if (page < total_pages) renderPage(page + 1);
651
+ return;
652
+ }
653
+ const rect = pages_container.getBoundingClientRect();
654
+ for (let i = 0; i < page_elements.length; i++) {
655
+ const el = page_elements[i];
656
+ if (!el) continue;
657
+ const er = el.getBoundingClientRect();
658
+ if (er.bottom >= rect.top - 200 && er.top <= rect.bottom + 200) {
659
+ renderPage(i + 1);
660
+ }
661
+ }
662
+ }
663
+
664
+ /* ------------------------------------------------------------------ */
665
+ /* Search */
666
+ /* ------------------------------------------------------------------ */
667
+
668
+ function openSearch() {
669
+ if (!searchable) return;
670
+ search_open = true;
671
+ requestAnimationFrame(() => {
672
+ search_input_el?.focus();
673
+ });
674
+ }
675
+
676
+ function closeSearch() {
677
+ search_open = false;
678
+ search_query = '';
679
+ search_matches = [];
680
+ search_current = 0;
681
+ }
682
+
683
+ function performSearch(query: string) {
684
+ if (!query.trim()) {
685
+ search_matches = [];
686
+ search_current = 0;
687
+ return;
688
+ }
689
+
690
+ const lower_query = query.toLowerCase();
691
+ const matches: SearchMatch[] = [];
692
+
693
+ for (let i = 0; i < page_texts.length; i++) {
694
+ const text = page_texts[i].toLowerCase();
695
+ let start = 0;
696
+ let idx = text.indexOf(lower_query, start);
697
+ while (idx !== -1) {
698
+ matches.push({ page: i + 1, index: idx });
699
+ start = idx + 1;
700
+ idx = text.indexOf(lower_query, start);
701
+ }
702
+ }
703
+
704
+ search_matches = matches;
705
+ search_current = matches.length > 0 ? 0 : 0;
706
+
707
+ if (matches.length > 0) {
708
+ goToPage(matches[0].page);
709
+ }
710
+ }
711
+
712
+ function searchNext() {
713
+ if (search_matches.length === 0) return;
714
+ search_current = (search_current + 1) % search_matches.length;
715
+ goToPage(search_matches[search_current].page);
716
+ }
717
+
718
+ function searchPrev() {
719
+ if (search_matches.length === 0) return;
720
+ search_current = (search_current - 1 + search_matches.length) % search_matches.length;
721
+ goToPage(search_matches[search_current].page);
722
+ }
723
+
724
+ function handleSearchInput(e: Event) {
725
+ search_query = (e.target as HTMLInputElement).value;
726
+ performSearch(search_query);
727
+ }
728
+
729
+ function handleSearchKeydown(e: KeyboardEvent) {
730
+ if (e.key === 'Enter') {
731
+ if (e.shiftKey) {
732
+ searchPrev();
733
+ } else {
734
+ searchNext();
735
+ }
736
+ } else if (e.key === 'Escape') {
737
+ closeSearch();
738
+ }
739
+ }
740
+
741
+ /* ------------------------------------------------------------------ */
742
+ /* Download */
743
+ /* ------------------------------------------------------------------ */
744
+
745
+ function handleDownload() {
746
+ ondownload?.();
747
+
748
+ if (src == null) return;
749
+ if (typeof src === 'string') {
750
+ const a = document.createElement('a');
751
+ a.href = src;
752
+ a.download = src.split('/').pop() || 'document.pdf';
753
+ a.click();
754
+ } else {
755
+ const blob = new Blob([src], { type: 'application/pdf' });
756
+ const url = URL.createObjectURL(blob);
757
+ const a = document.createElement('a');
758
+ a.href = url;
759
+ a.download = 'document.pdf';
760
+ a.click();
761
+ URL.revokeObjectURL(url);
762
+ }
763
+ }
764
+
765
+ /* ------------------------------------------------------------------ */
766
+ /* Annotations */
767
+ /* ------------------------------------------------------------------ */
768
+
769
+ function handlePageMouseUp(page_num: number) {
770
+ if (!annotatable) return;
771
+ const selection = window.getSelection();
772
+ if (!selection || selection.isCollapsed) return;
773
+
774
+ if (annotation_mode === 'highlight') {
775
+ const text = selection.toString();
776
+ if (text) {
777
+ onannotation?.({ type: 'highlight', page: page_num, data: { text } });
778
+ }
779
+ }
780
+ }
781
+
782
+ function handlePageClick(e: MouseEvent, page_num: number) {
783
+ if (!annotatable || annotation_mode !== 'note') return;
784
+ const target = e.currentTarget as HTMLDivElement;
785
+ const rect = target.getBoundingClientRect();
786
+ const x = e.clientX - rect.left;
787
+ const y = e.clientY - rect.top;
788
+ onannotation?.({ type: 'note', page: page_num, data: { x, y } });
789
+ }
790
+
791
+ /* ------------------------------------------------------------------ */
792
+ /* Keyboard shortcuts */
793
+ /* ------------------------------------------------------------------ */
794
+
795
+ function handleKeydown(e: KeyboardEvent) {
796
+ const is_mac =
797
+ typeof navigator !== 'undefined' && navigator.platform?.includes('Mac');
798
+ const mod = is_mac ? e.metaKey : e.ctrlKey;
799
+
800
+ if (mod && e.key === 'f' && searchable) {
801
+ e.preventDefault();
802
+ openSearch();
803
+ return;
804
+ }
805
+
806
+ if (e.key === 'Escape' && search_open) {
807
+ closeSearch();
808
+ return;
809
+ }
810
+
811
+ if (mod && (e.key === '=' || e.key === '+')) {
812
+ e.preventDefault();
813
+ zoomIn();
814
+ return;
815
+ }
816
+
817
+ if (mod && e.key === '-') {
818
+ e.preventDefault();
819
+ zoomOut();
820
+ return;
821
+ }
822
+
823
+ if (mod && e.key === '0') {
824
+ e.preventDefault();
825
+ resetZoom();
826
+ return;
827
+ }
828
+
829
+ // Don't handle navigation keys when input is focused
830
+ if ((e.target as HTMLElement)?.tagName === 'INPUT') return;
831
+
832
+ switch (e.key) {
833
+ case 'ArrowLeft':
834
+ case 'PageUp':
835
+ e.preventDefault();
836
+ goToPage(page - 1);
837
+ break;
838
+ case 'ArrowRight':
839
+ case 'PageDown':
840
+ e.preventDefault();
841
+ goToPage(page + 1);
842
+ break;
843
+ case 'Home':
844
+ e.preventDefault();
845
+ goToPage(1);
846
+ break;
847
+ case 'End':
848
+ e.preventDefault();
849
+ goToPage(total_pages);
850
+ break;
851
+ }
852
+ }
853
+
854
+ /* ------------------------------------------------------------------ */
855
+ /* Placeholder dimensions for unrendered pages */
856
+ /* ------------------------------------------------------------------ */
857
+
858
+ function getPageStyle(index: number): string {
859
+ const info = page_infos[index];
860
+ if (!info) return '';
861
+ const w = info.width * zoom;
862
+ const h = info.height * zoom;
863
+ return `width: ${w}px; height: ${h}px;`;
864
+ }
865
+
866
+ /* ------------------------------------------------------------------ */
867
+ /* Effects */
868
+ /* ------------------------------------------------------------------ */
869
+
870
+ // Load document when src changes
871
+ $effect(() => {
872
+ void src;
873
+ if (typeof window === 'undefined') return;
874
+ if (src == null) {
875
+ // No document yet (e.g. progressively assigned) — stay in the
876
+ // loading state so the skeleton shows instead of an error.
877
+ loading = true;
878
+ return;
879
+ }
880
+ loadDocument(src);
881
+ });
882
+
883
+ // Setup intersection observer after pages mount
884
+ $effect(() => {
885
+ if (total_pages > 0 && pages_container && page_elements.length > 0) {
886
+ // Wait for elements to be in DOM
887
+ const timer = setTimeout(() => {
888
+ const cleanup = setupObserver();
889
+ applyFit();
890
+ return cleanup;
891
+ }, 50);
892
+ return () => clearTimeout(timer);
893
+ }
894
+ });
895
+
896
+ // Re-render when zoom/rotation/pixel_density changes — wrap body in
897
+ // untrack so `clearRenderedPages` (which iterates `page_elements`
898
+ // reactively) doesn't pull every page_elements mutation into this
899
+ // effect's dependency set.
900
+ $effect(() => {
901
+ void zoom;
902
+ void rotation;
903
+ void pixel_density;
904
+ untrack(() => {
905
+ if (total_pages > 0) {
906
+ clearRenderedPages();
907
+ requestAnimationFrame(() => renderVisiblePages());
908
+ }
909
+ });
910
+ });
911
+
912
+ // Sync page input value when page prop changes externally
913
+ $effect(() => {
914
+ page_input_value = String(page);
915
+ });
916
+
917
+ // React to external page changes — fire ONLY when `page` itself changes.
918
+ // applyFit/scrollToPage transitively read other reactive state (page_infos,
919
+ // page_elements, etc.) which mutate during rendering, so wrap the body in
920
+ // untrack() to keep this effect from running on every mutation.
921
+ $effect(() => {
922
+ void page;
923
+ untrack(() => {
924
+ if (total_pages > 0 && page >= 1 && page <= total_pages) {
925
+ if (single_page) {
926
+ // In single_page mode the active + adjacent pages are all
927
+ // rendered into the same DOM, stacked vertically. Switching
928
+ // to a neighboring page just needs to render any NEW
929
+ // neighbor that isn't already drawn — don't `clearRenderedPages`
930
+ // or the current page's canvas vanishes for a frame, leaving
931
+ // the slot transparent until pdfjs finishes the re-render.
932
+ // applyFit can change `zoom` if pages differ in size; the
933
+ // zoom effect handles a full re-render in that case.
934
+ const prevZoom = zoom;
935
+ applyFit();
936
+ if (prevZoom === zoom) {
937
+ requestAnimationFrame(() => renderVisiblePages());
938
+ }
939
+ } else {
940
+ scrollToPage(page);
941
+ }
942
+ }
943
+ });
944
+ });
945
+ </script>
946
+
947
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
948
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
949
+ <div
950
+ {id}
951
+ class={['pdf-container', class_name].filter(Boolean).join(' ')}
952
+ class:single-page={single_page}
953
+ class:auto-paginate={single_page && auto_paginate}
954
+ style:--pdf-height={height}
955
+ bind:this={element}
956
+ onkeydown={handleKeydown}
957
+ tabindex="0"
958
+ role="document"
959
+ aria-label="PDF viewer">
960
+ {#if skeleton && loading && !single_page}
961
+ <!-- Skeleton / Loading (suppressed in single_page mode — used inside
962
+ the Carousel, which prefers a blank slide over a placeholder UI
963
+ while the document is being fetched). -->
964
+ <div class="skeleton">
965
+ {#if show_toolbar}
966
+ <div class="bar">
967
+ <div class="block" style="width: 6rem; height: 1.5rem;"></div>
968
+ <div class="block" style="width: 4rem; height: 1.5rem;"></div>
969
+ </div>
970
+ {/if}
971
+ <div class="page">
972
+ <div class="paper">
973
+ {#each { length: 13 } as _, i}
974
+ <div
975
+ class="line"
976
+ style:width="{45 + ((i * 37) % 50)}%"
977
+ style:--shimmer-delay="{i * 90}ms">
978
+ </div>
979
+ {/each}
980
+ </div>
981
+ </div>
982
+ </div>
983
+ {:else if error_message}
984
+ <!-- Error state -->
985
+ <div class="error">
986
+ <svg
987
+ width="48"
988
+ height="48"
989
+ viewBox="0 0 24 24"
990
+ fill="none"
991
+ stroke="currentColor"
992
+ stroke-width="1.5"
993
+ stroke-linecap="round"
994
+ stroke-linejoin="round"
995
+ aria-hidden="true">
996
+ <circle cx="12" cy="12" r="10" />
997
+ <line x1="12" y1="8" x2="12" y2="12" />
998
+ <line x1="12" y1="16" x2="12.01" y2="16" />
999
+ </svg>
1000
+ <span>Failed to load PDF</span>
1001
+ <span class="detail">{error_message}</span>
1002
+ </div>
1003
+ {:else}
1004
+ <!-- Toolbar -->
1005
+ {#if show_toolbar}
1006
+ <div class="toolbar">
1007
+ <!-- Page navigation -->
1008
+ <div class="group">
1009
+ <button
1010
+ type="button"
1011
+ class="btn"
1012
+ onclick={() => goToPage(page - 1)}
1013
+ disabled={page <= 1}
1014
+ aria-label="Previous page">
1015
+ <svg
1016
+ width="16"
1017
+ height="16"
1018
+ viewBox="0 0 16 16"
1019
+ fill="none"
1020
+ aria-hidden="true">
1021
+ <path
1022
+ d="M10 3L5 8L10 13"
1023
+ stroke="currentColor"
1024
+ stroke-width="1.5"
1025
+ stroke-linecap="round"
1026
+ stroke-linejoin="round" />
1027
+ </svg>
1028
+ </button>
1029
+
1030
+ <input
1031
+ type="text"
1032
+ value={page_input_value}
1033
+ oninput={(e) => {
1034
+ page_input_value = (e.target as HTMLInputElement).value;
1035
+ }}
1036
+ onblur={handlePageInput}
1037
+ onkeydown={handlePageInputKeydown}
1038
+ aria-label="Page number" />
1039
+ <span class="total">/ {total_pages}</span>
1040
+
1041
+ <button
1042
+ type="button"
1043
+ class="btn"
1044
+ onclick={() => goToPage(page + 1)}
1045
+ disabled={page >= total_pages}
1046
+ aria-label="Next page">
1047
+ <svg
1048
+ width="16"
1049
+ height="16"
1050
+ viewBox="0 0 16 16"
1051
+ fill="none"
1052
+ aria-hidden="true">
1053
+ <path
1054
+ d="M6 3L11 8L6 13"
1055
+ stroke="currentColor"
1056
+ stroke-width="1.5"
1057
+ stroke-linecap="round"
1058
+ stroke-linejoin="round" />
1059
+ </svg>
1060
+ </button>
1061
+ </div>
1062
+
1063
+ <div class="separator"></div>
1064
+
1065
+ <!-- Zoom controls -->
1066
+ <div class="group">
1067
+ <button
1068
+ type="button"
1069
+ class="btn"
1070
+ onclick={zoomOut}
1071
+ disabled={zoom <= 0.25}
1072
+ aria-label="Zoom out">
1073
+ <svg
1074
+ width="16"
1075
+ height="16"
1076
+ viewBox="0 0 16 16"
1077
+ fill="none"
1078
+ aria-hidden="true">
1079
+ <path
1080
+ d="M3 8H13"
1081
+ stroke="currentColor"
1082
+ stroke-width="1.5"
1083
+ stroke-linecap="round" />
1084
+ </svg>
1085
+ </button>
1086
+
1087
+ <span class="zoom">{zoom_percent}%</span>
1088
+
1089
+ <button
1090
+ type="button"
1091
+ class="btn"
1092
+ onclick={zoomIn}
1093
+ disabled={zoom >= 4}
1094
+ aria-label="Zoom in">
1095
+ <svg
1096
+ width="16"
1097
+ height="16"
1098
+ viewBox="0 0 16 16"
1099
+ fill="none"
1100
+ aria-hidden="true">
1101
+ <path
1102
+ d="M8 3V13M3 8H13"
1103
+ stroke="currentColor"
1104
+ stroke-width="1.5"
1105
+ stroke-linecap="round" />
1106
+ </svg>
1107
+ </button>
1108
+ </div>
1109
+
1110
+ <div class="separator"></div>
1111
+
1112
+ <!-- Fit mode -->
1113
+ <div class="group">
1114
+ <button
1115
+ type="button"
1116
+ class="btn"
1117
+ onclick={cycleFit}
1118
+ aria-label="Toggle fit mode ({current_fit})"
1119
+ title="Fit: {current_fit}">
1120
+ {#if current_fit === 'width'}
1121
+ <svg
1122
+ width="16"
1123
+ height="16"
1124
+ viewBox="0 0 16 16"
1125
+ fill="none"
1126
+ aria-hidden="true">
1127
+ <path
1128
+ d="M2 4V12M14 4V12M4 8H12M4 6L2 8L4 10M12 6L14 8L12 10"
1129
+ stroke="currentColor"
1130
+ stroke-width="1.25"
1131
+ stroke-linecap="round"
1132
+ stroke-linejoin="round" />
1133
+ </svg>
1134
+ {:else}
1135
+ <svg
1136
+ width="16"
1137
+ height="16"
1138
+ viewBox="0 0 16 16"
1139
+ fill="none"
1140
+ aria-hidden="true">
1141
+ <rect
1142
+ x="3"
1143
+ y="2"
1144
+ width="10"
1145
+ height="12"
1146
+ rx="1"
1147
+ stroke="currentColor"
1148
+ stroke-width="1.25" />
1149
+ </svg>
1150
+ {/if}
1151
+ </button>
1152
+ </div>
1153
+
1154
+ <div class="spacer"></div>
1155
+
1156
+ <!-- Search button -->
1157
+ {#if searchable}
1158
+ <button
1159
+ type="button"
1160
+ class="btn"
1161
+ class:active={search_open}
1162
+ onclick={() => (search_open ? closeSearch() : openSearch())}
1163
+ aria-label="Search">
1164
+ <svg
1165
+ width="16"
1166
+ height="16"
1167
+ viewBox="0 0 16 16"
1168
+ fill="none"
1169
+ aria-hidden="true">
1170
+ <circle cx="7" cy="7" r="4" stroke="currentColor" stroke-width="1.5" />
1171
+ <path
1172
+ d="M10 10L13 13"
1173
+ stroke="currentColor"
1174
+ stroke-width="1.5"
1175
+ stroke-linecap="round" />
1176
+ </svg>
1177
+ </button>
1178
+ {/if}
1179
+
1180
+ <!-- Download button -->
1181
+ {#if show_download}
1182
+ <button
1183
+ type="button"
1184
+ class="btn"
1185
+ onclick={handleDownload}
1186
+ aria-label="Download">
1187
+ <svg
1188
+ width="16"
1189
+ height="16"
1190
+ viewBox="0 0 16 16"
1191
+ fill="none"
1192
+ aria-hidden="true">
1193
+ <path
1194
+ d="M8 2V10M8 10L5 7M8 10L11 7"
1195
+ stroke="currentColor"
1196
+ stroke-width="1.5"
1197
+ stroke-linecap="round"
1198
+ stroke-linejoin="round" />
1199
+ <path
1200
+ d="M3 13H13"
1201
+ stroke="currentColor"
1202
+ stroke-width="1.5"
1203
+ stroke-linecap="round" />
1204
+ </svg>
1205
+ </button>
1206
+ {/if}
1207
+
1208
+ <!-- Annotation toggle -->
1209
+ {#if annotatable}
1210
+ <div class="separator"></div>
1211
+ <div class="group">
1212
+ <button
1213
+ type="button"
1214
+ class="btn"
1215
+ class:active={annotation_mode === 'highlight'}
1216
+ onclick={() =>
1217
+ (annotation_mode = annotation_mode === 'highlight' ? null : 'highlight')}
1218
+ aria-label="Highlight mode"
1219
+ title="Highlight text">
1220
+ <svg
1221
+ width="16"
1222
+ height="16"
1223
+ viewBox="0 0 16 16"
1224
+ fill="none"
1225
+ aria-hidden="true">
1226
+ <rect
1227
+ x="2"
1228
+ y="10"
1229
+ width="12"
1230
+ height="3"
1231
+ rx="0.5"
1232
+ fill="currentColor"
1233
+ opacity="0.3" />
1234
+ <path
1235
+ d="M3 7H13"
1236
+ stroke="currentColor"
1237
+ stroke-width="2"
1238
+ stroke-linecap="round" />
1239
+ </svg>
1240
+ </button>
1241
+ <button
1242
+ type="button"
1243
+ class="btn"
1244
+ class:active={annotation_mode === 'note'}
1245
+ onclick={() =>
1246
+ (annotation_mode = annotation_mode === 'note' ? null : 'note')}
1247
+ aria-label="Note mode"
1248
+ title="Add note">
1249
+ <svg
1250
+ width="16"
1251
+ height="16"
1252
+ viewBox="0 0 16 16"
1253
+ fill="none"
1254
+ aria-hidden="true">
1255
+ <rect
1256
+ x="2"
1257
+ y="2"
1258
+ width="12"
1259
+ height="12"
1260
+ rx="2"
1261
+ stroke="currentColor"
1262
+ stroke-width="1.25" />
1263
+ <path
1264
+ d="M5 5H11M5 8H9"
1265
+ stroke="currentColor"
1266
+ stroke-width="1.25"
1267
+ stroke-linecap="round" />
1268
+ </svg>
1269
+ </button>
1270
+ </div>
1271
+ {/if}
1272
+ </div>
1273
+ {/if}
1274
+
1275
+ <!-- Search bar -->
1276
+ {#if search_open}
1277
+ <div class="search-bar">
1278
+ <input
1279
+ type="text"
1280
+ placeholder="Search in document..."
1281
+ value={search_query}
1282
+ bind:this={search_input_el}
1283
+ oninput={handleSearchInput}
1284
+ onkeydown={handleSearchKeydown}
1285
+ aria-label="Search text" />
1286
+ {#if search_count_text}
1287
+ <span class="count">{search_count_text}</span>
1288
+ {/if}
1289
+ <button
1290
+ type="button"
1291
+ class="btn"
1292
+ onclick={searchPrev}
1293
+ disabled={search_matches.length === 0}
1294
+ aria-label="Previous match">
1295
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1296
+ <path
1297
+ d="M12 10L8 6L4 10"
1298
+ stroke="currentColor"
1299
+ stroke-width="1.5"
1300
+ stroke-linecap="round"
1301
+ stroke-linejoin="round" />
1302
+ </svg>
1303
+ </button>
1304
+ <button
1305
+ type="button"
1306
+ class="btn"
1307
+ onclick={searchNext}
1308
+ disabled={search_matches.length === 0}
1309
+ aria-label="Next match">
1310
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1311
+ <path
1312
+ d="M4 6L8 10L12 6"
1313
+ stroke="currentColor"
1314
+ stroke-width="1.5"
1315
+ stroke-linecap="round"
1316
+ stroke-linejoin="round" />
1317
+ </svg>
1318
+ </button>
1319
+ <button type="button" class="btn" onclick={closeSearch} aria-label="Close search">
1320
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1321
+ <path
1322
+ d="M4 4L12 12M12 4L4 12"
1323
+ stroke="currentColor"
1324
+ stroke-width="1.5"
1325
+ stroke-linecap="round" />
1326
+ </svg>
1327
+ </button>
1328
+ </div>
1329
+ {/if}
1330
+
1331
+ <!-- Pages -->
1332
+ <div
1333
+ class="pdf-pages"
1334
+ bind:this={pages_container}
1335
+ style={single_page && auto_paginate
1336
+ ? `transform: translateY(-${(page - 1) * 100}%); transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);`
1337
+ : undefined}
1338
+ {@attach scrollbar()}>
1339
+ {#each page_infos as info, i}
1340
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1341
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1342
+ <div
1343
+ class="pdf-page"
1344
+ class:single-page-slot={single_page}
1345
+ data-page={i + 1}
1346
+ style={single_page ? `top: ${i * 100}%;` : getPageStyle(i)}
1347
+ bind:this={page_elements[i]}
1348
+ onmouseup={() => handlePageMouseUp(i + 1)}
1349
+ onclick={(e) => handlePageClick(e, i + 1)}>
1350
+ {#if !rendered_pages.has(i + 1) && !single_page}
1351
+ <div class="placeholder">
1352
+ <span>{i + 1}</span>
1353
+ </div>
1354
+ {/if}
1355
+ </div>
1356
+ {/each}
1357
+ </div>
1358
+ {/if}
1359
+ </div>
1360
+
1361
+ <style>
1362
+ /* ── Container ────────────────────────────────────────────── */
1363
+
1364
+ .pdf-container {
1365
+ position: relative;
1366
+ width: 100%;
1367
+ height: var(--pdf-height, 600px);
1368
+ display: flex;
1369
+ flex-direction: column;
1370
+ border-radius: var(--radius-md, 0.5rem);
1371
+ @supports (corner-shape: squircle) {
1372
+ corner-shape: squircle;
1373
+ border-radius: calc(var(--radius-md, 0.5rem) * var(--squircle-ratio, 2));
1374
+ }
1375
+ overflow: hidden;
1376
+ background: light-dark(
1377
+ var(--color-bg-muted, #f1f5f9),
1378
+ var(--color-bg-muted, #0f172a)
1379
+ );
1380
+ outline: none;
1381
+ }
1382
+
1383
+ .pdf-container:focus-visible {
1384
+ outline: 2px solid var(--color-action, #3b82f6);
1385
+ outline-offset: -2px;
1386
+ }
1387
+
1388
+ /* ── Toolbar ──────────────────────────────────────────────── */
1389
+
1390
+ .toolbar {
1391
+ display: flex;
1392
+ align-items: center;
1393
+ gap: 0.5rem;
1394
+ padding: 0.5rem 0.75rem;
1395
+ background: light-dark(var(--color-surface, #f8fafc), var(--color-surface, #1a2332));
1396
+ border-bottom: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
1397
+ flex-shrink: 0;
1398
+ flex-wrap: wrap;
1399
+
1400
+ .group {
1401
+ display: flex;
1402
+ align-items: center;
1403
+ gap: 0.25rem;
1404
+ }
1405
+
1406
+ .separator {
1407
+ width: 1px;
1408
+ height: 1.5rem;
1409
+ background: var(--color-border, light-dark(#e2e8f0, #334155));
1410
+ margin-inline: 0.25rem;
1411
+ }
1412
+
1413
+ .spacer {
1414
+ flex: 1;
1415
+ }
1416
+
1417
+ input {
1418
+ width: 3rem;
1419
+ text-align: center;
1420
+ padding: 0.25rem;
1421
+ border: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
1422
+ border-radius: var(--radius-sm, 0.25rem);
1423
+ @supports (corner-shape: squircle) {
1424
+ corner-shape: squircle;
1425
+ border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
1426
+ }
1427
+ font-size: var(--text-sm, 0.875rem);
1428
+ background: light-dark(var(--color-bg, #ffffff), var(--color-bg, #1e293b));
1429
+ color: var(--color-text, light-dark(#1e293b, #e2e8f0));
1430
+
1431
+ &:focus {
1432
+ outline: 2px solid var(--color-action, #3b82f6);
1433
+ outline-offset: -1px;
1434
+ }
1435
+ }
1436
+
1437
+ .total {
1438
+ font-size: var(--text-sm, 0.875rem);
1439
+ color: var(--color-text-muted, light-dark(#64748b, #94a3b8));
1440
+ user-select: none;
1441
+ }
1442
+
1443
+ .zoom {
1444
+ font-size: var(--text-sm, 0.875rem);
1445
+ color: var(--color-text, light-dark(#1e293b, #e2e8f0));
1446
+ min-width: 3rem;
1447
+ text-align: center;
1448
+ user-select: none;
1449
+ font-variant-numeric: tabular-nums;
1450
+ }
1451
+ }
1452
+
1453
+ /* Shared by the toolbar and the search bar */
1454
+ .btn {
1455
+ display: flex;
1456
+ align-items: center;
1457
+ justify-content: center;
1458
+ width: 32px;
1459
+ height: 32px;
1460
+ border: none;
1461
+ background: none;
1462
+ border-radius: var(--radius-sm, 0.25rem);
1463
+ @supports (corner-shape: squircle) {
1464
+ corner-shape: squircle;
1465
+ border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
1466
+ }
1467
+ cursor: pointer;
1468
+ color: var(--color-text, light-dark(#1e293b, #e2e8f0));
1469
+ transition: background 150ms ease;
1470
+
1471
+ &:hover {
1472
+ background: light-dark(
1473
+ var(--color-surface, rgb(0 0 0 / 0.06)),
1474
+ var(--color-surface, rgb(255 255 255 / 0.08))
1475
+ );
1476
+ /* Snap the tint in on hover; the base rule eases it back out on leave. */
1477
+ transition: none;
1478
+ }
1479
+
1480
+ &:disabled {
1481
+ opacity: 0.4;
1482
+ cursor: default;
1483
+
1484
+ &:hover {
1485
+ background: none;
1486
+ }
1487
+ }
1488
+
1489
+ &.active {
1490
+ background: light-dark(
1491
+ rgb(from var(--color-action, #3b82f6) r g b / 0.12),
1492
+ rgb(from var(--color-action, #3b82f6) r g b / 0.2)
1493
+ );
1494
+ color: var(--color-action, #3b82f6);
1495
+ }
1496
+ }
1497
+
1498
+ /* ── Search bar ───────────────────────────────────────────── */
1499
+
1500
+ .search-bar {
1501
+ display: flex;
1502
+ align-items: center;
1503
+ gap: 0.5rem;
1504
+ padding: 0.5rem 0.75rem;
1505
+ background: light-dark(var(--color-surface, #f8fafc), var(--color-surface, #1a2332));
1506
+ border-bottom: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
1507
+ flex-shrink: 0;
1508
+
1509
+ input {
1510
+ flex: 1;
1511
+ min-width: 0;
1512
+ padding: 0.25rem 0.5rem;
1513
+ border: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
1514
+ border-radius: var(--radius-sm, 0.25rem);
1515
+ @supports (corner-shape: squircle) {
1516
+ corner-shape: squircle;
1517
+ border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
1518
+ }
1519
+ font-size: var(--text-sm, 0.875rem);
1520
+ background: light-dark(var(--color-bg, #ffffff), var(--color-bg, #1e293b));
1521
+ color: var(--color-text, light-dark(#1e293b, #e2e8f0));
1522
+
1523
+ &:focus {
1524
+ outline: 2px solid var(--color-action, #3b82f6);
1525
+ outline-offset: -1px;
1526
+ }
1527
+ }
1528
+
1529
+ .count {
1530
+ font-size: var(--text-xs, 0.75rem);
1531
+ color: var(--color-text-muted, light-dark(#64748b, #94a3b8));
1532
+ white-space: nowrap;
1533
+ user-select: none;
1534
+ }
1535
+ }
1536
+
1537
+ /* ── Pages container ──────────────────────────────────────── */
1538
+
1539
+ .pdf-pages {
1540
+ flex: 1;
1541
+ overflow-y: auto;
1542
+ overscroll-behavior: contain;
1543
+ padding: 1rem;
1544
+ display: flex;
1545
+ flex-direction: column;
1546
+ align-items: center;
1547
+ gap: 1rem;
1548
+ }
1549
+
1550
+ .single-page {
1551
+ height: 100%;
1552
+ background: transparent;
1553
+ border-radius: 0;
1554
+ /* Allow adjacent pages to render outside the slide bounds so the
1555
+ Carousel can scroll between them with both pages visible at once. */
1556
+ overflow: visible;
1557
+ }
1558
+ /* When the PDF paginates itself (standalone single-page mode) clip to the
1559
+ container so only the active page shows during the translate. */
1560
+ .single-page.auto-paginate {
1561
+ overflow: hidden;
1562
+ }
1563
+
1564
+ .single-page .pdf-pages {
1565
+ /* The slide is one page tall; adjacent pages live at top: ±100%
1566
+ relative to this container and need to be visible during swipe
1567
+ transitions. */
1568
+ overflow: visible;
1569
+ padding: 0;
1570
+ gap: 0;
1571
+ display: block;
1572
+ position: relative;
1573
+ flex: 1 1 auto;
1574
+ min-height: 0;
1575
+ }
1576
+
1577
+ .pdf-page.single-page-slot {
1578
+ /* Each page slot is absolutely positioned in a vertical stack so a
1579
+ carousel `translateY(-page * 100%)` on the parent reveals the
1580
+ prev/next page during the swipe. The slot fills the slide; the
1581
+ actual page canvas is centered inside via flex.
1582
+
1583
+ `transform-origin: 0 0` is required so the per-slot matrices applied
1584
+ by the Carousel (which already bake the origin into the matrix via
1585
+ the translate-scale-translate trick) anchor zooms at the click
1586
+ location rather than the slot's geometric center. */
1587
+ position: absolute;
1588
+ left: 0;
1589
+ right: 0;
1590
+ width: 100%;
1591
+ height: 100%;
1592
+ display: flex;
1593
+ align-items: center;
1594
+ justify-content: center;
1595
+ background: transparent;
1596
+ box-shadow: none;
1597
+ transform-origin: 0 0;
1598
+ will-change: transform;
1599
+ }
1600
+
1601
+ .pdf-page.single-page-slot :global(canvas) {
1602
+ max-width: 100%;
1603
+ max-height: 100%;
1604
+ object-fit: contain;
1605
+ box-shadow:
1606
+ 0 1px 3px rgb(0 0 0 / 0.12),
1607
+ 0 1px 2px rgb(0 0 0 / 0.08);
1608
+ }
1609
+
1610
+ /* ── Page ─────────────────────────────────────────────────── */
1611
+
1612
+ .pdf-page {
1613
+ position: relative;
1614
+ box-shadow:
1615
+ 0 1px 3px rgb(0 0 0 / 0.12),
1616
+ 0 1px 2px rgb(0 0 0 / 0.08);
1617
+ background: white;
1618
+ flex-shrink: 0;
1619
+ }
1620
+
1621
+ .pdf-page :global(canvas) {
1622
+ display: block;
1623
+ }
1624
+
1625
+ .pdf-page :global(.pdf-text-layer) {
1626
+ position: absolute;
1627
+ inset: 0;
1628
+ overflow: hidden;
1629
+ opacity: 0.25;
1630
+ line-height: 1;
1631
+ }
1632
+
1633
+ .pdf-page :global(.pdf-text-layer span) {
1634
+ color: transparent;
1635
+ }
1636
+
1637
+ .pdf-page :global(.pdf-text-layer span::selection) {
1638
+ background: color-mix(in oklch, var(--color-action, #3b82f6) 40%, transparent);
1639
+ }
1640
+
1641
+ .pdf-page .placeholder {
1642
+ position: absolute;
1643
+ inset: 0;
1644
+ display: flex;
1645
+ align-items: center;
1646
+ justify-content: center;
1647
+ background: white;
1648
+
1649
+ span {
1650
+ font-size: 2rem;
1651
+ color: light-dark(#cbd5e1, #475569);
1652
+ user-select: none;
1653
+ }
1654
+ }
1655
+
1656
+ /* ── Error state ──────────────────────────────────────────── */
1657
+
1658
+ .error {
1659
+ flex: 1;
1660
+ display: flex;
1661
+ flex-direction: column;
1662
+ align-items: center;
1663
+ justify-content: center;
1664
+ gap: 0.75rem;
1665
+ color: var(--color-text-muted, light-dark(#64748b, #94a3b8));
1666
+ padding: 2rem;
1667
+ text-align: center;
1668
+
1669
+ .detail {
1670
+ font-size: var(--text-sm, 0.875rem);
1671
+ max-width: 30rem;
1672
+ word-break: break-word;
1673
+ }
1674
+ }
1675
+
1676
+ /* ── Skeleton ─────────────────────────────────────────────── */
1677
+
1678
+ .skeleton {
1679
+ flex: 1;
1680
+ display: flex;
1681
+ flex-direction: column;
1682
+ pointer-events: none;
1683
+
1684
+ .bar {
1685
+ display: flex;
1686
+ align-items: center;
1687
+ gap: 1rem;
1688
+ padding: 0.5rem 0.75rem;
1689
+ border-bottom: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
1690
+ }
1691
+
1692
+ .block {
1693
+ border-radius: var(--radius-sm, 0.25rem);
1694
+ @supports (corner-shape: squircle) {
1695
+ corner-shape: squircle;
1696
+ border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
1697
+ }
1698
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
1699
+ position: relative;
1700
+ overflow: hidden;
1701
+
1702
+ &::after {
1703
+ content: '';
1704
+ position: absolute;
1705
+ inset: 0;
1706
+ transform: translateX(-100%);
1707
+ background-image: linear-gradient(
1708
+ 105deg,
1709
+ transparent 25%,
1710
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1711
+ transparent 75%
1712
+ );
1713
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1714
+ infinite;
1715
+ }
1716
+ }
1717
+
1718
+ /* Centering backdrop behind the "paper". A subtle surface tint makes the
1719
+ white page read as a sheet of paper regardless of the page theme. */
1720
+ /* Full-width backdrop behind the "paper". A subtle surface tint makes the
1721
+ white page read as a sheet of paper regardless of the page theme. Grid
1722
+ centering (rather than flex) lets the paper derive its width from its
1723
+ definite height + aspect-ratio instead of collapsing to its content. */
1724
+ .page {
1725
+ flex: 1;
1726
+ min-height: 0;
1727
+ display: grid;
1728
+ place-items: center;
1729
+ padding: 1.5rem;
1730
+ background: light-dark(#f1f5f9, #0f172a);
1731
+ }
1732
+
1733
+ /* The page itself — a portrait US-letter sheet (8.5 × 11) in white, since
1734
+ most PDFs are white paper. Lines below mimic dark text. */
1735
+ .paper {
1736
+ height: 100%;
1737
+ aspect-ratio: 8.5 / 11;
1738
+ max-width: 100%;
1739
+ background: #ffffff;
1740
+ border-radius: 3px;
1741
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
1742
+ display: flex;
1743
+ flex-direction: column;
1744
+ gap: 0.85rem;
1745
+ padding: 9% 10%;
1746
+ overflow: hidden;
1747
+ }
1748
+
1749
+ .line {
1750
+ flex: none;
1751
+ height: 0.7rem;
1752
+ border-radius: 3px;
1753
+ background: rgba(15, 23, 42, 0.08);
1754
+ position: relative;
1755
+ overflow: hidden;
1756
+
1757
+ &::after {
1758
+ content: '';
1759
+ position: absolute;
1760
+ inset: 0;
1761
+ transform: translateX(-100%);
1762
+ /* The paper is always white, so the sheen stays ink-on-paper rather
1763
+ than using the theme-aware skeleton tokens. */
1764
+ background-image: linear-gradient(
1765
+ 105deg,
1766
+ rgba(15, 23, 42, 0) 25%,
1767
+ rgba(15, 23, 42, 0.14) 50%,
1768
+ rgba(15, 23, 42, 0) 75%
1769
+ );
1770
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1771
+ infinite;
1772
+ animation-delay: var(--shimmer-delay, 0ms);
1773
+ }
1774
+ }
1775
+ }
1776
+
1777
+ @keyframes -global-delight-skeleton-shimmer {
1778
+ 0% {
1779
+ transform: translateX(-100%);
1780
+ }
1781
+ 55%,
1782
+ 100% {
1783
+ transform: translateX(100%);
1784
+ }
1785
+ }
1786
+
1787
+ @media (prefers-reduced-motion: reduce) {
1788
+ .skeleton .block::after,
1789
+ .skeleton .line::after {
1790
+ animation: none;
1791
+ }
1792
+ }
1793
+ </style>