@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
@@ -0,0 +1,95 @@
1
+ import { splitProseBlocks } from "./blocks";
2
+ import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput, type SplitAt } from "./types";
3
+
4
+ // Shape of the fields harvest reads, checked structurally at runtime — the
5
+ // engine must tolerate any future portal section without crashing (allowlist).
6
+ type Loose = Record<string, unknown>;
7
+
8
+ function str(v: unknown): string {
9
+ return typeof v === "string" ? v : "";
10
+ }
11
+
12
+ // Normalizes any runtime value (including null/undefined from malformed
13
+ // portal data) into a safe-to-index object so field access never throws.
14
+ function asRecord(v: unknown): Loose {
15
+ return v && typeof v === "object" ? (v as Loose) : {};
16
+ }
17
+
18
+ // One section's contribution to the current chunk (containers recurse).
19
+ function harvestInto(
20
+ target: Chunk,
21
+ type: string,
22
+ content: unknown,
23
+ imagesById: Record<string, DocImage>,
24
+ ): void {
25
+ const c = asRecord(content);
26
+ switch (type) {
27
+ case "sub_heading":
28
+ case "link_heading": // nested (inside a container) — not a boundary here
29
+ target.textBlocks.push({ kind: "heading", level: 2, text: str(c.heading) });
30
+ break;
31
+ case "sub_sub_heading":
32
+ target.textBlocks.push({ kind: "heading", level: 3, text: str(c.heading) });
33
+ break;
34
+ case "prose":
35
+ for (const html of splitProseBlocks(str(c.body))) {
36
+ target.textBlocks.push({ kind: "prose", html });
37
+ }
38
+ break;
39
+ case "media": {
40
+ const ref = asRecord(c.ref);
41
+ const image = imagesById[str(ref.imageId)];
42
+ if (image) target.images.push(image);
43
+ break;
44
+ }
45
+ case "container": {
46
+ const children = Array.isArray(c.children) ? c.children : [];
47
+ for (const child of children) {
48
+ const cc = asRecord(child);
49
+ if (typeof cc.type === "string") {
50
+ harvestInto(target, cc.type, cc.content, imagesById);
51
+ }
52
+ }
53
+ break;
54
+ }
55
+ default:
56
+ break; // allowlist: video, button, spacer, colors, do/dont, icon list, unknown future types
57
+ }
58
+ }
59
+
60
+ export function harvest(
61
+ sections: DocSectionInput[],
62
+ pageTitle: string,
63
+ imagesById: Record<string, DocImage>,
64
+ splitAtFor: (chunkId: string) => SplitAt = () => "none",
65
+ ): Chunk[] {
66
+ const chunks: Chunk[] = [];
67
+ const preamble: Chunk = { id: PREAMBLE_ID, title: pageTitle, textBlocks: [], images: [] };
68
+ let parent = preamble; // current top-level chunk — owns the splitAt setting
69
+ let current = preamble; // chunk receiving harvested content
70
+ let splitAt = splitAtFor(PREAMBLE_ID);
71
+
72
+ for (const section of sections) {
73
+ const heading = str(asRecord(section.content).heading);
74
+ if (section.type === "link_heading") {
75
+ parent = { id: section.id, title: heading, textBlocks: [], images: [] };
76
+ current = parent;
77
+ splitAt = splitAtFor(section.id);
78
+ chunks.push(parent);
79
+ } else if (section.type === "sub_heading" && splitAt === "sub_heading") {
80
+ // Promotion: the boundary heading becomes the chunk title, not a TextBlock.
81
+ current = { id: section.id, title: heading, textBlocks: [], images: [], parentId: parent.id, kicker: parent.title, depth: 1 };
82
+ chunks.push(current);
83
+ } else {
84
+ harvestInto(current, section.type, section.content, imagesById);
85
+ }
86
+ }
87
+
88
+ // Kept when it has own content OR spawned sub-chunks — an emptied-but-split
89
+ // preamble must stay selectable (its ghost is the way back to splitAt).
90
+ const keepPreamble =
91
+ preamble.textBlocks.length > 0 ||
92
+ preamble.images.length > 0 ||
93
+ chunks.some((c) => c.parentId === PREAMBLE_ID);
94
+ return keepPreamble ? [preamble, ...chunks] : chunks;
95
+ }
@@ -0,0 +1,152 @@
1
+ const EPS = 1e-6;
2
+
3
+ export type MasonryMode = "rows" | "columns";
4
+
5
+ export type Sized = { width: number; height: number };
6
+
7
+ export interface MasonryOpts {
8
+ mode: MasonryMode;
9
+ columns: 2 | 3 | 4 | 5 | 6;
10
+ bodyWIn: number;
11
+ bodyHIn: number;
12
+ gapIn: number;
13
+ targetRowHeightIn: number;
14
+ rows?: "auto" | 1 | 2 | 3 | 4;
15
+ }
16
+
17
+ export interface MasonryBox<T extends Sized = Sized> {
18
+ asset: T;
19
+ xIn: number;
20
+ yIn: number;
21
+ wIn: number;
22
+ hIn: number;
23
+ }
24
+
25
+ // A "cover" photo is laid out in a SQUARE cell (effective aspect 1) and the
26
+ // image crops to fill it; "contain" (default) uses the photo's natural aspect.
27
+ const effectiveAspect = (im: Sized, fit: "cover" | "contain" | undefined) =>
28
+ fit === "cover" ? 1 : im.width / im.height;
29
+
30
+ function layoutRows<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; endY: number } {
31
+ const asp = images.map((im, i) => effectiveAspect(im, fits[i]));
32
+ const boxes: MasonryBox<T>[] = [];
33
+ let y = 0, i = 0;
34
+ let used: number | null = null;
35
+ while (i < images.length) {
36
+ const row: number[] = []; // indices in this row
37
+ let natW = 0;
38
+ while (i + row.length < images.length) {
39
+ row.push(i + row.length);
40
+ const gaps = o.gapIn * (row.length - 1);
41
+ natW = row.reduce((s, idx) => s + asp[idx] * o.targetRowHeightIn, 0) + gaps;
42
+ if (natW >= o.bodyWIn) break;
43
+ }
44
+ const gaps = o.gapIn * (row.length - 1);
45
+ const sumAspect = row.reduce((s, idx) => s + asp[idx], 0);
46
+ const isFull = natW >= o.bodyWIn;
47
+ const rowH = isFull ? (o.bodyWIn - gaps) / sumAspect : o.targetRowHeightIn;
48
+ if (used === null && y + rowH > o.bodyHIn + EPS && boxes.length > 0) used = boxes.length;
49
+ let x = 0;
50
+ for (const idx of row) {
51
+ const w = asp[idx] * rowH;
52
+ boxes.push({ asset: images[idx], xIn: x, yIn: y, wIn: w, hIn: rowH });
53
+ x += w + o.gapIn;
54
+ }
55
+ y += rowH + o.gapIn;
56
+ i += row.length;
57
+ }
58
+ return { boxes, used: used ?? images.length, endY: y };
59
+ }
60
+
61
+ // Forced row count: partition the images into exactly N rows (fewer only if
62
+ // there are fewer images), aspect-balanced, each row scaled to full width —
63
+ // unless the stack would exceed the region height, in which case the whole
64
+ // block scales down uniformly to fit (never up), left-aligned. Unlike
65
+ // layoutRows there is no target height — the row's share of the total aspect
66
+ // determines its height.
67
+ function layoutForcedRows<T extends Sized>(
68
+ images: T[],
69
+ o: MasonryOpts,
70
+ fits: ("cover" | "contain")[],
71
+ n: number,
72
+ ): { boxes: MasonryBox<T>[]; used: number; endY: number } {
73
+ const asp = images.map((im, i) => effectiveAspect(im, fits[i]));
74
+ const count = Math.min(n, images.length);
75
+ // Greedy balance: each row takes images until it reaches the average
76
+ // aspect-sum of what's left, always leaving >=1 image per remaining row.
77
+ const rows: number[][] = [];
78
+ let i = 0;
79
+ for (let r = 0; r < count; r++) {
80
+ const remainingRows = count - r;
81
+ const target = asp.slice(i).reduce((s, a) => s + a, 0) / remainingRows;
82
+ const row: number[] = [];
83
+ let sum = 0;
84
+ while (i < asp.length) {
85
+ if (row.length > 0) {
86
+ if (asp.length - (i + 1) < remainingRows - 1) break; // taking i must leave >=1 image per remaining row
87
+ if (sum >= target - EPS) break;
88
+ }
89
+ row.push(i);
90
+ sum += asp[i];
91
+ i++;
92
+ }
93
+ rows.push(row);
94
+ }
95
+
96
+ // Pass 1: full-width row heights from the aspect math.
97
+ const rowMeta = rows.map((row) => {
98
+ const gaps = o.gapIn * (row.length - 1);
99
+ const sumAspect = row.reduce((s, a) => s + asp[a], 0);
100
+ return { row, gaps, sumAspect, hIn: (o.bodyWIn - gaps) / sumAspect };
101
+ });
102
+ // Pass 2: if the stack exceeds the region, scale everything down uniformly —
103
+ // natural aspects kept, gaps fixed, block left-aligned. Never upscale.
104
+ const rowGaps = o.gapIn * (rowMeta.length - 1);
105
+ const totalH = rowMeta.reduce((s, m) => s + m.hIn, 0);
106
+ const scale = totalH > 0 ? Math.min(1, Math.max(0, o.bodyHIn - rowGaps) / totalH) : 1;
107
+
108
+ const boxes: MasonryBox<T>[] = [];
109
+ let y = 0;
110
+ for (const m of rowMeta) {
111
+ const rowH = m.hIn * scale;
112
+ let x = 0;
113
+ for (const idx of m.row) {
114
+ const w = asp[idx] * rowH;
115
+ boxes.push({ asset: images[idx], xIn: x, yIn: y, wIn: w, hIn: rowH });
116
+ x += w + o.gapIn;
117
+ }
118
+ y += rowH + o.gapIn;
119
+ }
120
+ return { boxes, used: images.length, endY: y };
121
+ }
122
+
123
+ function layoutColumns<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; colHeights: number[]; colW: number } {
124
+ const n = o.columns;
125
+ const colW = (o.bodyWIn - o.gapIn * (n - 1)) / n;
126
+ const colH = new Array(n).fill(0);
127
+ const boxes: MasonryBox<T>[] = [];
128
+ let used: number | null = null;
129
+ images.forEach((im, idx) => {
130
+ let c = 0;
131
+ for (let k = 1; k < n; k++) if (colH[k] < colH[c]) c = k;
132
+ const y = colH[c];
133
+ const h = colW / effectiveAspect(im, fits[idx]);
134
+ if (used === null && y + h > o.bodyHIn + EPS && idx > 0) used = idx;
135
+ boxes.push({ asset: im, xIn: c * (colW + o.gapIn), yIn: y, wIn: colW, hIn: h });
136
+ colH[c] = y + h + o.gapIn;
137
+ });
138
+ return { boxes, used: used ?? images.length, colHeights: colH, colW };
139
+ }
140
+
141
+ export function layoutMasonryPage<T extends Sized>(
142
+ images: T[],
143
+ o: MasonryOpts,
144
+ fits: ("cover" | "contain")[] = [],
145
+ ): { boxes: MasonryBox<T>[]; used: number } {
146
+ if (o.mode === "columns") {
147
+ const r = layoutColumns(images, o, fits);
148
+ return { boxes: r.boxes, used: r.used };
149
+ }
150
+ const r = typeof o.rows === "number" ? layoutForcedRows(images, o, fits, o.rows) : layoutRows(images, o, fits);
151
+ return { boxes: r.boxes, used: r.used };
152
+ }
@@ -0,0 +1,229 @@
1
+ import { layoutMasonryPage } from "./masonry";
2
+ import type { SectionSettings } from "./settings";
3
+ import type { Chunk, DocImage, TextBlock } from "./types";
4
+
5
+ export interface DocGeom {
6
+ bodyWIn: number;
7
+ bodyHIn: number;
8
+ titleBandIn: number;
9
+ gapIn: number;
10
+ leftColWIn: number;
11
+ colGutterIn: number;
12
+ minTextColIn: number;
13
+ minImageRegionIn: number;
14
+ masonryTargetRowIn: number;
15
+ topTextMaxFraction: number;
16
+ }
17
+
18
+ // Mirrors .deck-body in deck.css (17×11 page, top 1.35in, sides/bottom 0.6in).
19
+ export const DOC_GEOM: DocGeom = {
20
+ bodyWIn: 15.8,
21
+ bodyHIn: 9.05,
22
+ titleBandIn: 0.9,
23
+ gapIn: 0.15,
24
+ leftColWIn: 4.6,
25
+ colGutterIn: 0.4,
26
+ minTextColIn: 1,
27
+ minImageRegionIn: 1.2,
28
+ masonryTargetRowIn: 2.6,
29
+ // Top text fills at most this fraction of the body height below the title
30
+ // band, so a text-heavy top section always leaves ≥ half the page for images.
31
+ topTextMaxFraction: 0.5,
32
+ };
33
+
34
+ const EPS = 1e-6;
35
+
36
+ export interface Region { xIn: number; yIn: number; wIn: number; hIn: number; }
37
+ export interface ImageBox {
38
+ image: DocImage;
39
+ xIn: number; yIn: number; wIn: number; hIn: number;
40
+ fit: "cover" | "contain";
41
+ }
42
+ export interface DerivedPage {
43
+ id: string;
44
+ chunkId: string;
45
+ title: string;
46
+ cont: boolean;
47
+ // Sub-chunk context (absent on top-level pages) — see Chunk.
48
+ kicker?: string;
49
+ depth?: 1;
50
+ textBlocks: TextBlock[];
51
+ textRegion: Region | null;
52
+ imageBoxes: ImageBox[];
53
+ warnings: string[];
54
+ }
55
+
56
+ function fitImages(
57
+ images: DocImage[],
58
+ layout: SectionSettings["imageLayout"],
59
+ region: Region,
60
+ geom: DocGeom,
61
+ ): { boxes: ImageBox[]; used: number } {
62
+ if (images.length === 0 || region.hIn < geom.minImageRegionIn) return { boxes: [], used: 0 };
63
+
64
+ if (layout.mode === "grid") {
65
+ const used = Math.min(layout.rows * layout.columns, images.length);
66
+ const cellW = (region.wIn - (layout.columns - 1) * geom.gapIn) / layout.columns;
67
+ const cellH = (region.hIn - (layout.rows - 1) * geom.gapIn) / layout.rows;
68
+ const boxes = images.slice(0, used).map((image, i) => {
69
+ const r = Math.floor(i / layout.columns);
70
+ const c = i % layout.columns;
71
+ return {
72
+ image,
73
+ xIn: region.xIn + c * (cellW + geom.gapIn),
74
+ yIn: region.yIn + r * (cellH + geom.gapIn),
75
+ wIn: cellW,
76
+ hIn: cellH,
77
+ fit: layout.fit,
78
+ };
79
+ });
80
+ return { boxes, used };
81
+ }
82
+
83
+ const r = layoutMasonryPage(images, {
84
+ mode: layout.flow,
85
+ columns: layout.columns,
86
+ rows: layout.rows,
87
+ bodyWIn: region.wIn,
88
+ bodyHIn: region.hIn,
89
+ gapIn: geom.gapIn,
90
+ targetRowHeightIn: geom.masonryTargetRowIn,
91
+ });
92
+ const boxes = r.boxes.slice(0, r.used).map((b) => ({
93
+ image: b.asset,
94
+ xIn: region.xIn + b.xIn,
95
+ yIn: region.yIn + b.yIn,
96
+ wIn: b.wIn,
97
+ hIn: b.hIn,
98
+ fit: "contain" as const,
99
+ }));
100
+ return { boxes, used: r.used };
101
+ }
102
+
103
+ export function paginateChunk(
104
+ chunk: Chunk,
105
+ settings: SectionSettings,
106
+ textHeightsIn: number[],
107
+ geom: DocGeom = DOC_GEOM,
108
+ naturalTextWIn?: number,
109
+ ): DerivedPage[] {
110
+ if (settings.excluded) return [];
111
+ if (chunk.textBlocks.length !== textHeightsIn.length) {
112
+ throw new Error("paginateChunk: textHeightsIn must match chunk.textBlocks");
113
+ }
114
+ if (chunk.textBlocks.length === 0 && chunk.images.length === 0) return [];
115
+
116
+ // leftColumn shrinks to the widest natural text line (+ slack so sub-pixel
117
+ // rounding can't re-wrap), floored/capped; absent measurement = legacy fixed
118
+ // column. The shrunk width is >= every block's natural width by
119
+ // construction, so measured heights remain valid.
120
+ const colWIn =
121
+ naturalTextWIn === undefined
122
+ ? geom.leftColWIn
123
+ : Math.min(geom.leftColWIn, Math.max(geom.minTextColIn, naturalTextWIn + 0.05));
124
+
125
+ const pages: DerivedPage[] = [];
126
+ let ti = 0;
127
+ let ii = 0;
128
+ const top = geom.titleBandIn;
129
+
130
+ while (ti < chunk.textBlocks.length || ii < chunk.images.length) {
131
+ const cont = pages.length > 0;
132
+ const warnings: string[] = [];
133
+ const textW = settings.textPlacement === "top" ? geom.bodyWIn : colWIn;
134
+
135
+ // 1. Greedy text fill. Top text is capped at half the body so a text-heavy
136
+ // section still leaves room for images below; leftColumn keeps full height.
137
+ const capY =
138
+ settings.textPlacement === "top"
139
+ ? geom.titleBandIn + (geom.bodyHIn - geom.titleBandIn) * geom.topTextMaxFraction
140
+ : geom.bodyHIn;
141
+ const placed: TextBlock[] = [];
142
+ let textEnd = top;
143
+ while (ti < chunk.textBlocks.length) {
144
+ const h = textHeightsIn[ti];
145
+ const yAfter = placed.length === 0 ? top + h : textEnd + geom.gapIn + h;
146
+ if (yAfter <= capY + EPS) {
147
+ placed.push(chunk.textBlocks[ti]);
148
+ textEnd = yAfter;
149
+ ti++;
150
+ } else if (placed.length === 0) {
151
+ // Oversize block: it gets the page, clipped at the cap (masonry-notice style).
152
+ placed.push(chunk.textBlocks[ti]);
153
+ textEnd = capY;
154
+ ti++;
155
+ warnings.push("A text block is taller than the page and was clipped.");
156
+ break;
157
+ } else {
158
+ break;
159
+ }
160
+ }
161
+
162
+ // dropTextOverflow: on the FIRST page only, discard any text that didn't fit
163
+ // rather than flowing it onto Cont. pages. Images still paginate as usual.
164
+ if (!cont && settings.dropTextOverflow && ti < chunk.textBlocks.length) {
165
+ const dropped = chunk.textBlocks.length - ti;
166
+ ti = chunk.textBlocks.length;
167
+ warnings.push(`${dropped} text block(s) hidden (overflow dropped)`);
168
+ }
169
+ const textRegion: Region | null =
170
+ placed.length > 0 ? { xIn: 0, yIn: top, wIn: textW, hIn: textEnd - top } : null;
171
+
172
+ // 2. Image region. leftColumn+right runs beside the text; every other
173
+ // case — and any page whose text is exhausted — is full width below it.
174
+ let region: Region;
175
+ if (settings.textPlacement === "leftColumn" && settings.imagePlacement === "right" && placed.length > 0) {
176
+ region = {
177
+ xIn: colWIn + geom.colGutterIn,
178
+ yIn: top,
179
+ wIn: geom.bodyWIn - colWIn - geom.colGutterIn,
180
+ hIn: geom.bodyHIn - top,
181
+ };
182
+ } else {
183
+ const startY = placed.length > 0 ? textEnd + geom.gapIn : top;
184
+ region = { xIn: 0, yIn: startY, wIn: geom.bodyWIn, hIn: Math.max(0, geom.bodyHIn - startY) };
185
+ }
186
+
187
+ // 3. Fit images into the region.
188
+ const { boxes, used } = fitImages(chunk.images.slice(ii), settings.imageLayout, region, geom);
189
+ ii += used;
190
+ if (boxes.some((b) => b.yIn + b.hIn > region.yIn + region.hIn + 0.01)) {
191
+ warnings.push("An image overflows the page and is clipped.");
192
+ }
193
+
194
+ // Progress guard: unreachable with sane geometry (a text-free page always
195
+ // gets the full region, and both fitters place ≥1 there), but a config
196
+ // mistake must not hang the browser.
197
+ if (placed.length === 0 && used === 0) {
198
+ warnings.push("Pagination could not place remaining content.");
199
+ pages.push({
200
+ id: `${chunk.id}#${pages.length}`,
201
+ chunkId: chunk.id,
202
+ title: cont ? `${chunk.title} Cont.` : chunk.title,
203
+ cont,
204
+ kicker: chunk.kicker,
205
+ depth: chunk.depth,
206
+ textBlocks: placed,
207
+ textRegion,
208
+ imageBoxes: boxes,
209
+ warnings,
210
+ });
211
+ break;
212
+ }
213
+
214
+ pages.push({
215
+ id: `${chunk.id}#${pages.length}`,
216
+ chunkId: chunk.id,
217
+ title: cont ? `${chunk.title} Cont.` : chunk.title,
218
+ cont,
219
+ kicker: chunk.kicker,
220
+ depth: chunk.depth,
221
+ textBlocks: placed,
222
+ textRegion,
223
+ imageBoxes: boxes,
224
+ warnings,
225
+ });
226
+ }
227
+
228
+ return pages;
229
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+
3
+ // Presentation settings for one link-heading chunk. This shape is designed as
4
+ // if it were already a portal page's `document` options block — migrating it
5
+ // into the portal later must be a data move, not a redesign.
6
+ export const sectionSettingsSchema = z.object({
7
+ excluded: z.boolean().default(false),
8
+ textPlacement: z.enum(["top", "leftColumn"]).default("top"),
9
+ // "right" is only meaningful with textPlacement "leftColumn"; settingsFor
10
+ // normalizes the invalid combo.
11
+ imagePlacement: z.enum(["fullWidth", "right"]).default("fullWidth"),
12
+ // When true, text that overflows the first page is dropped instead of
13
+ // flowing onto Cont. pages (images still paginate normally).
14
+ dropTextOverflow: z.boolean().default(false),
15
+ // Also start new pages at sub-heading boundaries. Only read from PARENT
16
+ // entries (link_heading ids / __preamble); inert on sub-chunk entries.
17
+ splitAt: z.enum(["none", "sub_heading"]).default("none"),
18
+ imageLayout: z
19
+ .discriminatedUnion("mode", [
20
+ z.object({
21
+ mode: z.literal("grid"),
22
+ rows: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2),
23
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(4),
24
+ // "cover" crops each image to its cell; "contain" letterboxes the
25
+ // natural image inside it.
26
+ fit: z.enum(["cover", "contain"]).default("cover"),
27
+ }),
28
+ z.object({
29
+ mode: z.literal("masonry"),
30
+ flow: z.enum(["rows", "columns"]).default("rows"),
31
+ columns: z
32
+ .union([z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)])
33
+ .default(3),
34
+ // rows flow only: "auto" = greedy target-height fill (legacy behavior);
35
+ // 1-4 forces an exact aspect-balanced row count.
36
+ rows: z
37
+ .union([z.literal("auto"), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
38
+ .default("auto"),
39
+ }),
40
+ ])
41
+ .default({ mode: "masonry", flow: "rows", columns: 3, rows: "auto" }),
42
+ });
43
+
44
+ export type SectionSettings = z.infer<typeof sectionSettingsSchema>;
45
+
46
+ const DEFAULTS: SectionSettings = sectionSettingsSchema.parse({});
47
+
48
+ // Overlay lookup: unknown/stale/malformed entries fall back to defaults —
49
+ // settings for a deleted portal section must be inert, never fatal.
50
+ export function settingsFor(
51
+ all: Record<string, unknown> | undefined,
52
+ chunkId: string,
53
+ ): SectionSettings {
54
+ const raw = all?.[chunkId];
55
+ const parsed = sectionSettingsSchema.safeParse(raw ?? {});
56
+ const s = parsed.success ? parsed.data : DEFAULTS;
57
+ if (s.textPlacement === "top" && s.imagePlacement === "right") {
58
+ return { ...s, imagePlacement: "fullWidth" };
59
+ }
60
+ return s;
61
+ }
62
+
63
+ const documentMetaSchema = z.object({
64
+ client: z.string().default(""),
65
+ project: z.string().default(""),
66
+ dateLabel: z.string().default(""),
67
+ });
68
+ const documentCoverSchema = z.object({
69
+ bgColor: z.string().default("#333333"),
70
+ accentColor: z.string().default("#EB9500"),
71
+ title: z.string().default(""),
72
+ label: z.string().default("BRAND GUIDELINES"),
73
+ date: z.string().default(""),
74
+ // Media-library image id shown between masthead and title lockup; null = solid cover.
75
+ imageId: z.string().nullable().default(null),
76
+ });
77
+
78
+ export const documentOptionsSchema = z.object({
79
+ enabled: z.boolean().default(false),
80
+ // `.default({})` doesn't typecheck even though `{}` parses fine at
81
+ // runtime — zod v4 infers the default's type from the object schema's
82
+ // required input shape, not its post-default output shape.
83
+ meta: documentMetaSchema.default(() => documentMetaSchema.parse({})),
84
+ cover: documentCoverSchema.default(() => documentCoverSchema.parse({})),
85
+ sections: z.record(z.string(), sectionSettingsSchema).default({}),
86
+ });
87
+ export type DocumentOptions = z.infer<typeof documentOptionsSchema>;
88
+ export type DocCover = DocumentOptions["cover"];
@@ -0,0 +1,64 @@
1
+ // Engine-local structural types for portal content. Deliberately NOT imported
2
+ // from the rest of @drawnagency/primitives: the engine's contract is the
3
+ // portal content SHAPE, and staying import-free keeps it portable into its
4
+ // own package later.
5
+ // (The fixture test in tests/document/fixture.test.ts pins the shape against
6
+ // the real primitives schemas so drift is caught.)
7
+
8
+ export const PREAMBLE_ID = "__preamble";
9
+
10
+ // Page-split boundary level for one parent section. Sub-headings are the
11
+ // deepest deck boundary (internal convention, 2026-07-19) — sub_sub_heading
12
+ // sections always stay in-page as level-3 text blocks.
13
+ export type SplitAt = "none" | "sub_heading";
14
+
15
+ // Breadcrumb metadata shown in the page chrome. Single definition — the
16
+ // harness carried this shape in two places (deck/DeckPage.tsx's `DeckMeta`
17
+ // and lib/deck/schema.ts's zod-inferred `DeckMeta`); the engine only needs
18
+ // the plain type, not a schema, so it lives here once.
19
+ export interface DocMeta {
20
+ client: string;
21
+ project: string;
22
+ dateLabel: string;
23
+ }
24
+
25
+ export interface DocSectionInput {
26
+ id: string;
27
+ type: string;
28
+ content: Record<string, unknown>;
29
+ options?: Record<string, unknown>;
30
+ }
31
+
32
+ // One displayable image, dimensions + pre-chosen repo filenames baked in by
33
+ // the (scaffolding) fetch layer so the engine never touches the manifest.
34
+ export interface DocImage {
35
+ imageId: string;
36
+ width: number;
37
+ height: number;
38
+ alt: string;
39
+ previewFile: string; // e.g. "1080.webp" — editor variant
40
+ printFile: string; // e.g. "1920.webp" — print/PDF variant
41
+ }
42
+
43
+ export type TextBlock =
44
+ | { kind: "heading"; level: 2 | 3; text: string }
45
+ | { kind: "prose"; html: string };
46
+
47
+ // A page-producing chunk: a link_heading section (or the preamble), or — when
48
+ // the parent's splitAt says so — a sub heading section.
49
+ export interface Chunk {
50
+ id: string; // boundary heading's section id, or PREAMBLE_ID
51
+ title: string;
52
+ textBlocks: TextBlock[];
53
+ images: DocImage[];
54
+ // Sub-chunk fields; absent on top-level chunks.
55
+ parentId?: string;
56
+ kicker?: string; // masthead context line — the parent link_heading title
57
+ depth?: 1;
58
+ }
59
+
60
+ export interface DocSourceContent {
61
+ pageTitle: string;
62
+ sections: DocSectionInput[];
63
+ images: Record<string, DocImage>;
64
+ }
@@ -0,0 +1,18 @@
1
+ // Public surface of the document engine + components. Sealed subtree: nothing
2
+ // in document/ imports from the rest of primitives (see spec — extraction to
3
+ // @drawnagency/document must stay a directory move).
4
+ export * from "./engine/types";
5
+ export * from "./engine/settings";
6
+ export { splitProseBlocks } from "./engine/blocks";
7
+ export { harvest } from "./engine/harvest";
8
+ export * from "./engine/paginate";
9
+ export { layoutMasonryPage, type MasonryOpts, type MasonryBox, type Sized } from "./engine/masonry";
10
+
11
+ export { DocPage, type DocMeta } from "./components/DocPage";
12
+ export { DocSectionPage, type DocHost } from "./components/DocSectionPage";
13
+ export { DocCoverPage } from "./components/DocCoverPage";
14
+ export { useDocPages } from "./components/useDocPages";
15
+
16
+ // Task 3 — shared document shell (presentation + editing).
17
+ export { DocViewer, type DocViewerProps } from "./components/DocViewer";
18
+ export { DocRail, entryId, type RailEntry } from "./components/DocRail";