@elabs-ai/components-viewer 4.0.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,947 @@
1
+ import { Button } from "@elabs-ai/components-ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+
5
+ import {
6
+ SAMPLE_DOCX_DATA_URI,
7
+ SAMPLE_PPTX_DATA_URI,
8
+ SAMPLE_XLSX_DATA_URI,
9
+ } from "../adapters/office-fixture";
10
+ import { SAMPLE_PDF_DATA_URI } from "../adapters/pdf/pdf-fixture";
11
+ import { ViewerError } from "../core/errors";
12
+ import { createRegistry } from "../core/registry";
13
+ import { PROTOCOL_VERSION } from "../core/types";
14
+ import {
15
+ FileViewer,
16
+ FileViewerContent,
17
+ FileViewerFrame,
18
+ FileViewerProvider,
19
+ FileViewerToolbar,
20
+ } from "./file-viewer";
21
+ import { useFileViewer } from "./file-viewer-context";
22
+ import { FileViewerPager } from "./file-viewer-pager";
23
+ import { FileViewerRotate, FileViewerZoom } from "./file-viewer-zoom";
24
+
25
+ /**
26
+ * `FileViewer` renders any file the registry can claim. The adapter decides how
27
+ * a format is parsed and drawn; this shell owns the chrome and the state grid,
28
+ * so every format gets the same identity row, skeleton, empty and error states.
29
+ */
30
+ const meta = {
31
+ title: "Viewer/FileViewer",
32
+ component: FileViewer,
33
+ tags: ["autodocs"],
34
+ parameters: {
35
+ layout: "padded",
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "Point it at a File, a Blob, a URL or a string and it detects the format, " +
40
+ "loads the matching adapter on demand, and renders it with brand-ui components. " +
41
+ "Formats are added by registering an adapter, never by editing this component.",
42
+ },
43
+ },
44
+ },
45
+ decorators: [
46
+ (Story) => (
47
+ <div className="h-[420px] w-full max-w-3xl">
48
+ <Story />
49
+ </div>
50
+ ),
51
+ ],
52
+ } satisfies Meta<typeof FileViewer>;
53
+
54
+ export default meta;
55
+ type Story = StoryObj<typeof meta>;
56
+
57
+ /* -------------------------------------------------------------------------- */
58
+ /* Fixtures */
59
+ /* -------------------------------------------------------------------------- */
60
+
61
+ const CSV = `region,orders,revenue,note
62
+ EMEA,1240,982400,"Includes Ireland, remapped in Q2"
63
+ AMER,2110,1704300,Steady
64
+ APAC,880,613900,"Two launches, one delayed"
65
+ `;
66
+
67
+ const JSON_TEXT = JSON.stringify(
68
+ {
69
+ pipeline: "nightly-ingest",
70
+ runs: [
71
+ { id: "r-8812", status: "succeeded", durationMs: 41200, rows: 1_204_881 },
72
+ { id: "r-8811", status: "failed", durationMs: 980, error: "source unreachable" },
73
+ ],
74
+ owner: { team: "Data Platform", contact: "data-platform@example.test" },
75
+ },
76
+ null,
77
+ 2,
78
+ );
79
+
80
+ const LOG = `2026-08-10T06:00:01Z INFO starting nightly-ingest
81
+ 2026-08-10T06:00:02Z INFO connected to source (12 shards)
82
+ 2026-08-10T06:41:13Z WARN shard 7 retried once
83
+ 2026-08-10T06:41:14Z INFO wrote 1204881 rows
84
+ `;
85
+
86
+ const MARKDOWN = `# Nightly ingest
87
+
88
+ Runs at **06:00 UTC** and writes into the \`warehouse.raw\` schema.
89
+
90
+ ## What it does
91
+
92
+ 1. Reads every shard the source advertises
93
+ 2. Normalises timestamps to UTC
94
+ 3. Writes one partition per shard
95
+
96
+ > A shard that fails is retried once, then reported. Partial runs are never
97
+ > published.
98
+
99
+ See the [runbook](https://example.com/runbook) for the escalation path.
100
+
101
+ \`\`\`bash
102
+ pnpm ingest --since 2026-08-09
103
+ \`\`\`
104
+ `;
105
+
106
+ const SOURCE = `import { useContext } from "react";
107
+
108
+ import { FileViewerContext } from "./file-viewer-context";
109
+
110
+ /** Read and drive the viewer from anywhere inside the provider. */
111
+ export function useFileViewer(): FileViewerContextValue {
112
+ const value = useContext(FileViewerContext);
113
+ if (!value) {
114
+ // A control outside the provider has no state to drive — say so here
115
+ // rather than failing later with an undefined read.
116
+ throw new Error("useFileViewer must be used inside a <FileViewerProvider>.");
117
+ }
118
+ return value;
119
+ }
120
+ `;
121
+
122
+ /**
123
+ * A real image file, inline so the story needs no network. Drawn as SVG rather
124
+ * than a base64 blob so the fixture is readable — and deliberately in the file's
125
+ * OWN colours, because the viewer shows a document as authored and only themes
126
+ * the chrome around it.
127
+ */
128
+ const DIAGRAM_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="300" viewBox="0 0 480 300">
129
+ <rect width="480" height="300" fill="whitesmoke"/>
130
+ <text x="24" y="44" font-family="Helvetica, Arial, sans-serif" font-size="18" fill="dimgray">Nightly ingest — rows per shard</text>
131
+ <line x1="24" y1="250" x2="456" y2="250" stroke="darkgray" stroke-width="2"/>
132
+ <rect x="48" y="150" width="52" height="100" fill="steelblue"/>
133
+ <rect x="120" y="96" width="52" height="154" fill="steelblue"/>
134
+ <rect x="192" y="182" width="52" height="68" fill="steelblue"/>
135
+ <rect x="264" y="120" width="52" height="130" fill="steelblue"/>
136
+ <rect x="336" y="206" width="52" height="44" fill="indianred"/>
137
+ <text x="336" y="274" font-family="Helvetica, Arial, sans-serif" font-size="13" fill="indianred">retried</text>
138
+ </svg>`;
139
+
140
+ const DIAGRAM = `data:image/svg+xml;utf8,${encodeURIComponent(DIAGRAM_SVG)}`;
141
+
142
+ /**
143
+ * A real, playable ~1.5 s silent 8-bit mono WAV, synthesized here so the story
144
+ * needs no network origin (`docs/CSP-AND-NETWORK.md`) and no binary fixture.
145
+ * Same technique as `@elabs-ai/components-ai`'s audio-player story —
146
+ * copied rather than imported, because a Layer-2 package may not reach sideways.
147
+ */
148
+ function silentWav(seconds = 1.5, sampleRate = 8000): string {
149
+ const samples = Math.floor(seconds * sampleRate);
150
+ const bytes = new Uint8Array(44 + samples).fill(128); // 128 == silence, 8-bit unsigned
151
+ const view = new DataView(bytes.buffer);
152
+ const ascii = (offset: number, text: string) => {
153
+ for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
154
+ };
155
+ ascii(0, "RIFF");
156
+ view.setUint32(4, 36 + samples, true);
157
+ ascii(8, "WAVEfmt ");
158
+ view.setUint32(16, 16, true); // PCM header size
159
+ view.setUint16(20, 1, true); // format: PCM
160
+ view.setUint16(22, 1, true); // channels: mono
161
+ view.setUint32(24, sampleRate, true);
162
+ view.setUint32(28, sampleRate, true); // byte rate
163
+ view.setUint16(32, 1, true); // block align
164
+ view.setUint16(34, 8, true); // bits per sample
165
+ ascii(36, "data");
166
+ view.setUint32(40, samples, true);
167
+
168
+ let binary = "";
169
+ for (const byte of bytes) binary += String.fromCharCode(byte);
170
+ return `data:audio/wav;base64,${btoa(binary)}`;
171
+ }
172
+
173
+ const TONE = silentWav();
174
+
175
+ /* -------------------------------------------------------------------------- */
176
+ /* Formats */
177
+ /* -------------------------------------------------------------------------- */
178
+
179
+ export const Default: Story = {
180
+ args: { source: { kind: "text", text: JSON_TEXT, name: "pipeline-runs.json" } },
181
+ };
182
+
183
+ /** A real CSV parser, so a quoted comma stays in one cell. */
184
+ export const Csv: Story = {
185
+ name: "CSV",
186
+ args: { source: { kind: "text", text: CSV, name: "sales-by-region.csv" } },
187
+ };
188
+
189
+ export const PlainText: Story = {
190
+ args: { source: { kind: "text", text: LOG, name: "nightly-ingest.log" } },
191
+ };
192
+
193
+ /**
194
+ * A markdown file read as a DOCUMENT — real headings, lists and links drawn with
195
+ * the library's own prose primitives, so a README in the viewer looks like the
196
+ * same README in a chat answer or the editor's preview.
197
+ *
198
+ * `streamdown` is an optional peer. No Streamdown plugins are installed, which
199
+ * is a deliberate trade: fenced code renders as an unhighlighted block rather
200
+ * than costing a consumer four more packages to open one file. A source file
201
+ * opened directly still gets Shiki — see `Source code` below.
202
+ */
203
+ export const Markdown: Story = {
204
+ args: { source: { kind: "text", text: MARKDOWN, name: "README.md" } },
205
+ };
206
+
207
+ /**
208
+ * A source file, tokenized by Shiki at load and coloured entirely with
209
+ * `var(--code-*)` references — so switching the theme recolours the document
210
+ * without re-highlighting it, and no raw hex ever enters the model.
211
+ *
212
+ * `shiki` is an optional peer, and this adapter claims only extensions it has a
213
+ * grammar for. A `.log`, a `.env` or an unknown extension keeps falling through
214
+ * to the plain-text backstop rather than failing to open.
215
+ */
216
+ export const SourceCode: Story = {
217
+ name: "Source code",
218
+ args: { source: { kind: "text", text: SOURCE, name: "use-file-viewer.ts" } },
219
+ };
220
+
221
+ export const Image: Story = {
222
+ args: {
223
+ source: {
224
+ kind: "url",
225
+ url: DIAGRAM,
226
+ name: "rows-per-shard.svg",
227
+ mediaType: "image/svg+xml",
228
+ // The description travels with the source, so a screen-reader user gets
229
+ // what the picture says — not just that a picture is there.
230
+ alt: "Bar chart of rows per shard; shard 7 is the shortest bar and is marked as retried.",
231
+ },
232
+ },
233
+ };
234
+
235
+ /**
236
+ * A real PDF, opened by pdf.js on a worker: the page is rasterized in the file's
237
+ * own colours, and a transparent text layer over it stays selectable.
238
+ *
239
+ * The pages STACK and the pane scrolls, the way every document reader works —
240
+ * page 2 is a gesture away, not behind a button. Only the pages near the viewport
241
+ * are mounted, so the cost follows the screen rather than the page count.
242
+ *
243
+ * The pager, the scale and the rotate button are the SHELL's (ADR 0026), not the
244
+ * adapter's — which is why the same controls drive the deck below, and why an app
245
+ * can move any of them into its own header.
246
+ *
247
+ * `pdfjs-dist` is an optional peer — a consumer who never opens a PDF never
248
+ * installs it, and one who forgot sees the `ParserMissing` panel below.
249
+ */
250
+ export const Pdf: Story = {
251
+ name: "PDF",
252
+ args: {
253
+ source: {
254
+ kind: "url",
255
+ url: SAMPLE_PDF_DATA_URI,
256
+ name: "quarterly-report.pdf",
257
+ mediaType: "application/pdf",
258
+ },
259
+ },
260
+ };
261
+
262
+ /**
263
+ * Video and audio use the NATIVE elements on purpose — the platform's own
264
+ * transport brings keyboard control, captions, picture-in-picture and the OS
265
+ * media keys, none of which a custom skin gets for free. The adapter streams
266
+ * from a URL and never buffers the bytes, so a 2 GB recording seeks instantly.
267
+ *
268
+ * The fixture is audio because a real, playable video cannot be synthesized
269
+ * inline; the element and the chrome are the same for both.
270
+ */
271
+ export const Audio: Story = {
272
+ args: {
273
+ source: {
274
+ kind: "url",
275
+ url: TONE,
276
+ name: "standup-recording.wav",
277
+ mediaType: "audio/wav",
278
+ },
279
+ },
280
+ };
281
+
282
+ /**
283
+ * The settled "can't play this" outcome — a codec the browser does not ship, or
284
+ * a truncated file. It is an error, not a capability gap: the format IS
285
+ * supported, this particular file just will not decode. No retry is offered,
286
+ * because retrying cannot install a codec.
287
+ */
288
+ export const MediaUndecodable: Story = {
289
+ name: "Media (undecodable)",
290
+ args: {
291
+ source: {
292
+ kind: "url",
293
+ url: "data:video/mp4;base64,AAAA",
294
+ name: "briefing.mp4",
295
+ mediaType: "video/mp4",
296
+ },
297
+ },
298
+ };
299
+
300
+ /**
301
+ * A real Word file. mammoth resolves Word's styles, numbering and images; the
302
+ * adapter then parses that into a block model and draws it with the library's own
303
+ * prose primitives — so a `.docx` inherits the theme, the type scale and real
304
+ * heading semantics instead of arriving as a slab of foreign markup.
305
+ *
306
+ * It shows the document's STRUCTURE, not Word's page layout: no page breaks, no
307
+ * columns, no margins. The toolbar's download is the answer for the real thing.
308
+ */
309
+ export const Word: Story = {
310
+ args: {
311
+ source: {
312
+ kind: "url",
313
+ url: SAMPLE_DOCX_DATA_URI,
314
+ name: "quarterly-review.docx",
315
+ mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
316
+ },
317
+ },
318
+ };
319
+
320
+ /**
321
+ * A real workbook, every sheet reachable. SheetJS is asked only for the DATA —
322
+ * never its `sheet_to_html` helper — so the grid is the same `Table` the CSV
323
+ * adapter renders, with the theme, the density dial and keyboard semantics.
324
+ *
325
+ * `xlsx` is an optional peer with two npm-only advisories; see
326
+ * `docs/CONSUMING.md` before installing it in a build that opens untrusted files.
327
+ */
328
+ export const Excel: Story = {
329
+ args: {
330
+ source: {
331
+ kind: "url",
332
+ url: SAMPLE_XLSX_DATA_URI,
333
+ name: "quarter.xlsx",
334
+ mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
335
+ },
336
+ },
337
+ };
338
+
339
+ /**
340
+ * A real deck, read as an outline: title, text at its authored indent level, and
341
+ * the speaker notes — which are followed through the slide's own relationship,
342
+ * not by matching slide numbers.
343
+ *
344
+ * Slides stack and scroll, like the PDF above: a deck is skimmed by scrolling
345
+ * through it, not by clicking "next" once per slide.
346
+ *
347
+ * There is no PowerPoint library here; a `.pptx` is a zip of XML, so jszip opens
348
+ * it and the platform's `DOMParser` reads it. Positional design (layout, images,
349
+ * charts, transitions) is deliberately absent rather than half-reproduced.
350
+ */
351
+ export const PowerPoint: Story = {
352
+ args: {
353
+ source: {
354
+ kind: "url",
355
+ url: SAMPLE_PPTX_DATA_URI,
356
+ name: "quarterly-review.pptx",
357
+ mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
358
+ },
359
+ },
360
+ };
361
+
362
+ /* -------------------------------------------------------------------------- */
363
+ /* States */
364
+ /* -------------------------------------------------------------------------- */
365
+
366
+ /** No file chosen yet — an invitation, not a failure. */
367
+ export const Empty: Story = { args: {} };
368
+
369
+ /**
370
+ * The layout-shaped skeleton. Held open by an adapter that never resolves, so
371
+ * the state can be inspected rather than glimpsed.
372
+ */
373
+ export const Loading: Story = {
374
+ args: {
375
+ source: { kind: "text", text: LOG, name: "nightly-ingest.log" },
376
+ registry: (() => {
377
+ const registry = createRegistry();
378
+ registry.register(
379
+ { id: "slow", protocol: PROTOCOL_VERSION, categories: ["text"], mediaTypes: ["text/"] },
380
+ () =>
381
+ Promise.resolve({
382
+ manifest: { id: "slow", protocol: PROTOCOL_VERSION, categories: ["text"] },
383
+ create: () => ({ load: () => new Promise<never>(() => undefined) }),
384
+ Renderer: () => null,
385
+ }),
386
+ );
387
+ return registry;
388
+ })(),
389
+ },
390
+ };
391
+
392
+ /** No adapter claims the file. Not retryable — retrying changes nothing. */
393
+ export const UnsupportedFormat: Story = {
394
+ args: {
395
+ source: {
396
+ kind: "text",
397
+ text: "%PDF-1.7",
398
+ name: "contract.pdf",
399
+ mediaType: "application/pdf",
400
+ },
401
+ registry: (() => {
402
+ const registry = createRegistry();
403
+ registry.register(
404
+ { id: "text", protocol: PROTOCOL_VERSION, categories: ["text"] },
405
+ () => import("../adapters/text/text-adapter"),
406
+ );
407
+ return registry;
408
+ })(),
409
+ },
410
+ };
411
+
412
+ /**
413
+ * The optional parser package is not installed. The message names it, because
414
+ * "couldn't open this file" would leave the reader with nothing to do.
415
+ */
416
+ export const ParserMissing: Story = {
417
+ args: {
418
+ source: { kind: "text", text: CSV, name: "sales-by-region.csv" },
419
+ registry: (() => {
420
+ const registry = createRegistry();
421
+ registry.register(
422
+ {
423
+ id: "csv",
424
+ protocol: PROTOCOL_VERSION,
425
+ extensions: ["csv"],
426
+ requires: ["papaparse"],
427
+ },
428
+ () => Promise.reject(new Error("Cannot find module 'papaparse'")),
429
+ );
430
+ return registry;
431
+ })(),
432
+ },
433
+ };
434
+
435
+ /** A settled read failure — the one error state that offers a retry. */
436
+ export const ReadFailed: Story = {
437
+ args: {
438
+ source: { kind: "text", text: LOG, name: "nightly-ingest.log" },
439
+ registry: (() => {
440
+ const registry = createRegistry();
441
+ registry.register(
442
+ { id: "broken", protocol: PROTOCOL_VERSION, categories: ["text"], mediaTypes: ["text/"] },
443
+ () =>
444
+ Promise.resolve({
445
+ manifest: { id: "broken", protocol: PROTOCOL_VERSION, categories: ["text"] },
446
+ create: () => ({
447
+ // A typed failure, not a bare Error: the CODE picks the copy and
448
+ // decides whether a retry is offered, so a story that says
449
+ // "read failed" has to throw one.
450
+ load: () =>
451
+ Promise.reject(
452
+ new ViewerError("read-failed", "The network connection was lost.", {
453
+ fileName: "nightly-ingest.log",
454
+ }),
455
+ ),
456
+ }),
457
+ Renderer: () => null,
458
+ }),
459
+ );
460
+ return registry;
461
+ })(),
462
+ },
463
+ };
464
+
465
+ /** A parent that fetches the file itself can hold the viewer not-ready. */
466
+ export const ParentLoading: Story = {
467
+ args: { source: { kind: "text", text: LOG, name: "nightly-ingest.log" }, loading: true },
468
+ };
469
+
470
+ /* -------------------------------------------------------------------------- */
471
+ /* Composition */
472
+ /* -------------------------------------------------------------------------- */
473
+
474
+ /** Reads the viewer state from outside the frame — no prop-drilling, no refs. */
475
+ function FormatBadge() {
476
+ const { state } = useFileViewer();
477
+ return (
478
+ <p className="text-meta text-muted-foreground mb-2">
479
+ {state.status === "ready"
480
+ ? `Showing ${state.source?.extension.toUpperCase() ?? ""} · ${state.source?.category ?? ""}`
481
+ : `Status: ${state.status}`}
482
+ </p>
483
+ );
484
+ }
485
+
486
+ /**
487
+ * The parts, arranged by hand. `FileViewerProvider` is the state boundary, so a
488
+ * control **outside** the frame but inside the provider reads the same state the
489
+ * viewer does — the reason this is a compound component rather than one prop bag.
490
+ */
491
+ export const Composed: Story = {
492
+ render: () => (
493
+ <FileViewerProvider source={{ kind: "text", text: JSON_TEXT, name: "pipeline-runs.json" }}>
494
+ <FormatBadge />
495
+ <FileViewerFrame className="h-[380px]">
496
+ <FileViewerToolbar />
497
+ <FileViewerContent />
498
+ </FileViewerFrame>
499
+ </FileViewerProvider>
500
+ ),
501
+ };
502
+
503
+ /* -------------------------------------------------------------------------- */
504
+ /* Page, scale and rotation (ADR 0026) */
505
+ /* -------------------------------------------------------------------------- */
506
+
507
+ const PDF_SOURCE = {
508
+ kind: "url",
509
+ url: SAMPLE_PDF_DATA_URI,
510
+ name: "quarterly-report.pdf",
511
+ mediaType: "application/pdf",
512
+ } as const;
513
+
514
+ /**
515
+ * The same file opened at page 2, at a fixed 125%.
516
+ *
517
+ * Page and scale are the SHELL's state, so they are ordinary props with the
518
+ * controlled/uncontrolled trio — which is what makes a deep link ("open the
519
+ * contract at page 2") a one-liner instead of a reach inside the canvas.
520
+ *
521
+ * There is no rotate button here, and that is the point: the PDF manifest does
522
+ * not claim `rotate`, so the control does not exist. A capability is a promise
523
+ * the renderer keeps — chrome for one it has not implemented would be a lie the
524
+ * reader can click.
525
+ */
526
+ export const PageAndZoom: Story = {
527
+ args: {
528
+ source: PDF_SOURCE,
529
+ defaultPageNumber: 2,
530
+ defaultZoom: 1.25,
531
+ },
532
+ };
533
+
534
+ /**
535
+ * Rotation, on the format that implements it.
536
+ *
537
+ * `image` DOES claim `rotate`, so the button appears and turns the picture a
538
+ * quarter at a time. It also honours the fit modes and the fixed stops, which the
539
+ * manifest had been claiming for a renderer that did neither.
540
+ */
541
+ export const Rotated: Story = {
542
+ args: {
543
+ source: {
544
+ kind: "url",
545
+ url: DIAGRAM,
546
+ name: "rows-per-shard.svg",
547
+ mediaType: "image/svg+xml",
548
+ alt: "Bar chart of rows per shard; shard 7 is the shortest bar and is marked as retried.",
549
+ },
550
+ defaultRotation: 90,
551
+ },
552
+ };
553
+
554
+ /**
555
+ * The controls moved OUT of the viewer, into the app's own header.
556
+ *
557
+ * They are parts over the provider, not adapter chrome: anything inside
558
+ * `FileViewerProvider` reads and drives the same page and scale, so a workspace
559
+ * can put the pager beside a breadcrumb and keep the document pane clean. This is
560
+ * the composition the old design could not express at all — page and zoom were
561
+ * `useState` inside the PDF renderer, so there could only ever be one pager, and
562
+ * only inside the canvas.
563
+ */
564
+ export const ChromeInTheAppHeader: Story = {
565
+ render: () => (
566
+ <FileViewerProvider source={PDF_SOURCE}>
567
+ <div className="mb-2 flex items-center justify-between gap-2">
568
+ <p className="text-meta text-muted-foreground">Contracts / Q3 / quarterly-report.pdf</p>
569
+ <div className="flex items-center gap-1">
570
+ <FileViewerPager />
571
+ <FileViewerZoom />
572
+ <FileViewerRotate />
573
+ </div>
574
+ </div>
575
+ <FileViewerFrame className="h-[360px]">
576
+ <FileViewerContent />
577
+ </FileViewerFrame>
578
+ </FileViewerProvider>
579
+ ),
580
+ play: async ({ canvasElement }) => {
581
+ const canvas = within(canvasElement);
582
+ // Prove the header really drives the document. Under continuous scroll page
583
+ // 2 is already MOUNTED, so its presence proves nothing — what has to follow
584
+ // the button outside the frame is the viewport.
585
+ await canvas.findByRole("img", { name: /Page 1/ }, { timeout: 15_000 });
586
+ const pane = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
587
+ expect(pane?.scrollTop).toBe(0);
588
+ await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
589
+ await waitFor(() => expect(pane?.scrollTop).toBeGreaterThan(0));
590
+ await waitFor(() => expect(canvas.getByRole("img", { name: /Page 2/ })).toBeInTheDocument());
591
+ },
592
+ };
593
+
594
+ /**
595
+ * The other direction: the reader scrolls, and the chrome follows.
596
+ *
597
+ * A paginated document is a COLUMN, not a flipbook — page 2 sits one gesture
598
+ * below page 1, and only the pages near the viewport are ever mounted, so a
599
+ * 900-page file costs what is on screen rather than what is in the file. The page
600
+ * number is a two-way binding: type "2" and the column scrolls, scroll and the
601
+ * field updates.
602
+ */
603
+ export const ContinuousScroll: Story = {
604
+ args: { source: PDF_SOURCE },
605
+ play: async ({ canvasElement }) => {
606
+ const canvas = within(canvasElement);
607
+ await canvas.findByRole("img", { name: /Page 1/ }, { timeout: 15_000 });
608
+ const pane = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
609
+ const field = canvas.getByRole("textbox", { name: "Page number" });
610
+ expect(field).toHaveValue("1");
611
+
612
+ // Scroll the way a reader does — the pane, not a control — and the pager has
613
+ // to arrive at page 2 on its own.
614
+ pane?.scrollTo({ top: pane.scrollHeight });
615
+ pane?.dispatchEvent(new Event("scroll"));
616
+ await waitFor(() => expect(field).toHaveValue("2"));
617
+ },
618
+ };
619
+
620
+ /* -------------------------------------------------------------------------- */
621
+ /* Pointing at part of a document (ADR 0025) */
622
+ /* -------------------------------------------------------------------------- */
623
+
624
+ /** The citations a RAG answer would hand the viewer, quoted from the log above. */
625
+ const CITATIONS = [
626
+ { id: "c1", label: "12 shards were advertised", address: { kind: "quote", text: "12 shards" } },
627
+ { id: "c2", label: "one shard was retried", address: { kind: "quote", text: "shard 7 retried" } },
628
+ { id: "c3", label: "the row count", address: { kind: "quote", text: "1204881 rows" } },
629
+ ] as const;
630
+
631
+ /**
632
+ * Open the find row the way a reader does — focus the document, press the
633
+ * platform's find shortcut, type — and leave it open so the story SHOWS the
634
+ * search state instead of describing it. Also what puts the row in front of the
635
+ * blocking interaction + axe pass.
636
+ */
637
+ function openFindAndSearch(query: string) {
638
+ return async ({ canvasElement }: { canvasElement: HTMLElement }) => {
639
+ const canvas = within(canvasElement);
640
+ // Wait for the file to have actually LOADED, not merely for the pane to
641
+ // exist: the shortcut is a no-op until the adapter has published text to
642
+ // search (the pane holds a skeleton before that), so pressing it early
643
+ // silently does nothing.
644
+ const pane = await waitFor(() => {
645
+ const el = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
646
+ expect(el?.querySelector("pre")).not.toBeNull();
647
+ return el as HTMLElement;
648
+ });
649
+ pane.focus();
650
+ await userEvent.keyboard("{Control>}f{/Control}");
651
+ const box = await canvas.findByRole("textbox", { name: "Find in document" });
652
+ await userEvent.type(box, query);
653
+ await waitFor(() => expect(canvas.getByRole("status")).toHaveTextContent(/of/));
654
+ };
655
+ }
656
+
657
+ /** Prev / next over the located citations, driven from outside the frame. */
658
+ function CitationStepper() {
659
+ const { state, actions, meta } = useFileViewer();
660
+ const located = meta.resolvedHighlights.filter(
661
+ (highlight) => highlight.source === "citation" && highlight.status === "resolved",
662
+ );
663
+ const current = located.find((highlight) => highlight.id === state.activeHighlightId);
664
+ return (
665
+ <div className="mb-2 flex items-center gap-2">
666
+ <Button size="sm" variant="outline" onClick={actions.previousHighlight}>
667
+ Previous passage
668
+ </Button>
669
+ <Button size="sm" variant="outline" onClick={actions.nextHighlight}>
670
+ Next passage
671
+ </Button>
672
+ <span aria-live="polite" className="text-meta text-muted-foreground">
673
+ {current
674
+ ? `Passage ${String(current.index ?? 0)} of ${String(located.length)} — ${current.label ?? ""}`
675
+ : `${String(located.length)} passages`}
676
+ </span>
677
+ </div>
678
+ );
679
+ }
680
+
681
+ /**
682
+ * A chat answer cites three passages; clicking one scrolls the document to it
683
+ * and marks it. The app owns the citation list and which one is current — the
684
+ * viewer only locates and paints them, so the same list drives a sidebar, a
685
+ * stepper, or a chat bubble without any of them talking to each other.
686
+ *
687
+ * The current passage is never signalled by colour alone: it also carries an
688
+ * outline and `aria-current="true"` (WCAG 1.4.1).
689
+ */
690
+ export const Citations: Story = {
691
+ render: () => (
692
+ <FileViewerProvider
693
+ source={{ kind: "text", text: LOG, name: "nightly-ingest.log" }}
694
+ defaultHighlights={[...CITATIONS]}
695
+ defaultActiveHighlightId="c2"
696
+ >
697
+ <CitationStepper />
698
+ <FileViewerFrame className="h-[340px]">
699
+ <FileViewerToolbar />
700
+ <FileViewerContent />
701
+ </FileViewerFrame>
702
+ </FileViewerProvider>
703
+ ),
704
+ };
705
+
706
+ /**
707
+ * A quote that is not in the document is a STATE, not a no-op. The reader
708
+ * clicked a source link, so silence would leave them unable to tell whether the
709
+ * viewer broke, the passage moved, or they mis-clicked.
710
+ */
711
+ export const PassageNotFound: Story = {
712
+ args: {
713
+ source: { kind: "text", text: LOG, name: "nightly-ingest.log" },
714
+ highlights: [{ id: "c1", address: { kind: "quote", text: "a sentence that is not there" } }],
715
+ },
716
+ };
717
+
718
+ /**
719
+ * Find-in-document — press **Ctrl/Cmd + F** with the viewer focused. It paints
720
+ * through the same mark layer citations use, so a document never grows two
721
+ * highlight systems that disagree about what a mark looks like.
722
+ *
723
+ * The shortcut is bound to the FRAME, not `document`: a page may hold several
724
+ * viewers, and the browser's own find is untouched everywhere else.
725
+ */
726
+ export const FindInDocument: Story = {
727
+ args: { source: { kind: "text", text: LOG, name: "nightly-ingest.log" } },
728
+ // The story OPENS the search rather than telling the reader to press a key:
729
+ // a state nobody can see is a state nobody reviews — and this is what puts
730
+ // the find row in front of the blocking axe pass.
731
+ play: openFindAndSearch("shard"),
732
+ };
733
+
734
+ /**
735
+ * The citations for the PDF fixture: one geometric, one quoted.
736
+ *
737
+ * The `rect` is the interesting one — its box is in page FRACTIONS, so it is
738
+ * correct at every zoom stop and after any resize, and it needed no text
739
+ * extraction at all. That is the address an OCR or layout-aware chunker
740
+ * produces, and the PDF adapter is the only one that can honour it.
741
+ */
742
+ const PDF_CITATIONS = [
743
+ {
744
+ id: "p1",
745
+ label: "The report's own title",
746
+ address: {
747
+ kind: "rect",
748
+ page: 1,
749
+ rects: [{ x: 0.11, y: 0.084, width: 0.53, height: 0.034 }],
750
+ },
751
+ },
752
+ {
753
+ id: "p2",
754
+ label: "Where the appendix starts",
755
+ address: { kind: "quote", text: "Appendix - page two" },
756
+ },
757
+ ] as const;
758
+
759
+ /**
760
+ * A PDF page is pixels, so a citation is a BOX over the raster rather than a
761
+ * `<mark>` — same state, same stepper, different paint. Stepping to the second
762
+ * passage turns to page 2 on its own: the quote was located in the text
763
+ * projection, and the document's index says which page that stretch came from.
764
+ *
765
+ * The current box is not distinguished by colour alone — it is also drawn twice
766
+ * as thick (WCAG 1.4.1).
767
+ */
768
+ export const PdfCitations: Story = {
769
+ name: "PDF citations",
770
+ render: () => (
771
+ <FileViewerProvider
772
+ source={{
773
+ kind: "url",
774
+ url: SAMPLE_PDF_DATA_URI,
775
+ name: "quarterly-report.pdf",
776
+ mediaType: "application/pdf",
777
+ }}
778
+ defaultHighlights={[...PDF_CITATIONS]}
779
+ defaultActiveHighlightId="p1"
780
+ >
781
+ <CitationStepper />
782
+ <FileViewerFrame className="h-[520px]">
783
+ <FileViewerToolbar />
784
+ <FileViewerContent />
785
+ </FileViewerFrame>
786
+ </FileViewerProvider>
787
+ ),
788
+ };
789
+
790
+ /**
791
+ * A Word document marks the WORDS. The passage is underlined where it falls —
792
+ * mid-paragraph, through a bold run, inside one bullet, in a single table cell —
793
+ * and the document keeps reading as a document around it.
794
+ *
795
+ * Word's own page layout is gone by the time this renders (see `Word` above), so
796
+ * there is no page to draw a box on: the marks are the whole answer here.
797
+ */
798
+ export const WordCitations: Story = {
799
+ name: "Word citations",
800
+ render: () => (
801
+ <FileViewerProvider
802
+ source={{
803
+ kind: "url",
804
+ url: SAMPLE_DOCX_DATA_URI,
805
+ name: "quarterly-review.docx",
806
+ mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
807
+ }}
808
+ defaultHighlights={[
809
+ {
810
+ id: "w1",
811
+ label: "The growth claim",
812
+ address: { kind: "quote", text: "Revenue grew 18%" },
813
+ },
814
+ { id: "w2", label: "How EMEA did", address: { kind: "quote", text: "EMEA beat plan" } },
815
+ { id: "w3", label: "The number behind it", address: { kind: "quote", text: "4.2M" } },
816
+ ]}
817
+ defaultActiveHighlightId="w1"
818
+ >
819
+ <CitationStepper />
820
+ <FileViewerFrame className="h-[420px]">
821
+ <FileViewerToolbar />
822
+ <FileViewerContent />
823
+ </FileViewerFrame>
824
+ </FileViewerProvider>
825
+ ),
826
+ };
827
+
828
+ /**
829
+ * Markdown is addressed by its SOURCE, which is why its citations are plated
830
+ * blocks rather than `<mark>`s: source offset 212 can land inside `**bold**`,
831
+ * two of whose characters are never drawn. So a passage lights up the paragraph,
832
+ * heading, list item or fence it lives in — enough to point a reader at it, and
833
+ * incapable of being subtly wrong about which words it covers.
834
+ *
835
+ * The current plate doubles its rail as well as warming its fill, and carries
836
+ * `aria-current="true"` (WCAG 1.4.1).
837
+ */
838
+ export const MarkdownCitations: Story = {
839
+ name: "Markdown citations",
840
+ render: () => (
841
+ <FileViewerProvider
842
+ source={{ kind: "text", text: MARKDOWN, name: "nightly-ingest.md" }}
843
+ defaultHighlights={[
844
+ { id: "m1", label: "When it runs", address: { kind: "quote", text: "06:00 UTC" } },
845
+ {
846
+ id: "m2",
847
+ label: "What happens to a failed shard",
848
+ address: { kind: "quote", text: "retried once, then reported" },
849
+ },
850
+ { id: "m3", label: "How to run it", address: { kind: "quote", text: "pnpm ingest" } },
851
+ ]}
852
+ defaultActiveHighlightId="m2"
853
+ >
854
+ <CitationStepper />
855
+ <FileViewerFrame className="h-[420px]">
856
+ <FileViewerToolbar />
857
+ <FileViewerContent />
858
+ </FileViewerFrame>
859
+ </FileViewerProvider>
860
+ ),
861
+ };
862
+
863
+ /**
864
+ * A spreadsheet marks the CELL. Rows are what the projection is indexed by, but
865
+ * the mark is still character-granular, so a citation lands inside one cell and
866
+ * leaves the rest of the row alone.
867
+ *
868
+ * Stepping to the third citation switches TAB as well as scrolling: the passage
869
+ * lives on Headcount, and a mark on a sheet nobody is looking at is the same as
870
+ * no mark at all.
871
+ */
872
+ export const SpreadsheetCitations: Story = {
873
+ name: "Spreadsheet citations",
874
+ render: () => (
875
+ <FileViewerProvider
876
+ source={{
877
+ kind: "url",
878
+ url: SAMPLE_XLSX_DATA_URI,
879
+ name: "quarter.xlsx",
880
+ mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
881
+ }}
882
+ defaultHighlights={[
883
+ { id: "x1", label: "The biggest region", address: { kind: "quote", text: "6100000" } },
884
+ { id: "x2", label: "Where APAC landed", address: { kind: "quote", text: "1800000" } },
885
+ {
886
+ id: "x3",
887
+ label: "The team behind it (other sheet)",
888
+ address: { kind: "quote", text: "Engineering" },
889
+ },
890
+ ]}
891
+ defaultActiveHighlightId="x1"
892
+ >
893
+ <CitationStepper />
894
+ <FileViewerFrame className="h-[420px]">
895
+ <FileViewerToolbar />
896
+ <FileViewerContent />
897
+ </FileViewerFrame>
898
+ </FileViewerProvider>
899
+ ),
900
+ };
901
+
902
+ /**
903
+ * A deck is read as an outline, so a citation marks the line it came from — a
904
+ * bullet, a title, a table row, or the speaker notes. Stepping to a passage on
905
+ * another slide PAGES there first, the same way the PDF turns to the cited page.
906
+ */
907
+ export const DeckCitations: Story = {
908
+ name: "Deck citations",
909
+ render: () => (
910
+ <FileViewerProvider
911
+ source={{
912
+ kind: "url",
913
+ url: SAMPLE_PPTX_DATA_URI,
914
+ name: "quarter.pptx",
915
+ mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
916
+ }}
917
+ defaultHighlights={[
918
+ { id: "d1", label: "The headline", address: { kind: "quote", text: "Revenue grew 18%" } },
919
+ {
920
+ id: "d2",
921
+ label: "What the presenter says",
922
+ address: { kind: "quote", text: "hand over to Sam" },
923
+ },
924
+ {
925
+ id: "d3",
926
+ label: "The number (next slide)",
927
+ address: { kind: "quote", text: "4.2M" },
928
+ },
929
+ ]}
930
+ defaultActiveHighlightId="d1"
931
+ >
932
+ <CitationStepper />
933
+ <FileViewerFrame className="h-[520px]">
934
+ <FileViewerToolbar />
935
+ <FileViewerContent />
936
+ </FileViewerFrame>
937
+ </FileViewerProvider>
938
+ ),
939
+ };
940
+
941
+ /** The same search over a source file — marks survive syntax colouring. */
942
+ export const FindInSourceCode: Story = {
943
+ args: { source: { kind: "text", text: SOURCE, name: "use-file-viewer.ts" } },
944
+ // A word that appears several times AND inside a coloured token, so the story
945
+ // shows marks surviving syntax colouring rather than only sitting beside it.
946
+ play: openFindAndSearch("provider"),
947
+ };