@drawnagency/primitives 0.1.81 → 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 (53) hide show
  1. package/dist/{chunk-ZEXR7JYQ.js → chunk-22GU6GNP.js} +1 -1
  2. package/dist/{chunk-P4ZNHZMP.js → chunk-5UDIQLGQ.js} +2 -2
  3. package/dist/{chunk-PJOHAWNV.js → chunk-6NRVX6NQ.js} +1 -1
  4. package/dist/{chunk-EVNEMJZ6.js → chunk-ETJYYUQX.js} +1 -1
  5. package/dist/{chunk-WP5UX4MI.js → chunk-FM4M4Q2W.js} +12 -3
  6. package/dist/{chunk-FN4NKUZU.js → chunk-Y54G2XSC.js} +1 -1
  7. package/dist/components/editor/index.js +2 -2
  8. package/dist/components/primitives/index.js +2 -2
  9. package/dist/components/sections/brand-guide.js +3 -3
  10. package/dist/components/sections/register-schemas.js +4 -4
  11. package/dist/components/sections/register.js +4 -4
  12. package/dist/components/shell/DocInspector.d.ts +1 -0
  13. package/dist/components/shell/DocInspector.d.ts.map +1 -1
  14. package/dist/components/shell/EditorShell.d.ts +3 -0
  15. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  16. package/dist/document/components/DocRail.d.ts +1 -0
  17. package/dist/document/components/DocRail.d.ts.map +1 -1
  18. package/dist/document/components/DocSectionPage.d.ts.map +1 -1
  19. package/dist/document/components/measure.d.ts +1 -0
  20. package/dist/document/components/measure.d.ts.map +1 -1
  21. package/dist/document/components/useDocPages.d.ts.map +1 -1
  22. package/dist/document/engine/harvest.d.ts +2 -2
  23. package/dist/document/engine/harvest.d.ts.map +1 -1
  24. package/dist/document/engine/masonry.d.ts +1 -0
  25. package/dist/document/engine/masonry.d.ts.map +1 -1
  26. package/dist/document/engine/paginate.d.ts +4 -1
  27. package/dist/document/engine/paginate.d.ts.map +1 -1
  28. package/dist/document/engine/settings.d.ts +18 -0
  29. package/dist/document/engine/settings.d.ts.map +1 -1
  30. package/dist/document/engine/types.d.ts +4 -0
  31. package/dist/document/engine/types.d.ts.map +1 -1
  32. package/dist/document/index.js +109 -25
  33. package/dist/document/styles/document.css +9 -0
  34. package/dist/index.js +4 -4
  35. package/dist/lib/index.js +3 -3
  36. package/dist/media/index.js +2 -2
  37. package/dist/schemas/index.js +3 -3
  38. package/dist/schemas/site-config.d.ts +42 -0
  39. package/dist/schemas/site-config.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/components/shell/DocInspector.tsx +46 -1
  42. package/src/components/shell/DocumentEditorOverlay.tsx +1 -1
  43. package/src/document/components/DocRail.tsx +10 -6
  44. package/src/document/components/DocSectionPage.tsx +4 -1
  45. package/src/document/components/DocViewer.tsx +1 -1
  46. package/src/document/components/measure.tsx +14 -2
  47. package/src/document/components/useDocPages.ts +25 -7
  48. package/src/document/engine/harvest.ts +21 -6
  49. package/src/document/engine/masonry.ts +64 -1
  50. package/src/document/engine/paginate.ts +24 -4
  51. package/src/document/engine/settings.ts +12 -1
  52. package/src/document/engine/types.ts +12 -2
  53. package/src/document/styles/document.css +9 -0
@@ -21,7 +21,7 @@ function thumbSrc(imageId: string, item: MediaItem | undefined): string | null {
21
21
 
22
22
  export interface DocInspectorProps {
23
23
  document: DocumentOptions;
24
- selection: { kind: "cover" } | { kind: "chunk"; chunkId: string; title: string };
24
+ selection: { kind: "cover" } | { kind: "chunk"; chunkId: string; title: string; depth?: number };
25
25
  warnings: string[]; // warningsByChunk for the selected chunk
26
26
  onUpdateCover: (patch: Partial<DocumentOptions["cover"]>) => void;
27
27
  onUpdateMeta: (patch: Partial<DocumentOptions["meta"]>) => void;
@@ -84,6 +84,7 @@ export function DocInspector({
84
84
  key={selection.chunkId}
85
85
  chunkId={selection.chunkId}
86
86
  title={selection.title}
87
+ depth={selection.depth}
87
88
  doc={doc}
88
89
  warnings={warnings}
89
90
  onUpdateSection={onUpdateSection}
@@ -153,12 +154,14 @@ function CoverPanel({
153
154
  function ChunkPanel({
154
155
  chunkId,
155
156
  title,
157
+ depth,
156
158
  doc,
157
159
  warnings,
158
160
  onUpdateSection,
159
161
  }: {
160
162
  chunkId: string;
161
163
  title: string;
164
+ depth?: number;
162
165
  doc: DocumentOptions;
163
166
  warnings: string[];
164
167
  onUpdateSection: (chunkId: string, patch: Record<string, unknown>) => void;
@@ -177,6 +180,22 @@ function ChunkPanel({
177
180
  label="Include in document"
178
181
  />
179
182
 
183
+ {/* Only parent sections own the boundary setting — a sub-page can't
184
+ split further, and its entry would be inert anyway (harvest reads
185
+ splitAt from parent ids only). */}
186
+ {(depth ?? 0) === 0 ? (
187
+ <Select
188
+ className="mb-4"
189
+ label="Split pages at"
190
+ value={settings.splitAt}
191
+ options={[
192
+ { value: "none", label: "Off" },
193
+ { value: "sub_heading", label: "Sub-headings" },
194
+ ]}
195
+ onChange={(splitAt) => onUpdateSection(chunkId, { splitAt })}
196
+ />
197
+ ) : null}
198
+
180
199
  {/* Image placement is linked, not independent (Drew, 2026-07-19): text
181
200
  left → images right of the column, text top → images below full
182
201
  width. One control drives both fields. */}
@@ -237,6 +256,16 @@ function ChunkPanel({
237
256
  options={[2, 3, 4].map((n) => ({ value: String(n), label: String(n) }))}
238
257
  onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, columns: Number(v) } })}
239
258
  />
259
+ <Select
260
+ className="mb-4"
261
+ label="Image fit"
262
+ value={layout.fit}
263
+ options={[
264
+ { value: "cover", label: "Crop" },
265
+ { value: "contain", label: "Contain" },
266
+ ]}
267
+ onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, fit: v } })}
268
+ />
240
269
  </>
241
270
  ) : (
242
271
  <>
@@ -250,6 +279,22 @@ function ChunkPanel({
250
279
  ]}
251
280
  onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, flow: v } })}
252
281
  />
282
+ {/* Rows count only drives the rows flow; "auto" keeps the greedy
283
+ target-height fill. Mirrors Columns, which only drives columns. */}
284
+ {layout.flow === "rows" ? (
285
+ <Select
286
+ className="mb-4"
287
+ label="Rows"
288
+ value={String(layout.rows)}
289
+ options={[
290
+ { value: "auto", label: "Auto" },
291
+ ...[1, 2, 3, 4].map((n) => ({ value: String(n), label: String(n) })),
292
+ ]}
293
+ onChange={(v) =>
294
+ onUpdateSection(chunkId, { imageLayout: { ...layout, rows: v === "auto" ? "auto" : Number(v) } })
295
+ }
296
+ />
297
+ ) : null}
253
298
  {/* Column count only drives the columns flow (layoutRows never reads
254
299
  it) — hide it when irrelevant. */}
255
300
  {layout.flow === "columns" ? (
@@ -110,7 +110,7 @@ export function DocumentEditorOverlay({
110
110
  const selection: DocInspectorProps["selection"] = useMemo(() => {
111
111
  if (selectedChunkId === null) return { kind: "cover" };
112
112
  const chunk = chunks.find((c) => c.id === selectedChunkId);
113
- return { kind: "chunk", chunkId: selectedChunkId, title: chunk?.title ?? selectedChunkId };
113
+ return { kind: "chunk", chunkId: selectedChunkId, title: chunk?.title ?? selectedChunkId, depth: chunk?.depth };
114
114
  }, [selectedChunkId, chunks]);
115
115
 
116
116
  const warnings = selectedChunkId ? warningsByChunk[selectedChunkId] ?? [] : [];
@@ -13,7 +13,7 @@ const PAGE_H = 1056;
13
13
  export type RailEntry =
14
14
  | { kind: "cover" }
15
15
  | { kind: "page"; page: DerivedPage }
16
- | { kind: "ghost"; chunkId: string; title: string; reason: "excluded" | "empty" };
16
+ | { kind: "ghost"; chunkId: string; title: string; reason: "excluded" | "empty"; depth?: 1 };
17
17
 
18
18
  export function entryId(entry: RailEntry): string {
19
19
  if (entry.kind === "cover") return "cover";
@@ -21,12 +21,16 @@ export function entryId(entry: RailEntry): string {
21
21
  return `ghost:${entry.chunkId}`;
22
22
  }
23
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
+
24
28
  function ThumbButton({
25
29
  id,
26
30
  selectedId,
27
31
  scale,
28
32
  label,
29
- indent,
33
+ indentLevel,
30
34
  onSelect,
31
35
  children,
32
36
  }: {
@@ -34,12 +38,12 @@ function ThumbButton({
34
38
  selectedId: string;
35
39
  scale: number;
36
40
  label: string;
37
- indent?: boolean;
41
+ indentLevel?: number;
38
42
  onSelect: (id: string) => void;
39
43
  children: ReactNode;
40
44
  }) {
41
45
  return (
42
- <li className={indent ? "pl-4" : undefined}>
46
+ <li className={indentClass(indentLevel ?? 0)}>
43
47
  <button
44
48
  type="button"
45
49
  onClick={() => onSelect(id)}
@@ -128,7 +132,7 @@ export function DocRail({
128
132
  selectedId={selectedId}
129
133
  scale={scale}
130
134
  label={entry.page.title}
131
- indent={entry.page.cont}
135
+ indentLevel={(entry.page.depth ?? 0) + (entry.page.cont ? 1 : 0)}
132
136
  onSelect={onSelect}
133
137
  >
134
138
  <DocSectionPage page={entry.page} meta={meta} host={host} />
@@ -136,7 +140,7 @@ export function DocRail({
136
140
  );
137
141
  }
138
142
  return (
139
- <li key={id}>
143
+ <li key={id} className={indentClass(entry.depth ?? 0)}>
140
144
  <button
141
145
  type="button"
142
146
  onClick={() => onSelect(id)}
@@ -12,7 +12,10 @@ const inch = (v: number) => `${v}in`;
12
12
  export function DocSectionPage({ page, meta, host }: { page: DerivedPage; meta: DocMeta; host: DocHost }) {
13
13
  return (
14
14
  <DocPage meta={meta}>
15
- <h2 className="pdoc-title">{page.title}</h2>
15
+ <h2 className="pdoc-title">
16
+ {page.kicker ? <span className="pdoc-kicker" aria-hidden="true">{page.kicker}</span> : null}
17
+ {page.title}
18
+ </h2>
16
19
  {page.textRegion ? (
17
20
  <div
18
21
  className="pdoc-text"
@@ -74,7 +74,7 @@ export function DocViewer({
74
74
  const empty = chunk.textBlocks.length === 0 && chunk.images.length === 0;
75
75
  if (s.excluded || empty) {
76
76
  if (mode === "edit") {
77
- list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty" });
77
+ list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty", depth: chunk.depth });
78
78
  }
79
79
  } else {
80
80
  for (const page of pages.filter((p) => p.chunkId === chunk.id)) list.push({ kind: "page", page });
@@ -39,8 +39,9 @@ function signatureFor(chunks: Chunk[], settingsMap: Record<string, SectionSettin
39
39
  export function useTextMeasurements(
40
40
  chunks: Chunk[],
41
41
  settingsMap: Record<string, SectionSettings>,
42
- ): { heights: Record<string, number[]> | null } {
42
+ ): { heights: Record<string, number[]> | null; textWidths: Record<string, number> | null } {
43
43
  const [heights, setHeights] = useState<Record<string, number[]> | null>(null);
44
+ const [textWidths, setTextWidths] = useState<Record<string, number> | null>(null);
44
45
  const [fontsReady, setFontsReady] = useState(false);
45
46
 
46
47
  // jsdom (tests) has no document.fonts — optional-chain so this resolves
@@ -65,10 +66,12 @@ export function useTextMeasurements(
65
66
  if (!fontsReady) return;
66
67
  const containers: HTMLDivElement[] = [];
67
68
  const next: Record<string, number[]> = {};
69
+ const nextW: Record<string, number> = {};
68
70
 
69
71
  for (const chunk of chunks) {
70
72
  if (chunk.textBlocks.length === 0) {
71
73
  next[chunk.id] = [];
74
+ nextW[chunk.id] = 0;
72
75
  continue;
73
76
  }
74
77
  const container = document.createElement("div");
@@ -78,6 +81,7 @@ export function useTextMeasurements(
78
81
  containers.push(container);
79
82
 
80
83
  const measured: number[] = [];
84
+ let wMax = 0;
81
85
  for (const block of chunk.textBlocks) {
82
86
  const wrapper = document.createElement("div");
83
87
  wrapper.className = blockClassName(block);
@@ -86,13 +90,21 @@ export function useTextMeasurements(
86
90
  } else {
87
91
  wrapper.innerHTML = block.html;
88
92
  }
93
+ // max-content + max-width:100% = shrink-to-fit capped at the column:
94
+ // wrapping blocks report the full cap (same wrap as before -> heights
95
+ // unchanged); short blocks report their natural width.
96
+ wrapper.style.width = "max-content";
97
+ wrapper.style.maxWidth = "100%";
89
98
  container.appendChild(wrapper);
90
99
  measured.push(wrapper.offsetHeight / PX_PER_IN);
100
+ wMax = Math.max(wMax, wrapper.getBoundingClientRect().width / PX_PER_IN);
91
101
  }
92
102
  next[chunk.id] = measured;
103
+ nextW[chunk.id] = wMax;
93
104
  }
94
105
 
95
106
  setHeights(next);
107
+ setTextWidths(nextW);
96
108
  return () => {
97
109
  for (const container of containers) container.remove();
98
110
  };
@@ -101,5 +113,5 @@ export function useTextMeasurements(
101
113
  // eslint-disable-next-line react-hooks/exhaustive-deps
102
114
  }, [signature, fontsReady]);
103
115
 
104
- return { heights };
116
+ return { heights, textWidths };
105
117
  }
@@ -1,8 +1,8 @@
1
1
  import { useMemo } from "react";
2
2
  import { harvest } from "../engine/harvest";
3
- import { paginateChunk, type DerivedPage } from "../engine/paginate";
3
+ import { DOC_GEOM, paginateChunk, type DerivedPage } from "../engine/paginate";
4
4
  import { settingsFor, type SectionSettings } from "../engine/settings";
5
- import type { Chunk, DocSourceContent } from "../engine/types";
5
+ import type { Chunk, DocSourceContent, SplitAt } from "../engine/types";
6
6
  import { useTextMeasurements } from "./measure";
7
7
 
8
8
  export function useDocPages(input: {
@@ -11,9 +11,27 @@ export function useDocPages(input: {
11
11
  }): { chunks: Chunk[]; pages: DerivedPage[]; measuring: boolean; warningsByChunk: Record<string, string[]> } {
12
12
  const { source, sections } = input;
13
13
 
14
+ // splitAt drives harvesting, so chunks must re-derive when it changes — but
15
+ // ONLY then. Keying on a JSON signature of just the non-default splitAt
16
+ // entries keeps chunk identities stable across unrelated settings edits
17
+ // (exclude, layout), which would otherwise churn measurement and onDocState.
18
+ const splitSig = useMemo(() => {
19
+ const m: Record<string, SplitAt> = {};
20
+ for (const id of Object.keys(sections ?? {})) {
21
+ const v = settingsFor(sections, id).splitAt;
22
+ if (v !== "none") m[id] = v;
23
+ }
24
+ return JSON.stringify(m);
25
+ }, [sections]);
26
+
27
+ const splitAtFor = useMemo(() => {
28
+ const m = JSON.parse(splitSig) as Record<string, SplitAt>;
29
+ return (id: string) => m[id] ?? "none";
30
+ }, [splitSig]);
31
+
14
32
  const chunks = useMemo(
15
- () => (source ? harvest(source.sections, source.pageTitle, source.images) : []),
16
- [source],
33
+ () => (source ? harvest(source.sections, source.pageTitle, source.images, splitAtFor) : []),
34
+ [source, splitAtFor],
17
35
  );
18
36
 
19
37
  const settingsMap = useMemo(() => {
@@ -22,7 +40,7 @@ export function useDocPages(input: {
22
40
  return map;
23
41
  }, [chunks, sections]);
24
42
 
25
- const { heights } = useTextMeasurements(chunks, settingsMap);
43
+ const { heights, textWidths } = useTextMeasurements(chunks, settingsMap);
26
44
 
27
45
  // `heights` is state set by an effect, so it always lags one render behind
28
46
  // `chunks`: when `source` changes shape (a block added/removed, a new
@@ -36,9 +54,9 @@ export function useDocPages(input: {
36
54
  return chunks.flatMap((c) => {
37
55
  const h = heights?.[c.id];
38
56
  if (c.textBlocks.length > 0 && (!h || h.length !== c.textBlocks.length)) return [];
39
- return paginateChunk(c, settingsMap[c.id], h ?? []);
57
+ return paginateChunk(c, settingsMap[c.id], h ?? [], DOC_GEOM, textWidths?.[c.id]);
40
58
  });
41
- }, [chunks, settingsMap, heights]);
59
+ }, [chunks, settingsMap, heights, textWidths]);
42
60
 
43
61
  // Mirrors the pages memo's per-chunk guard: still "measuring" until every
44
62
  // non-empty chunk has current heights, not just until `heights` is non-null.
@@ -1,5 +1,5 @@
1
1
  import { splitProseBlocks } from "./blocks";
2
- import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput } from "./types";
2
+ import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput, type SplitAt } from "./types";
3
3
 
4
4
  // Shape of the fields harvest reads, checked structurally at runtime — the
5
5
  // engine must tolerate any future portal section without crashing (allowlist).
@@ -61,20 +61,35 @@ export function harvest(
61
61
  sections: DocSectionInput[],
62
62
  pageTitle: string,
63
63
  imagesById: Record<string, DocImage>,
64
+ splitAtFor: (chunkId: string) => SplitAt = () => "none",
64
65
  ): Chunk[] {
65
66
  const chunks: Chunk[] = [];
66
- let current: Chunk = { id: PREAMBLE_ID, title: pageTitle, textBlocks: [], images: [] };
67
- let preamble = current;
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);
68
71
 
69
72
  for (const section of sections) {
73
+ const heading = str(asRecord(section.content).heading);
70
74
  if (section.type === "link_heading") {
71
- current = { id: section.id, title: str(asRecord(section.content).heading), textBlocks: [], images: [] };
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 };
72
82
  chunks.push(current);
73
83
  } else {
74
84
  harvestInto(current, section.type, section.content, imagesById);
75
85
  }
76
86
  }
77
87
 
78
- const preambleHasContent = preamble.textBlocks.length > 0 || preamble.images.length > 0;
79
- return preambleHasContent ? [preamble, ...chunks] : chunks;
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;
80
95
  }
@@ -11,6 +11,7 @@ export interface MasonryOpts {
11
11
  bodyHIn: number;
12
12
  gapIn: number;
13
13
  targetRowHeightIn: number;
14
+ rows?: "auto" | 1 | 2 | 3 | 4;
14
15
  }
15
16
 
16
17
  export interface MasonryBox<T extends Sized = Sized> {
@@ -57,6 +58,68 @@ function layoutRows<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover"
57
58
  return { boxes, used: used ?? images.length, endY: y };
58
59
  }
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
+
60
123
  function layoutColumns<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; colHeights: number[]; colW: number } {
61
124
  const n = o.columns;
62
125
  const colW = (o.bodyWIn - o.gapIn * (n - 1)) / n;
@@ -84,6 +147,6 @@ export function layoutMasonryPage<T extends Sized>(
84
147
  const r = layoutColumns(images, o, fits);
85
148
  return { boxes: r.boxes, used: r.used };
86
149
  }
87
- const r = layoutRows(images, o, fits);
150
+ const r = typeof o.rows === "number" ? layoutForcedRows(images, o, fits, o.rows) : layoutRows(images, o, fits);
88
151
  return { boxes: r.boxes, used: r.used };
89
152
  }
@@ -9,6 +9,7 @@ export interface DocGeom {
9
9
  gapIn: number;
10
10
  leftColWIn: number;
11
11
  colGutterIn: number;
12
+ minTextColIn: number;
12
13
  minImageRegionIn: number;
13
14
  masonryTargetRowIn: number;
14
15
  topTextMaxFraction: number;
@@ -22,6 +23,7 @@ export const DOC_GEOM: DocGeom = {
22
23
  gapIn: 0.15,
23
24
  leftColWIn: 4.6,
24
25
  colGutterIn: 0.4,
26
+ minTextColIn: 1,
25
27
  minImageRegionIn: 1.2,
26
28
  masonryTargetRowIn: 2.6,
27
29
  // Top text fills at most this fraction of the body height below the title
@@ -42,6 +44,9 @@ export interface DerivedPage {
42
44
  chunkId: string;
43
45
  title: string;
44
46
  cont: boolean;
47
+ // Sub-chunk context (absent on top-level pages) — see Chunk.
48
+ kicker?: string;
49
+ depth?: 1;
45
50
  textBlocks: TextBlock[];
46
51
  textRegion: Region | null;
47
52
  imageBoxes: ImageBox[];
@@ -69,7 +74,7 @@ function fitImages(
69
74
  yIn: region.yIn + r * (cellH + geom.gapIn),
70
75
  wIn: cellW,
71
76
  hIn: cellH,
72
- fit: "cover" as const,
77
+ fit: layout.fit,
73
78
  };
74
79
  });
75
80
  return { boxes, used };
@@ -78,6 +83,7 @@ function fitImages(
78
83
  const r = layoutMasonryPage(images, {
79
84
  mode: layout.flow,
80
85
  columns: layout.columns,
86
+ rows: layout.rows,
81
87
  bodyWIn: region.wIn,
82
88
  bodyHIn: region.hIn,
83
89
  gapIn: geom.gapIn,
@@ -99,6 +105,7 @@ export function paginateChunk(
99
105
  settings: SectionSettings,
100
106
  textHeightsIn: number[],
101
107
  geom: DocGeom = DOC_GEOM,
108
+ naturalTextWIn?: number,
102
109
  ): DerivedPage[] {
103
110
  if (settings.excluded) return [];
104
111
  if (chunk.textBlocks.length !== textHeightsIn.length) {
@@ -106,6 +113,15 @@ export function paginateChunk(
106
113
  }
107
114
  if (chunk.textBlocks.length === 0 && chunk.images.length === 0) return [];
108
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
+
109
125
  const pages: DerivedPage[] = [];
110
126
  let ti = 0;
111
127
  let ii = 0;
@@ -114,7 +130,7 @@ export function paginateChunk(
114
130
  while (ti < chunk.textBlocks.length || ii < chunk.images.length) {
115
131
  const cont = pages.length > 0;
116
132
  const warnings: string[] = [];
117
- const textW = settings.textPlacement === "top" ? geom.bodyWIn : geom.leftColWIn;
133
+ const textW = settings.textPlacement === "top" ? geom.bodyWIn : colWIn;
118
134
 
119
135
  // 1. Greedy text fill. Top text is capped at half the body so a text-heavy
120
136
  // section still leaves room for images below; leftColumn keeps full height.
@@ -158,9 +174,9 @@ export function paginateChunk(
158
174
  let region: Region;
159
175
  if (settings.textPlacement === "leftColumn" && settings.imagePlacement === "right" && placed.length > 0) {
160
176
  region = {
161
- xIn: geom.leftColWIn + geom.colGutterIn,
177
+ xIn: colWIn + geom.colGutterIn,
162
178
  yIn: top,
163
- wIn: geom.bodyWIn - geom.leftColWIn - geom.colGutterIn,
179
+ wIn: geom.bodyWIn - colWIn - geom.colGutterIn,
164
180
  hIn: geom.bodyHIn - top,
165
181
  };
166
182
  } else {
@@ -185,6 +201,8 @@ export function paginateChunk(
185
201
  chunkId: chunk.id,
186
202
  title: cont ? `${chunk.title} Cont.` : chunk.title,
187
203
  cont,
204
+ kicker: chunk.kicker,
205
+ depth: chunk.depth,
188
206
  textBlocks: placed,
189
207
  textRegion,
190
208
  imageBoxes: boxes,
@@ -198,6 +216,8 @@ export function paginateChunk(
198
216
  chunkId: chunk.id,
199
217
  title: cont ? `${chunk.title} Cont.` : chunk.title,
200
218
  cont,
219
+ kicker: chunk.kicker,
220
+ depth: chunk.depth,
201
221
  textBlocks: placed,
202
222
  textRegion,
203
223
  imageBoxes: boxes,
@@ -12,12 +12,18 @@ export const sectionSettingsSchema = z.object({
12
12
  // When true, text that overflows the first page is dropped instead of
13
13
  // flowing onto Cont. pages (images still paginate normally).
14
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"),
15
18
  imageLayout: z
16
19
  .discriminatedUnion("mode", [
17
20
  z.object({
18
21
  mode: z.literal("grid"),
19
22
  rows: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2),
20
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"),
21
27
  }),
22
28
  z.object({
23
29
  mode: z.literal("masonry"),
@@ -25,9 +31,14 @@ export const sectionSettingsSchema = z.object({
25
31
  columns: z
26
32
  .union([z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)])
27
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"),
28
39
  }),
29
40
  ])
30
- .default({ mode: "masonry", flow: "rows", columns: 3 }),
41
+ .default({ mode: "masonry", flow: "rows", columns: 3, rows: "auto" }),
31
42
  });
32
43
 
33
44
  export type SectionSettings = z.infer<typeof sectionSettingsSchema>;
@@ -7,6 +7,11 @@
7
7
 
8
8
  export const PREAMBLE_ID = "__preamble";
9
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
+
10
15
  // Breadcrumb metadata shown in the page chrome. Single definition — the
11
16
  // harness carried this shape in two places (deck/DeckPage.tsx's `DeckMeta`
12
17
  // and lib/deck/schema.ts's zod-inferred `DeckMeta`); the engine only needs
@@ -39,12 +44,17 @@ export type TextBlock =
39
44
  | { kind: "heading"; level: 2 | 3; text: string }
40
45
  | { kind: "prose"; html: string };
41
46
 
42
- // A link-heading chunk: everything between one link_heading and the next.
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.
43
49
  export interface Chunk {
44
- id: string; // link_heading section id, or PREAMBLE_ID
50
+ id: string; // boundary heading's section id, or PREAMBLE_ID
45
51
  title: string;
46
52
  textBlocks: TextBlock[];
47
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;
48
58
  }
49
59
 
50
60
  export interface DocSourceContent {
@@ -178,6 +178,15 @@
178
178
  margin: 0;
179
179
  height: 0.9in; /* = DOC_GEOM.titleBandIn; keep in sync */
180
180
  }
181
+ /* Sub-page context line inside the title band (uppercase inherits from
182
+ .pdoc-title). Sized to sit with the title within the fixed 0.9in band. */
183
+ .pdoc-kicker {
184
+ display: block;
185
+ font-size: 9pt;
186
+ letter-spacing: 0.12em;
187
+ color: #999999;
188
+ margin-bottom: 0.05in;
189
+ }
181
190
  .pdoc-text { position: absolute; }
182
191
  .pdoc-h2, .pdoc-h3 {
183
192
  font-family: "Kapra Neue Expanded", Helvetica, Arial, sans-serif;