@drawnagency/primitives 0.1.80 → 0.1.82

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 (102) hide show
  1. package/dist/{chunk-7XWER6YR.js → chunk-22GU6GNP.js} +3 -3
  2. package/dist/{chunk-4TVODHD2.js → chunk-5UDIQLGQ.js} +6 -6
  3. package/dist/{chunk-UNUJPLZV.js → chunk-6NRVX6NQ.js} +1 -1
  4. package/dist/{chunk-KVQFMVXE.js → chunk-DKXISNJ2.js} +2 -2
  5. package/dist/{chunk-RSITF24B.js → chunk-ETJYYUQX.js} +9 -3
  6. package/dist/{chunk-VI26O4E3.js → chunk-FDBZZ4AT.js} +1 -1
  7. package/dist/chunk-FM4M4Q2W.js +72 -0
  8. package/dist/{chunk-EMGLZQCM.js → chunk-MBJUWOM2.js} +1 -1
  9. package/dist/{chunk-2DDWWTRX.js → chunk-Q6LZOH5M.js} +1 -1
  10. package/dist/{chunk-QB5Q23G2.js → chunk-Y54G2XSC.js} +1 -1
  11. package/dist/{chunk-3LNKOLZP.js → chunk-YKOLOZB2.js} +1 -1
  12. package/dist/components/editor/PagesModal.d.ts +2 -0
  13. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  14. package/dist/components/editor/index.js +6 -5
  15. package/dist/components/primitives/index.js +7 -6
  16. package/dist/components/sections/brand-guide.js +7 -6
  17. package/dist/components/sections/register-schemas.js +10 -9
  18. package/dist/components/sections/register.js +10 -9
  19. package/dist/components/shell/DocInspector.d.ts +18 -0
  20. package/dist/components/shell/DocInspector.d.ts.map +1 -0
  21. package/dist/components/shell/DocumentEditorOverlay.d.ts +29 -0
  22. package/dist/components/shell/DocumentEditorOverlay.d.ts.map +1 -0
  23. package/dist/components/shell/DocumentPresentOverlay.d.ts +30 -0
  24. package/dist/components/shell/DocumentPresentOverlay.d.ts.map +1 -0
  25. package/dist/components/shell/EditorShell.d.ts +34 -0
  26. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  27. package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  28. package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  29. package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  30. package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  31. package/dist/document/components/DocCoverPage.d.ts +11 -0
  32. package/dist/document/components/DocCoverPage.d.ts.map +1 -0
  33. package/dist/document/components/DocPage.d.ts +10 -0
  34. package/dist/document/components/DocPage.d.ts.map +1 -0
  35. package/dist/document/components/DocRail.d.ts +28 -0
  36. package/dist/document/components/DocRail.d.ts.map +1 -0
  37. package/dist/document/components/DocSectionPage.d.ts +12 -0
  38. package/dist/document/components/DocSectionPage.d.ts.map +1 -0
  39. package/dist/document/components/DocViewer.d.ts +18 -0
  40. package/dist/document/components/DocViewer.d.ts.map +1 -0
  41. package/dist/document/components/Wordmark.d.ts +4 -0
  42. package/dist/document/components/Wordmark.d.ts.map +1 -0
  43. package/dist/document/components/measure.d.ts +7 -0
  44. package/dist/document/components/measure.d.ts.map +1 -0
  45. package/dist/document/components/useDocPages.d.ts +12 -0
  46. package/dist/document/components/useDocPages.d.ts.map +1 -0
  47. package/dist/document/engine/blocks.d.ts +2 -0
  48. package/dist/document/engine/blocks.d.ts.map +1 -0
  49. package/dist/document/engine/harvest.d.ts +3 -0
  50. package/dist/document/engine/harvest.d.ts.map +1 -0
  51. package/dist/document/engine/masonry.d.ts +26 -0
  52. package/dist/document/engine/masonry.d.ts.map +1 -0
  53. package/dist/document/engine/paginate.d.ts +43 -0
  54. package/dist/document/engine/paginate.d.ts.map +1 -0
  55. package/dist/document/engine/settings.d.ts +88 -0
  56. package/dist/document/engine/settings.d.ts.map +1 -0
  57. package/dist/document/engine/types.d.ts +44 -0
  58. package/dist/document/engine/types.d.ts.map +1 -0
  59. package/dist/document/index.d.ts +13 -0
  60. package/dist/document/index.d.ts.map +1 -0
  61. package/dist/document/index.js +965 -0
  62. package/dist/document/styles/document.css +217 -0
  63. package/dist/index.js +29 -28
  64. package/dist/lib/document-input.d.ts +25 -0
  65. package/dist/lib/document-input.d.ts.map +1 -0
  66. package/dist/lib/index.js +10 -9
  67. package/dist/lib/pages.d.ts +2 -0
  68. package/dist/lib/pages.d.ts.map +1 -1
  69. package/dist/media/index.js +4 -3
  70. package/dist/schemas/index.js +3 -2
  71. package/dist/schemas/site-config.d.ts +265 -1
  72. package/dist/schemas/site-config.d.ts.map +1 -1
  73. package/package.json +5 -1
  74. package/src/components/editor/PagesModal.tsx +16 -2
  75. package/src/components/shell/DocInspector.tsx +323 -0
  76. package/src/components/shell/DocumentEditorOverlay.tsx +169 -0
  77. package/src/components/shell/DocumentPresentOverlay.tsx +87 -0
  78. package/src/components/shell/EditorShell.tsx +128 -5
  79. package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  80. package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  81. package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  82. package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  83. package/src/document/components/DocCoverPage.tsx +45 -0
  84. package/src/document/components/DocPage.tsx +40 -0
  85. package/src/document/components/DocRail.tsx +164 -0
  86. package/src/document/components/DocSectionPage.tsx +48 -0
  87. package/src/document/components/DocViewer.tsx +285 -0
  88. package/src/document/components/Wordmark.tsx +18 -0
  89. package/src/document/components/measure.tsx +117 -0
  90. package/src/document/components/useDocPages.ts +85 -0
  91. package/src/document/engine/blocks.ts +87 -0
  92. package/src/document/engine/harvest.ts +95 -0
  93. package/src/document/engine/masonry.ts +152 -0
  94. package/src/document/engine/paginate.ts +229 -0
  95. package/src/document/engine/settings.ts +88 -0
  96. package/src/document/engine/types.ts +64 -0
  97. package/src/document/index.ts +18 -0
  98. package/src/document/styles/document.css +217 -0
  99. package/src/lib/document-input.ts +57 -0
  100. package/src/lib/pages.ts +22 -0
  101. package/src/schemas/site-config.ts +22 -7
  102. /package/dist/{chunk-4YEOTFVU.js → chunk-YBVK254P.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { Fragment, useState, useCallback, useEffect, useRef, useMemo, type ReactNode } from "react";
1
+ import { Fragment, Suspense, lazy, useState, useCallback, useEffect, useRef, useMemo, type ReactNode } from "react";
2
2
 
3
3
  import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
4
4
  import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
@@ -10,6 +10,10 @@ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
10
10
  import type { Audience } from "../../auth/types";
11
11
  import type { MediaManifest } from "../../media/types";
12
12
  import type { QueueItem } from "../../media/queue";
13
+ // Sanctioned outside->inside import: document/ is a sealed subtree, but the
14
+ // Document mode toggle wiring needs the patch shape it edits (mirrors the same
15
+ // sanctioned import in schemas/site-config.ts and lib/pages.ts).
16
+ import type { DocumentOptions } from "../../document/engine/settings";
13
17
  import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
14
18
  import { ensureSanitizer } from "../../lib/sanitize";
15
19
  import { EditorProvider, useEditorContext, type HistoryState } from "./EditorContext";
@@ -34,6 +38,17 @@ import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert
34
38
 
35
39
  ensureSectionsRegistered();
36
40
  import { BugReportFAB } from "./BugReportFAB";
41
+ // Lazy boundary: the document editor/presentation overlays (and the whole
42
+ // document engine they pull in) must not weigh /edit on sites that never use
43
+ // document mode — they load only when a document-enabled page opens the overlay.
44
+ // Named exports → adapt to the default-export shape React.lazy expects (mirrors
45
+ // the ChatPanel lazy import convention in ChatWidget).
46
+ const DocumentEditorOverlay = lazy(() =>
47
+ import("./DocumentEditorOverlay").then((m) => ({ default: m.DocumentEditorOverlay })),
48
+ );
49
+ const DocumentPresentOverlay = lazy(() =>
50
+ import("./DocumentPresentOverlay").then((m) => ({ default: m.DocumentPresentOverlay })),
51
+ );
37
52
  import { SectionWrapper } from "../editor/SectionWrapper";
38
53
  import { SectionOrderingModal } from "../editor/SectionOrderingModal";
39
54
  import { MoveSectionModal } from "../editor/MoveSectionModal";
@@ -72,7 +87,7 @@ import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent
72
87
  import {
73
88
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
74
89
  reorderSectionInPage,
75
- setHomePage, setPageArchived, setPageFields, setPageAudience, deletePage, addPage, reorderPages,
90
+ setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage, reorderPages,
76
91
  moveSection as moveSectionReducer,
77
92
  } from "../../lib/pages";
78
93
  import { PagesContext } from "../shared/PagesContext";
@@ -187,6 +202,7 @@ export default function EditorShell({
187
202
  const [showOrderingModal, setShowOrderingModal] = useState(false);
188
203
  const [showPagesModal, setShowPagesModal] = useState(false);
189
204
  const [showChatbotConfig, setShowChatbotConfig] = useState(false);
205
+ const [showDocumentEditor, setShowDocumentEditor] = useState(false);
190
206
  const [isRestoring, setIsRestoring] = useState(false);
191
207
  const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
192
208
  // Surfaces useMediaPipeline's onUploadError (oversized/disallowed files,
@@ -208,6 +224,13 @@ export default function EditorShell({
208
224
  // takeover confirmation is pending for.
209
225
  const [remoteNotice, setRemoteNotice] = useState<string | null>(null);
210
226
  const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
227
+ // Lifted from EditorContext.historyState by HistoryWatcher (mirrors
228
+ // ViewBranchWatcher's setViewSections lift) — the shell body renders the
229
+ // EditorProvider, so it can't read historyState via useEditorContext directly.
230
+ // Drives the Document FAB gate below: history is presentation only, never
231
+ // editing (branch-preview already routes to DocumentPresentOverlay; this
232
+ // covers the separate historySelectEvent commit view).
233
+ const [historyActive, setHistoryActive] = useState(false);
211
234
 
212
235
  const siteIndexRef = useRef<SiteIndex>({ siteId, pages: [], sections: {} });
213
236
  // Ids of sections that exist on the remote (loaded from server, or persisted by
@@ -285,6 +308,26 @@ export default function EditorShell({
285
308
  return audiences.filter((a) => resolvedActivePage.access.includes(a.name));
286
309
  }, [resolvedActivePage, audiences]);
287
310
 
311
+ // The sections shown on the canvas when previewing the published ("live")
312
+ // branch — the SAME list computeDisplaySections renders for viewBranch "live".
313
+ // Only meaningful while viewSections !== null (branch view); the read-only
314
+ // document presentation overlay renders exactly these so it mirrors the canvas.
315
+ // (viewSections is set only for the live branch, so its non-null-ness IS the
316
+ // "branch view" signal at this level.)
317
+ const branchDisplaySections = useMemo(
318
+ () =>
319
+ viewSections && resolvedActivePage
320
+ ? computeDisplaySections({
321
+ isEditMode: false,
322
+ viewBranch: "live",
323
+ sections,
324
+ viewSections,
325
+ activePageSectionIds: resolvedActivePage.order,
326
+ })
327
+ : [],
328
+ [viewSections, resolvedActivePage, sections],
329
+ );
330
+
288
331
  const getPageHeadings = useCallback(
289
332
  (pageId: string) => {
290
333
  const page = pageById(siteIndex, pageId);
@@ -1218,6 +1261,11 @@ export default function EditorShell({
1218
1261
  persistence.markIndexDirty(); setLocalChangesExist(true);
1219
1262
  }, [persistence]);
1220
1263
 
1264
+ const handleSetPageDocument = useCallback((id: string, patch: Partial<DocumentOptions>) => {
1265
+ setSiteIndex((prev) => setPageDocument(prev, id, patch));
1266
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1267
+ }, [persistence]);
1268
+
1221
1269
  const handleSetPageAudience = useCallback((id: string, access: string[]) => {
1222
1270
  const { index: next, resetSectionIds } = setPageAudience(siteIndexRef.current, id, access);
1223
1271
  setSiteIndex(next);
@@ -1363,7 +1411,7 @@ export default function EditorShell({
1363
1411
  <ViewBranchWatcher
1364
1412
  setViewSections={setViewSections}
1365
1413
  />
1366
- <HistoryWatcher />
1414
+ <HistoryWatcher onHistoryActiveChange={setHistoryActive} />
1367
1415
  <EditorModalProvider>
1368
1416
  <MediaLibraryContext.Provider value={{
1369
1417
  ...mediaPipeline.contextValue,
@@ -1408,6 +1456,59 @@ export default function EditorShell({
1408
1456
  the button would always 401 — hide it there. */}
1409
1457
  {!capabilities.passwordOnly && <BugReportFAB />}
1410
1458
 
1459
+ {/* Document editor: opens the full-screen editing surface for the
1460
+ active page when its document mode is enabled. Mirrors the viewer
1461
+ document FAB's icon; sits above BugReportFAB (bottom-40 clears the
1462
+ bottom-28 bug button + gap) so the two never collide.
1463
+ !historyActive: history is presentation only, never editing — while
1464
+ viewing a specific historical commit (historySelectEvent →
1465
+ EditorContext.historyState, lifted here by HistoryWatcher since this
1466
+ scope renders the provider and can't read the context directly),
1467
+ hide the FAB so it can't open the draft editor over a history view. */}
1468
+ {resolvedActivePage?.document?.enabled && !historyActive && (
1469
+ <button
1470
+ type="button"
1471
+ onClick={() => setShowDocumentEditor(true)}
1472
+ aria-label="Edit as document"
1473
+ className="cursor-pointer fixed bottom-40 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1474
+ >
1475
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
1476
+ <rect x="3" y="4" width="18" height="14" rx="2" />
1477
+ <path d="M8 21h8" />
1478
+ </svg>
1479
+ </button>
1480
+ )}
1481
+ {showDocumentEditor && resolvedActivePage?.document?.enabled && (
1482
+ <Suspense fallback={<DocumentOverlayFallback />}>
1483
+ {/* Branch/history view (viewSections !== null → previewing the
1484
+ published branch): presentation only, never the editor. Drew's
1485
+ product decision — old versions are the record, and we have no
1486
+ way to edit them, so opening the editor over one would be a lie.
1487
+ The draft view (viewSections === null) still opens the real
1488
+ editor. NOTE: viewSections drives the "Published" branch preview
1489
+ only; the separate historySelectEvent commit view
1490
+ (EditorContext.historyState) is the OTHER read-only case — the
1491
+ historyActive-gated FAB above is what keeps this overlay from
1492
+ ever being opened onto it in the first place. */}
1493
+ {viewSections !== null ? (
1494
+ <DocumentPresentOverlay
1495
+ page={resolvedActivePage}
1496
+ sections={branchDisplaySections}
1497
+ manifest={mediaManifest}
1498
+ onClose={() => setShowDocumentEditor(false)}
1499
+ />
1500
+ ) : (
1501
+ <DocumentEditorOverlay
1502
+ page={resolvedActivePage}
1503
+ sections={activeSections}
1504
+ manifest={mediaManifest}
1505
+ onUpdateDocument={(patch) => handleSetPageDocument(resolvedActivePage.id, patch)}
1506
+ onClose={() => setShowDocumentEditor(false)}
1507
+ />
1508
+ )}
1509
+ </Suspense>
1510
+ )}
1511
+
1411
1512
  {/* Editor chat FAB: chat while editing, against the DRAFT chatbot
1412
1513
  config (tasks + prompt reflect unsaved changes; grounding is
1413
1514
  published content). Gated only on the platform switch — editors
@@ -1589,6 +1690,7 @@ export default function EditorShell({
1589
1690
  onSetArchived={handleSetArchived}
1590
1691
  onSetFields={handleSetPageFields}
1591
1692
  onSetAudience={handleSetPageAudience}
1693
+ onSetDocument={handleSetPageDocument}
1592
1694
  onConfirmDelete={handleDeletePage}
1593
1695
  />
1594
1696
  </EditorModal>
@@ -1665,8 +1767,16 @@ function ViewBranchWatcher({
1665
1767
  return null;
1666
1768
  }
1667
1769
 
1668
- function HistoryWatcher() {
1669
- const { setHistoryState } = useEditorContext();
1770
+ function HistoryWatcher({
1771
+ onHistoryActiveChange,
1772
+ }: {
1773
+ // Lifts historyState's active/inactive status to the shell body (mirrors
1774
+ // ViewBranchWatcher's setViewSections lift) — the body renders the
1775
+ // EditorProvider this component reads from, so it can't call
1776
+ // useEditorContext() itself to get at historyState.
1777
+ onHistoryActiveChange?: (active: boolean) => void;
1778
+ }) {
1779
+ const { historyState, setHistoryState } = useEditorContext();
1670
1780
 
1671
1781
  useEffect(() => {
1672
1782
  const unlisten = historySelectEvent.listen(async ({ sha, date }) => {
@@ -1691,6 +1801,10 @@ function HistoryWatcher() {
1691
1801
  return unlisten;
1692
1802
  }, [setHistoryState]);
1693
1803
 
1804
+ useEffect(() => {
1805
+ onHistoryActiveChange?.(historyState !== null);
1806
+ }, [historyState, onHistoryActiveChange]);
1807
+
1694
1808
  return null;
1695
1809
  }
1696
1810
 
@@ -2039,6 +2153,15 @@ function EditorContent({
2039
2153
  );
2040
2154
  }
2041
2155
 
2156
+ // Suspense fallback while the lazy document overlay chunk loads.
2157
+ function DocumentOverlayFallback() {
2158
+ return (
2159
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-base" role="status" aria-live="polite">
2160
+ <span className="text-sm text-base-contrast-light">Loading document editor…</span>
2161
+ </div>
2162
+ );
2163
+ }
2164
+
2042
2165
  function GlobalModal() {
2043
2166
  const { modalState, closeModal } = useEditorModal();
2044
2167
 
@@ -0,0 +1,45 @@
1
+ import type { DocCover } from "../engine/settings";
2
+ import type { DocImage, DocMeta } from "../engine/types";
3
+ import { DocPage } from "./DocPage";
4
+ import type { DocHost } from "./DocSectionPage";
5
+ import { Wordmark } from "./Wordmark";
6
+
7
+ // Absorbs the harness's TitleCardLayout inline instead of wrapping it: the
8
+ // portal-native cover has no client-logo slot (the harness only ever passed
9
+ // null) and cover text is edited in the inspector, never on-canvas, so there
10
+ // is no EditableText/deck-context machinery here — this always renders the
11
+ // harness's non-editable, null-logo branch.
12
+ export function DocCoverPage({
13
+ cover,
14
+ meta,
15
+ image,
16
+ host,
17
+ }: {
18
+ cover: DocCover;
19
+ meta: DocMeta;
20
+ /** Resolved from source.images[cover.imageId] by the caller; null/undefined = solid cover. */
21
+ image?: DocImage | null;
22
+ host?: DocHost;
23
+ }) {
24
+ return (
25
+ <DocPage meta={meta} bleed chrome={false}>
26
+ <div className="layout-titlecard" style={{ background: cover.bgColor }}>
27
+ <header className="layout-titlecard__masthead">
28
+ <Wordmark className="layout-titlecard__wordmark" />
29
+ <span className="layout-titlecard__rule" />
30
+ </header>
31
+ {image && host ? (
32
+ <div className="layout-titlecard__image">
33
+ <img src={host.imageSrc(image, host.variant)} alt={image.alt} />
34
+ </div>
35
+ ) : null}
36
+ <div className="layout-titlecard__lockup" style={{ color: cover.accentColor }}>
37
+ <h1 className="layout-titlecard__title">{cover.title}</h1>
38
+ <span className="layout-titlecard__titlerule" style={{ background: cover.accentColor }} />
39
+ <p className="layout-titlecard__label">{cover.label}</p>
40
+ <p className="layout-titlecard__date">{cover.date || meta.dateLabel}</p>
41
+ </div>
42
+ </div>
43
+ </DocPage>
44
+ );
45
+ }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react";
2
+ import type { DocMeta } from "../engine/types";
3
+ import { Wordmark } from "./Wordmark";
4
+
5
+ // Re-exported so index.ts's `export { DocPage, type DocMeta } from
6
+ // "./components/DocPage"` has a local name — the single definition still
7
+ // lives in engine/types.ts.
8
+ export type { DocMeta };
9
+
10
+ export function DocPage({
11
+ meta,
12
+ bleed = false,
13
+ chrome = true,
14
+ children,
15
+ }: {
16
+ meta: DocMeta;
17
+ bleed?: boolean;
18
+ chrome?: boolean;
19
+ children: ReactNode;
20
+ }) {
21
+ const crumb = [meta.client, meta.project, meta.dateLabel]
22
+ .map((s) => s.trim().toUpperCase())
23
+ .filter(Boolean)
24
+ .join(" :: ");
25
+
26
+ return (
27
+ <section className="deck-page">
28
+ {bleed ? <div className="deck-bleed">{children}</div> : null}
29
+ {chrome ? (
30
+ <header className="deck-chrome">
31
+ <span className="deck-crumb">{crumb}</span>
32
+ <div className="deck-wordmark-clip">
33
+ <Wordmark className="deck-wordmark" />
34
+ </div>
35
+ </header>
36
+ ) : null}
37
+ {!bleed ? <div className="deck-body">{children}</div> : null}
38
+ </section>
39
+ );
40
+ }
@@ -0,0 +1,164 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import type { DerivedPage } from "../engine/paginate";
3
+ import type { DocCover } from "../engine/settings";
4
+ import type { DocImage, DocMeta } from "../engine/types";
5
+ import { DocCoverPage } from "./DocCoverPage";
6
+ import { DocSectionPage, type DocHost } from "./DocSectionPage";
7
+
8
+ // 17in @96dpi = 1632px wide; thumbnails render the real page scaled to fill the
9
+ // rail width at a locked 17:11 aspect ratio (mirrors the harness PageRail).
10
+ const PAGE_W = 1632;
11
+ const PAGE_H = 1056;
12
+
13
+ export type RailEntry =
14
+ | { kind: "cover" }
15
+ | { kind: "page"; page: DerivedPage }
16
+ | { kind: "ghost"; chunkId: string; title: string; reason: "excluded" | "empty"; depth?: 1 };
17
+
18
+ export function entryId(entry: RailEntry): string {
19
+ if (entry.kind === "cover") return "cover";
20
+ if (entry.kind === "page") return entry.page.id;
21
+ return `ghost:${entry.chunkId}`;
22
+ }
23
+
24
+ // Rail nesting: sub-pages sit one step in per depth, continuations one more.
25
+ const INDENT = ["", "pl-4", "pl-8"];
26
+ const indentClass = (level: number) => INDENT[Math.min(level, INDENT.length - 1)] || undefined;
27
+
28
+ function ThumbButton({
29
+ id,
30
+ selectedId,
31
+ scale,
32
+ label,
33
+ indentLevel,
34
+ onSelect,
35
+ children,
36
+ }: {
37
+ id: string;
38
+ selectedId: string;
39
+ scale: number;
40
+ label: string;
41
+ indentLevel?: number;
42
+ onSelect: (id: string) => void;
43
+ children: ReactNode;
44
+ }) {
45
+ return (
46
+ <li className={indentClass(indentLevel ?? 0)}>
47
+ <button
48
+ type="button"
49
+ onClick={() => onSelect(id)}
50
+ className={`relative block w-full rounded border-2 bg-white ${
51
+ id === selectedId ? "border-teal-600" : "border-neutral-200 hover:border-neutral-300"
52
+ }`}
53
+ >
54
+ <span className="pointer-events-none block w-full overflow-hidden aspect-[17/11]">
55
+ <span
56
+ className="block origin-top-left"
57
+ style={{ transform: `scale(${scale})`, width: PAGE_W, height: PAGE_H }}
58
+ >
59
+ {children}
60
+ </span>
61
+ </span>
62
+ <span className="absolute bottom-0.5 left-1 max-w-[90%] truncate text-[10px] text-neutral-400">
63
+ {label}
64
+ </span>
65
+ </button>
66
+ </li>
67
+ );
68
+ }
69
+
70
+ export function DocRail({
71
+ entries,
72
+ selectedId,
73
+ meta,
74
+ cover,
75
+ coverImage,
76
+ host,
77
+ onSelect,
78
+ onCollapse,
79
+ }: {
80
+ entries: RailEntry[];
81
+ selectedId: string;
82
+ meta: DocMeta;
83
+ cover: DocCover;
84
+ coverImage?: DocImage | null;
85
+ host: DocHost;
86
+ onSelect: (id: string) => void;
87
+ onCollapse: () => void;
88
+ }) {
89
+ const listRef = useRef<HTMLOListElement>(null);
90
+ const [scale, setScale] = useState(0.11);
91
+ useEffect(() => {
92
+ const el = listRef.current;
93
+ if (!el || typeof ResizeObserver === "undefined") return;
94
+ const obs = new ResizeObserver(() => {
95
+ // content width = list width minus the button's 2px border on each side
96
+ const w = el.clientWidth - 4;
97
+ if (w > 0) setScale(w / PAGE_W);
98
+ });
99
+ obs.observe(el);
100
+ return () => obs.disconnect();
101
+ }, []);
102
+
103
+ return (
104
+ <nav
105
+ data-testid="doc-rail"
106
+ className="flex w-52 shrink-0 flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3"
107
+ >
108
+ <button
109
+ type="button"
110
+ aria-label="Collapse page rail"
111
+ onClick={onCollapse}
112
+ className="mb-2 flex w-full items-center justify-between rounded px-2 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
113
+ >
114
+ <span>Pages</span>
115
+ <span aria-hidden="true" className="text-lg leading-none">‹</span>
116
+ </button>
117
+ <ol ref={listRef} className="flex flex-col gap-2">
118
+ {entries.map((entry) => {
119
+ const id = entryId(entry);
120
+ if (entry.kind === "cover") {
121
+ return (
122
+ <ThumbButton key={id} id={id} selectedId={selectedId} scale={scale} label="Cover" onSelect={onSelect}>
123
+ <DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
124
+ </ThumbButton>
125
+ );
126
+ }
127
+ if (entry.kind === "page") {
128
+ return (
129
+ <ThumbButton
130
+ key={id}
131
+ id={id}
132
+ selectedId={selectedId}
133
+ scale={scale}
134
+ label={entry.page.title}
135
+ indentLevel={(entry.page.depth ?? 0) + (entry.page.cont ? 1 : 0)}
136
+ onSelect={onSelect}
137
+ >
138
+ <DocSectionPage page={entry.page} meta={meta} host={host} />
139
+ </ThumbButton>
140
+ );
141
+ }
142
+ return (
143
+ <li key={id} className={indentClass(entry.depth ?? 0)}>
144
+ <button
145
+ type="button"
146
+ onClick={() => onSelect(id)}
147
+ className={`flex w-full items-center justify-between gap-2 rounded border px-2 py-2 text-left text-xs ${
148
+ id === selectedId
149
+ ? "border-teal-600 bg-white"
150
+ : "border-dashed border-neutral-300 bg-neutral-50 hover:border-neutral-400"
151
+ }`}
152
+ >
153
+ <span className="truncate text-neutral-400">{entry.title}</span>
154
+ <span className="shrink-0 rounded bg-neutral-200 px-1 text-[10px] uppercase tracking-wide text-neutral-500">
155
+ {entry.reason}
156
+ </span>
157
+ </button>
158
+ </li>
159
+ );
160
+ })}
161
+ </ol>
162
+ </nav>
163
+ );
164
+ }
@@ -0,0 +1,48 @@
1
+ import type { DocImage, DocMeta } from "../engine/types";
2
+ import type { DerivedPage } from "../engine/paginate";
3
+ import { DocPage } from "./DocPage";
4
+
5
+ export interface DocHost {
6
+ imageSrc: (image: DocImage, variant: "preview" | "print") => string;
7
+ variant: "preview" | "print";
8
+ }
9
+
10
+ const inch = (v: number) => `${v}in`;
11
+
12
+ export function DocSectionPage({ page, meta, host }: { page: DerivedPage; meta: DocMeta; host: DocHost }) {
13
+ return (
14
+ <DocPage meta={meta}>
15
+ <h2 className="pdoc-title">
16
+ {page.kicker ? <span className="pdoc-kicker" aria-hidden="true">{page.kicker}</span> : null}
17
+ {page.title}
18
+ </h2>
19
+ {page.textRegion ? (
20
+ <div
21
+ className="pdoc-text"
22
+ style={{ left: inch(page.textRegion.xIn), top: inch(page.textRegion.yIn), width: inch(page.textRegion.wIn) }}
23
+ >
24
+ {page.textBlocks.map((b, i) => {
25
+ const spacing = i > 0 ? { marginTop: inch(0.15) } : undefined;
26
+ if (b.kind === "heading") {
27
+ return b.level === 2 ? (
28
+ <h3 key={i} className="pdoc-h2" style={spacing}>{b.text}</h3>
29
+ ) : (
30
+ <h4 key={i} className="pdoc-h3" style={spacing}>{b.text}</h4>
31
+ );
32
+ }
33
+ return <div key={i} className="pdoc-prose" style={spacing} dangerouslySetInnerHTML={{ __html: b.html }} />;
34
+ })}
35
+ </div>
36
+ ) : null}
37
+ {page.imageBoxes.map((box, i) => (
38
+ <div
39
+ key={`${box.image.imageId}-${i}`}
40
+ className="pdoc-img"
41
+ style={{ left: inch(box.xIn), top: inch(box.yIn), width: inch(box.wIn), height: inch(box.hIn) }}
42
+ >
43
+ <img src={host.imageSrc(box.image, host.variant)} alt={box.image.alt} style={{ objectFit: box.fit }} />
44
+ </div>
45
+ ))}
46
+ </DocPage>
47
+ );
48
+ }