@gmickel/gno 1.39.2 → 1.41.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 (62) hide show
  1. package/README.md +1 -0
  2. package/assets/skill/SKILL.md +17 -0
  3. package/assets/skill/cli-reference.md +48 -0
  4. package/assets/skill/mcp-reference.md +24 -0
  5. package/assets/spa-production.json.gz +0 -0
  6. package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.41.0.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v1.41.0.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/package.json +1 -1
  10. package/spec/cli.md +146 -7
  11. package/spec/db/schema.sql +17 -0
  12. package/spec/mcp.md +194 -0
  13. package/spec/output-schemas/memory-recall.schema.json +159 -0
  14. package/spec/output-schemas/memory-remember.schema.json +164 -0
  15. package/spec/output-schemas/status.schema.json +269 -54
  16. package/src/cli/commands/memory.ts +491 -0
  17. package/src/cli/commands/status.ts +23 -4
  18. package/src/cli/options.ts +4 -0
  19. package/src/cli/program.ts +127 -0
  20. package/src/config/types.ts +7 -0
  21. package/src/core/audit-provenance.ts +91 -0
  22. package/src/core/audit-workspace.ts +17 -0
  23. package/src/core/memory-diagnostics.ts +144 -0
  24. package/src/core/memory-fence.ts +239 -0
  25. package/src/core/memory-recall.ts +269 -0
  26. package/src/core/memory-record.ts +435 -0
  27. package/src/core/memory-remember.ts +425 -0
  28. package/src/core/memory-types.ts +211 -0
  29. package/src/core/memory.ts +87 -0
  30. package/src/core/network-boundary-inventory.ts +10 -0
  31. package/src/ingestion/sync.ts +17 -0
  32. package/src/mcp/http-egress.ts +2 -0
  33. package/src/mcp/tools/index.ts +43 -0
  34. package/src/mcp/tools/memory-recall.ts +122 -0
  35. package/src/mcp/tools/memory-remember.ts +177 -0
  36. package/src/mcp/tools/memory-shared.ts +80 -0
  37. package/src/pipeline/search.ts +2 -0
  38. package/src/pipeline/types.ts +8 -0
  39. package/src/sdk/client.ts +94 -1
  40. package/src/sdk/index.ts +13 -0
  41. package/src/sdk/types.ts +28 -0
  42. package/src/serve/CLAUDE.md +22 -2
  43. package/src/serve/clipper-security.ts +3 -17
  44. package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
  45. package/src/serve/public/globals.built.css +1 -1
  46. package/src/serve/public/globals.css +4 -0
  47. package/src/serve/public/hooks/use-pdf-document.ts +101 -37
  48. package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
  49. package/src/serve/public/lib/pdf-transport.ts +13 -0
  50. package/src/serve/public/lib/pdf.ts +77 -10
  51. package/src/serve/public/lib/server-capabilities.ts +19 -0
  52. package/src/serve/public/pages/Ask.tsx +8 -9
  53. package/src/serve/public/pages/DocView.tsx +80 -18
  54. package/src/serve/public/pages/Search.tsx +8 -9
  55. package/src/serve/request-locality.ts +84 -0
  56. package/src/serve/routes/api.ts +255 -3
  57. package/src/serve/server.ts +189 -29
  58. package/src/store/migrations/027-memory-scopes.ts +37 -0
  59. package/src/store/migrations/index.ts +2 -0
  60. package/src/store/sqlite/adapter.ts +127 -3
  61. package/src/store/types.ts +54 -0
  62. package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
package/src/sdk/client.ts CHANGED
@@ -38,7 +38,11 @@ import type {
38
38
  GnoMoveNoteOptions,
39
39
  GnoMultiGetOptions,
40
40
  GnoQueryOptions,
41
+ GnoRecallInput,
42
+ GnoRecallResult,
41
43
  GnoRefactorNoteResult,
44
+ GnoRememberInput,
45
+ GnoRememberResult,
42
46
  GnoRenameNoteApplyOptions,
43
47
  GnoRenameNoteOptions,
44
48
  GnoSearchOptions,
@@ -113,6 +117,11 @@ import {
113
117
  listKnowledgeChanges,
114
118
  type KnowledgeDeltaServiceResult,
115
119
  } from "../core/knowledge-delta";
120
+ import {
121
+ MemoryError,
122
+ type MemoryErrorCode,
123
+ MemoryService,
124
+ } from "../core/memory";
116
125
  import { resolveNoteCreatePlan } from "../core/note-creation";
117
126
  import { resolveNotePreset } from "../core/note-presets";
118
127
  import {
@@ -144,6 +153,7 @@ import {
144
153
  } from "../core/sections";
145
154
  import { normalizeStructuredQueryInput } from "../core/structured-query";
146
155
  import { parseAndValidateTagFilter } from "../core/tags";
156
+ import { writeLeasePath } from "../core/write-lease";
147
157
  import {
148
158
  defaultSyncService,
149
159
  type SyncResult,
@@ -171,7 +181,7 @@ import {
171
181
  multiGetDocuments,
172
182
  } from "./documents";
173
183
  import { runEmbed } from "./embed";
174
- import { sdkError } from "./errors";
184
+ import { type GnoSdkErrorCode, sdkError } from "./errors";
175
185
 
176
186
  interface OpenedClientState {
177
187
  config: Config;
@@ -295,6 +305,41 @@ async function resolveClientState(
295
305
  };
296
306
  }
297
307
 
308
+ /** SDK error family per memory code; exhaustive so a new code fails to compile. */
309
+ const MEMORY_ERROR_TO_SDK: Readonly<Record<MemoryErrorCode, GnoSdkErrorCode>> =
310
+ {
311
+ MEMORY_TEXT_REQUIRED: "VALIDATION",
312
+ MEMORY_TEXT_TOO_LARGE: "VALIDATION",
313
+ MEMORY_QUERY_REQUIRED: "VALIDATION",
314
+ MEMORY_BUDGET_INVALID: "VALIDATION",
315
+ MEMORY_COLLECTION_REQUIRED: "VALIDATION",
316
+ MEMORY_COLLECTION_NOT_FOUND: "NOT_FOUND",
317
+ MEMORY_COLLECTION_UNMANAGED: "VALIDATION",
318
+ MEMORY_SCOPES_REQUIRED: "VALIDATION",
319
+ MEMORY_SCOPES_INVALID: "VALIDATION",
320
+ MEMORY_IDENTITY_REQUIRED: "VALIDATION",
321
+ MEMORY_DECISION_INVALID: "VALIDATION",
322
+ MEMORY_PREDECESSOR_REQUIRED: "VALIDATION",
323
+ MEMORY_PREDECESSOR_NOT_FOUND: "NOT_FOUND",
324
+ MEMORY_PREDECESSOR_HASH_MISMATCH: "RUNTIME",
325
+ MEMORY_SUPERSEDE_CONFLICT: "RUNTIME",
326
+ MEMORY_SUPERSEDE_PROJECTION_FAILED: "RUNTIME",
327
+ MEMORY_FENCED_REPLAY: "VALIDATION",
328
+ MEMORY_FENCED_DERIVED: "VALIDATION",
329
+ MEMORY_WRITE_LEASE_BUSY: "RUNTIME",
330
+ MEMORY_SYNC_FAILED: "RUNTIME",
331
+ MEMORY_QUERY_FAILED: "RUNTIME",
332
+ };
333
+
334
+ /** Map a core MemoryError onto the SDK error family; the memory code survives in `details.code`. */
335
+ function toMemorySdkError(cause: unknown): unknown {
336
+ if (!(cause instanceof MemoryError)) return cause;
337
+ return sdkError(MEMORY_ERROR_TO_SDK[cause.code], cause.message, {
338
+ cause,
339
+ details: { code: cause.code },
340
+ });
341
+ }
342
+
298
343
  class GnoClientImpl implements GnoClient {
299
344
  config: Config;
300
345
  readonly dbPath: string;
@@ -1581,6 +1626,54 @@ class GnoClientImpl implements GnoClient {
1581
1626
  };
1582
1627
  }
1583
1628
 
1629
+ /**
1630
+ * The memory service owns the shared write lease; the SDK never takes it.
1631
+ * Embedding is best-effort: without a local model the service reports
1632
+ * lexical-only matching/retrieval instead of failing.
1633
+ */
1634
+ private async withMemoryService<T>(
1635
+ collection: string | undefined,
1636
+ run: (service: MemoryService) => Promise<T>
1637
+ ): Promise<T> {
1638
+ this.assertOpen();
1639
+ const ports = await this.createRuntimePorts({
1640
+ embed: true,
1641
+ collection: this.config.collections.some(
1642
+ (candidate) => candidate.name === collection
1643
+ )
1644
+ ? collection
1645
+ : undefined,
1646
+ });
1647
+ try {
1648
+ return await run(
1649
+ new MemoryService({
1650
+ store: this.store,
1651
+ config: this.config,
1652
+ collections: this.config.collections,
1653
+ lockPath: writeLeasePath(this.dbPath),
1654
+ embedPort: ports.embedPort,
1655
+ vectorIndex: ports.vectorIndex,
1656
+ })
1657
+ );
1658
+ } catch (cause) {
1659
+ throw toMemorySdkError(cause);
1660
+ } finally {
1661
+ await this.disposeRuntimePorts(ports);
1662
+ }
1663
+ }
1664
+
1665
+ async remember(input: GnoRememberInput): Promise<GnoRememberResult> {
1666
+ return this.withMemoryService(input?.collection, (service) =>
1667
+ service.remember(input)
1668
+ );
1669
+ }
1670
+
1671
+ async recall(input: GnoRecallInput): Promise<GnoRecallResult> {
1672
+ return this.withMemoryService(input?.collection, (service) =>
1673
+ service.recall(input)
1674
+ );
1675
+ }
1676
+
1584
1677
  async capture(options: GnoCaptureOptions): Promise<GnoCaptureResult> {
1585
1678
  this.assertOpen();
1586
1679
  const collection = this.getCollections(options.collection)[0];
package/src/sdk/index.ts CHANGED
@@ -58,6 +58,10 @@ export type {
58
58
  GnoMultiGetOptions,
59
59
  GnoMultiGetResult,
60
60
  GnoQueryOptions,
61
+ GnoRecallInput,
62
+ GnoRecallResult,
63
+ GnoRememberInput,
64
+ GnoRememberResult,
61
65
  GnoRenameNoteApplyOptions,
62
66
  GnoRenameNoteOptions,
63
67
  GnoProjectHintOptions,
@@ -76,7 +80,16 @@ export type {
76
80
  SectionTargetCreateSelector,
77
81
  SectionTargetResolveResult,
78
82
  SectionTargetV1,
83
+ MemoryCandidate,
84
+ MemoryFact,
85
+ MemoryRecallReceipt,
86
+ RecalledFact,
79
87
  } from "./types";
88
+ export {
89
+ MemoryError,
90
+ type MemoryDecision,
91
+ type MemoryErrorCode,
92
+ } from "../core/memory";
80
93
  export {
81
94
  ContextCapsuleContractError,
82
95
  type ContextCapsuleErrorCode,
package/src/sdk/types.ts CHANGED
@@ -43,6 +43,16 @@ import type {
43
43
  KnowledgeImpactResult,
44
44
  ListKnowledgeChangesInput,
45
45
  } from "../core/knowledge-delta";
46
+ import type {
47
+ MemoryCandidate,
48
+ MemoryFact,
49
+ MemoryRecallReceipt,
50
+ RecalledFact,
51
+ RecallInput,
52
+ RecallResult,
53
+ RememberInput,
54
+ RememberResult,
55
+ } from "../core/memory";
46
56
  import type { NoteCollisionPolicy } from "../core/note-creation";
47
57
  import type { NotePresetId } from "../core/note-presets";
48
58
  import type {
@@ -245,6 +255,13 @@ export interface GnoCaptureOptions extends Omit<CaptureInput, "overwrite"> {}
245
255
 
246
256
  export type GnoCaptureResult = CaptureReceipt;
247
257
 
258
+ /** Shared memory contract (identical on CLI, MCP, REST, and SDK). */
259
+ export type GnoRememberInput = RememberInput;
260
+ export type GnoRememberResult = RememberResult;
261
+ export type GnoRecallInput = RecallInput;
262
+ export type GnoRecallResult = RecallResult;
263
+ export type { MemoryCandidate, MemoryFact, MemoryRecallReceipt, RecalledFact };
264
+
248
265
  export interface GnoCreateFolderOptions {
249
266
  collection: string;
250
267
  name: string;
@@ -370,6 +387,17 @@ export interface GnoClient {
370
387
  embed(options?: GnoEmbedOptions): Promise<GnoEmbedResult>;
371
388
  index(options?: GnoIndexOptions): Promise<GnoIndexResult>;
372
389
  capture(options: GnoCaptureOptions): Promise<GnoCaptureResult>;
390
+ /**
391
+ * Store one fact in a memory-managed collection, or propose candidates when
392
+ * `decision` is omitted. Requires caller + session identity and explicit
393
+ * scopes. Errors carry the stable memory code in `details.code`.
394
+ */
395
+ remember(input: GnoRememberInput): Promise<GnoRememberResult>;
396
+ /**
397
+ * Budgeted, cited recall of current facts in the caller's explicit scopes.
398
+ * The result carries a content-free fencing receipt.
399
+ */
400
+ recall(input: GnoRecallInput): Promise<GnoRecallResult>;
373
401
  createNote(options: GnoCreateNoteOptions): Promise<GnoCreateNoteResult>;
374
402
  createFolder(options: GnoCreateFolderOptions): Promise<GnoCreateFolderResult>;
375
403
  previewRenameNote(
@@ -71,12 +71,13 @@ Answer generation uses shared module to stay in sync with CLI:
71
71
  | --------------------- | ------ | ------------------------------------------ |
72
72
  | `/api/health` | GET | Health check |
73
73
  | `/api/status` | GET | Index stats, onboarding, health, bootstrap |
74
- | `/api/capabilities` | GET | Available features |
74
+ | `/api/capabilities` | GET | Available features + `localClient` |
75
75
  | `/api/collections` | GET | List collections |
76
76
  | `/api/publish/export` | POST | Export gno.sh publish artifact JSON |
77
77
  | `/api/docs` | GET | List documents |
78
78
  | `/api/doc` | GET | Get document content |
79
- | `/api/doc-asset` | GET | Original source file bytes (Range, HEAD) |
79
+ | `/api/doc-asset` | GET | Source file bytes (Range, HEAD, ETag/304) |
80
+ | `/api/docs/:id/reveal`| POST | Reveal source on host (local client only) |
80
81
  | `/vendor/pdfjs/*` | GET | Same-origin pdfjs worker/cmaps/fonts |
81
82
  | `/api/search` | POST | BM25 search |
82
83
  | `/api/query` | POST | Hybrid search |
@@ -94,6 +95,17 @@ Answer generation uses shared module to stay in sync with CLI:
94
95
  - **AI Elements**: Conversation, Message, Sources, CodeBlock, Loader
95
96
  - **Tag Components**: TagInput, TagFacets (filter sidebar)
96
97
  - **Routing**: Simple hash-free SPA routing in App.tsx
98
+ - **Server capabilities**: `public/lib/server-capabilities.ts` is the one
99
+ shared `ServerCapabilities` type (`localClient`) plus
100
+ `fetchServerCapabilities()`; a failed read counts as remote
101
+ - **PDF viewer transport**: `public/lib/pdf-transport.ts` holds the tier
102
+ constants (`PDF_WHOLE_FILE_MAX_BYTES` = 8 MB, `PDF_RANGE_CHUNK_BYTES` =
103
+ 1 MB) with no pdfjs import so Bun-side tooling can read them;
104
+ `hooks/use-pdf-document.ts` sends one same-origin `HEAD` per load to pick
105
+ the tier and falls back to ranged on any probe failure;
106
+ `hooks/use-pdf-pages.ts` publishes page slots from page 1 geometry and
107
+ corrects sizes and fit scales in one commit when the full pass lands
108
+ (fatal error = page 1 or document; later pages carry `slot.error`)
97
109
 
98
110
  ## Development
99
111
 
@@ -110,6 +122,14 @@ gno serve --port 3000
110
122
  - Binds to `127.0.0.1` only (no LAN exposure)
111
123
  - CSP headers on all responses
112
124
  - CORS protection on POST endpoints
125
+ - Request locality (`request-locality.ts`): `localClient` is true only for a
126
+ loopback peer, a loopback `Host`, and no forwarding headers; the reveal
127
+ route refuses non-local clients with 403
128
+ - HTTP caching: `/api/doc-asset` sends a strong `ETag` (size + mtime) with
129
+ `private, max-age=0, must-revalidate` and answers `If-None-Match` with 304
130
+ before Range handling; hashed SPA chunks (`/chunk-*.js|css`) get a one-year
131
+ `immutable` policy and a gzip body via `createPublicFetchFallback` in
132
+ `server.ts` (production only; the entry HTML and dev/HMR are untouched)
113
133
  - No external font/script loading — the PDF.js worker, cMaps, and standard fonts
114
134
  are served same-origin from the installed `pdfjs-dist` package via
115
135
  `/vendor/pdfjs/*`, so the CSP keeps `worker-src 'self'` and `font-src 'self'`
@@ -4,6 +4,7 @@ import type { HttpMcpPeerServer } from "../mcp/http-security";
4
4
 
5
5
  import { readClipperBoundedJson } from "./clipper-body";
6
6
  import { clipperSecurityErrorResponse } from "./clipper-security-errors";
7
+ import { isLoopbackAddress } from "./request-locality";
7
8
  import { ReaderGate } from "./resident-admission";
8
9
 
9
10
  export { readClipperBoundedJson } from "./clipper-body";
@@ -126,7 +127,7 @@ function validateSameOriginEntry(origin: string): boolean {
126
127
 
127
128
  function isLoopbackHostname(hostname: string): boolean {
128
129
  if (hostname === "localhost") return true;
129
- return isClipperLoopbackAddress(hostname.replace(/^\[|\]$/g, ""));
130
+ return isLoopbackAddress(hostname.replace(/^\[|\]$/g, ""));
130
131
  }
131
132
 
132
133
  function normalizedHeaderNames(headers: readonly string[]): string[] {
@@ -144,21 +145,6 @@ function normalizedHeaderNames(headers: readonly string[]): string[] {
144
145
  return [...new Set(normalized)];
145
146
  }
146
147
 
147
- export function isClipperLoopbackAddress(address: string): boolean {
148
- const normalized = address.toLowerCase();
149
- if (normalized === "::1") return true;
150
- const mapped = normalized.startsWith("::ffff:")
151
- ? normalized.slice("::ffff:".length)
152
- : normalized;
153
- const octets = mapped.split(".");
154
- return (
155
- octets.length === 4 &&
156
- octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
157
- Number(octets[0]) === 127 &&
158
- octets.every((octet) => Number(octet) <= 255)
159
- );
160
- }
161
-
162
148
  export function parseClipperExtensionOrigin(
163
149
  origin: string
164
150
  ): { extensionId: string; origin: string } | null {
@@ -370,7 +356,7 @@ export class ClipperSecurityBoundary {
370
356
  }
371
357
  | ClipperSecurityFailure {
372
358
  const peer = server.requestIP(request);
373
- if (!peer || !isClipperLoopbackAddress(peer.address)) {
359
+ if (!peer || !isLoopbackAddress(peer.address)) {
374
360
  return {
375
361
  ok: false,
376
362
  response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
@@ -99,6 +99,69 @@ function StatePanel({
99
99
  );
100
100
  }
101
101
 
102
+ const ERROR_EYEBROW: Record<PdfFallbackReason, string> = {
103
+ corrupt: "CANNOT RENDER",
104
+ password: "PASSWORD PROTECTED",
105
+ network: "COULD NOT LOAD",
106
+ bootstrap: "VIEWER UNAVAILABLE",
107
+ };
108
+
109
+ const PAGE_ERROR_BODY: Record<PdfFallbackReason, string> = {
110
+ corrupt: "This page could not be rendered.",
111
+ password: "This page is password protected.",
112
+ network: "This page could not be loaded from this session.",
113
+ bootstrap: "The PDF viewer could not render this page.",
114
+ };
115
+
116
+ type PdfPageErrorSlotProps = {
117
+ pageNumber: number;
118
+ width: number;
119
+ height: number;
120
+ error: PdfFallbackReason;
121
+ onMount: (pageNumber: number, el: HTMLElement | null) => void;
122
+ };
123
+
124
+ /**
125
+ * A later page whose geometry failed (R2): the page error state rendered in
126
+ * place of the page, at the slot's placeholder size so the scroll model and
127
+ * the visibility window keep their shape. Never fires the text fallback.
128
+ */
129
+ function PdfPageErrorSlot({
130
+ pageNumber,
131
+ width,
132
+ height,
133
+ error,
134
+ onMount,
135
+ }: PdfPageErrorSlotProps) {
136
+ const rootRef = useRef<HTMLDivElement | null>(null);
137
+
138
+ useEffect(() => {
139
+ onMount(pageNumber, rootRef.current);
140
+ return () => {
141
+ onMount(pageNumber, null);
142
+ };
143
+ }, [pageNumber, onMount]);
144
+
145
+ return (
146
+ <div
147
+ className="gno-pdf-page flex flex-col items-center justify-center gap-2 px-4 text-center"
148
+ data-page-error={error}
149
+ data-page-number={pageNumber}
150
+ data-testid={`pdf-page-${pageNumber}`}
151
+ ref={rootRef}
152
+ role="status"
153
+ style={{ width: width || undefined, height: height || undefined }}
154
+ >
155
+ <p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
156
+ {ERROR_EYEBROW[error]}
157
+ </p>
158
+ <p className="text-[13px] text-foreground/90 leading-relaxed">
159
+ Page {pageNumber}: {PAGE_ERROR_BODY[error]}
160
+ </p>
161
+ </div>
162
+ );
163
+ }
164
+
102
165
  function prefersReducedMotion(): boolean {
103
166
  if (
104
167
  typeof window === "undefined" ||
@@ -184,7 +247,11 @@ export function PdfViewer({
184
247
  containerWidth,
185
248
  containerHeight,
186
249
  genId,
250
+ scrollContainerRef: columnRef,
187
251
  });
252
+ // Fatal only: a document load failure or the hook's fatal error (page 1
253
+ // geometry, render-path acquisition). A later page's geometry failure rides
254
+ // on its slot and never reaches this path.
188
255
  const viewerError = status === "error" ? error : pages.error;
189
256
 
190
257
  // Fallback: exactly once per failed load/page attempt when extracted text exists.
@@ -400,7 +467,7 @@ export function PdfViewer({
400
467
  <StatePanel
401
468
  body="This PDF could not be rendered. Download the original to read it."
402
469
  downloadUrl={downloadUrl}
403
- eyebrow="CANNOT RENDER"
470
+ eyebrow={ERROR_EYEBROW.corrupt}
404
471
  onRetry={handleRetry}
405
472
  role="alert"
406
473
  testId="pdf-state-corrupt"
@@ -411,7 +478,7 @@ export function PdfViewer({
411
478
  <StatePanel
412
479
  body="This PDF is password protected. Download the original to open it in a PDF reader."
413
480
  downloadUrl={downloadUrl}
414
- eyebrow="PASSWORD PROTECTED"
481
+ eyebrow={ERROR_EYEBROW.password}
415
482
  role="alert"
416
483
  testId="pdf-state-password"
417
484
  />
@@ -421,7 +488,7 @@ export function PdfViewer({
421
488
  <StatePanel
422
489
  body="The document could not be loaded from this session. Try again, or download the original."
423
490
  downloadUrl={downloadUrl}
424
- eyebrow="COULD NOT LOAD"
491
+ eyebrow={ERROR_EYEBROW.network}
425
492
  onRetry={handleRetry}
426
493
  role="alert"
427
494
  testId="pdf-state-network"
@@ -432,7 +499,7 @@ export function PdfViewer({
432
499
  <StatePanel
433
500
  body="The PDF viewer could not start in this window. Download the original to read it."
434
501
  downloadUrl={downloadUrl}
435
- eyebrow="VIEWER UNAVAILABLE"
502
+ eyebrow={ERROR_EYEBROW.bootstrap}
436
503
  onRetry={handleRetry}
437
504
  role="alert"
438
505
  testId="pdf-state-bootstrap"
@@ -512,21 +579,32 @@ export function PdfViewer({
512
579
 
513
580
  {showProgressive ? (
514
581
  <div className="mx-auto flex w-fit flex-col items-center gap-6">
515
- {pages.slots.map((slot) => (
516
- <PdfPageView
517
- key={slot.pageNumber}
518
- active={slot.active}
519
- doc={doc}
520
- height={slot.height}
521
- onMount={pages.observePage}
522
- onInternalNavigate={goToPage}
523
- onRender={pages.ensureRendered}
524
- pageNumber={slot.pageNumber}
525
- rendered={slot.rendered}
526
- scale={pages.scale}
527
- width={slot.width}
528
- />
529
- ))}
582
+ {pages.slots.map((slot) =>
583
+ slot.error ? (
584
+ <PdfPageErrorSlot
585
+ key={slot.pageNumber}
586
+ error={slot.error}
587
+ height={slot.height}
588
+ onMount={pages.observePage}
589
+ pageNumber={slot.pageNumber}
590
+ width={slot.width}
591
+ />
592
+ ) : (
593
+ <PdfPageView
594
+ key={slot.pageNumber}
595
+ active={slot.active}
596
+ doc={doc}
597
+ height={slot.height}
598
+ onMount={pages.observePage}
599
+ onInternalNavigate={goToPage}
600
+ onRender={pages.ensureRendered}
601
+ pageNumber={slot.pageNumber}
602
+ rendered={slot.rendered}
603
+ scale={pages.scale}
604
+ width={slot.width}
605
+ />
606
+ )
607
+ )}
530
608
  </div>
531
609
  ) : null}
532
610
  </div>