@drawnagency/primitives 0.1.89 → 0.1.91

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 (93) hide show
  1. package/dist/{chunk-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
  2. package/dist/{chunk-VK5KNH73.js → chunk-AGWE2LJG.js} +2 -2
  3. package/dist/{chunk-2QXJ2KGB.js → chunk-KBQNJZUX.js} +1 -1
  4. package/dist/{chunk-WCBYXNAG.js → chunk-KMQZYORS.js} +1 -0
  5. package/dist/{chunk-Y24T75HT.js → chunk-MK7YB2A4.js} +1 -1
  6. package/dist/{chunk-QFV5TSPO.js → chunk-SIYQ37QY.js} +12 -4
  7. package/dist/{chunk-J75XGXYD.js → chunk-XKRQMTE4.js} +9 -0
  8. package/dist/{chunk-L2MAJKKC.js → chunk-XUIJHYKI.js} +2 -2
  9. package/dist/{chunk-MVX4ULYE.js → chunk-ZLG7S54V.js} +241 -82
  10. package/dist/components/chat/ChatPanel.d.ts +14 -6
  11. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  12. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  13. package/dist/components/editor/AudienceIndicator.d.ts +6 -0
  14. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  15. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
  16. package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
  17. package/dist/components/editor/DragHandle.d.ts +3 -1
  18. package/dist/components/editor/DragHandle.d.ts.map +1 -1
  19. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  20. package/dist/components/editor/StatusIndicator.d.ts +7 -0
  21. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  22. package/dist/components/editor/index.js +98 -76
  23. package/dist/components/primitives/index.js +3 -3
  24. package/dist/components/sections/Container/Container.d.ts +3 -2
  25. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  26. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
  27. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
  28. package/dist/components/sections/Container/index.d.ts +8 -2
  29. package/dist/components/sections/Container/index.d.ts.map +1 -1
  30. package/dist/components/sections/SectionLayout.d.ts +3 -1
  31. package/dist/components/sections/SectionLayout.d.ts.map +1 -1
  32. package/dist/components/sections/ViewRenderer.d.ts +3 -1
  33. package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
  34. package/dist/components/sections/all-sections.d.ts +8 -2
  35. package/dist/components/sections/all-sections.d.ts.map +1 -1
  36. package/dist/components/sections/brand-guide.js +3 -3
  37. package/dist/components/sections/core-sections.d.ts +8 -2
  38. package/dist/components/sections/core-sections.d.ts.map +1 -1
  39. package/dist/components/sections/register-schemas.js +6 -6
  40. package/dist/components/sections/register.js +6 -6
  41. package/dist/components/shared/Fab.d.ts +37 -0
  42. package/dist/components/shared/Fab.d.ts.map +1 -0
  43. package/dist/components/shared/Tooltip.d.ts +8 -1
  44. package/dist/components/shared/Tooltip.d.ts.map +1 -1
  45. package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
  46. package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
  47. package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
  48. package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
  49. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  50. package/dist/components/shell/EditorContext.d.ts +3 -0
  51. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  52. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  53. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  54. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  55. package/dist/index.js +6 -4
  56. package/dist/lib/authoring-defaults.d.ts +17 -0
  57. package/dist/lib/authoring-defaults.d.ts.map +1 -0
  58. package/dist/lib/container-grid.d.ts +6 -0
  59. package/dist/lib/container-grid.d.ts.map +1 -1
  60. package/dist/lib/index.js +3 -3
  61. package/dist/lib/migrate-sections-transform.js +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/schemas/index.js +4 -2
  64. package/dist/schemas/site-config.d.ts +6 -0
  65. package/dist/schemas/site-config.d.ts.map +1 -1
  66. package/package.json +1 -1
  67. package/src/components/chat/ChatPanel.tsx +51 -19
  68. package/src/components/chat/ChatWidget.tsx +113 -51
  69. package/src/components/editor/AudienceIndicator.tsx +50 -37
  70. package/src/components/editor/ChildBlockWrapper.tsx +8 -7
  71. package/src/components/editor/ColSpanControl.tsx +14 -11
  72. package/src/components/editor/DragHandle.tsx +5 -2
  73. package/src/components/editor/PagesModal.tsx +10 -10
  74. package/src/components/editor/SectionWrapper.tsx +32 -21
  75. package/src/components/editor/StatusIndicator.tsx +37 -24
  76. package/src/components/sections/Container/Container.tsx +185 -43
  77. package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
  78. package/src/components/sections/Container/index.tsx +45 -20
  79. package/src/components/sections/SectionLayout.tsx +13 -3
  80. package/src/components/sections/ViewRenderer.tsx +11 -2
  81. package/src/components/shared/Fab.tsx +57 -0
  82. package/src/components/shared/Tooltip.tsx +18 -2
  83. package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
  84. package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
  85. package/src/components/shell/BugReportFAB.tsx +3 -7
  86. package/src/components/shell/EditorContext.tsx +5 -1
  87. package/src/components/shell/EditorShell.tsx +80 -25
  88. package/src/components/shell/SiteSettingsChatbot.tsx +2 -1
  89. package/src/hooks/useBuildStatus.ts +35 -4
  90. package/src/lib/authoring-defaults.ts +42 -0
  91. package/src/lib/container-grid.ts +14 -0
  92. package/src/lib/migrate-sections-transform.ts +5 -3
  93. package/src/schemas/site-config.ts +1 -1
@@ -2,7 +2,7 @@ import { type ComponentType, type ReactNode } from "react";
2
2
  import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
3
3
  import { getSection } from "../../../lib/registry";
4
4
  import { cn } from "../../../lib/cn";
5
- import { containerGridClass, colSpanClass, spacerHiddenClass } from "../../../lib/container-grid";
5
+ import { containerGridClass, colSpanClass, spacerHiddenClass, masonryColumnsClass } from "../../../lib/container-grid";
6
6
  import { ChildBlockWrapper } from "../../editor/ChildBlockWrapper";
7
7
  import { SettingsForm, type SettingsFormResult } from "../../editor/SettingsForm";
8
8
  import { useBlockDnd, useBlockDropZone } from "../../../hooks/useBlockDnd";
@@ -19,6 +19,7 @@ import type { Section } from "../../../schemas/sections";
19
19
  // Stable allowedEdges arrays — must not be re-created per render (the dnd hooks
20
20
  // re-subscribe whenever this array identity changes).
21
21
  const ROW_EDGES: Edge[] = ["left", "right"];
22
+ // Masonry stacks items vertically (CSS multi-column), so its drag edges are top/bottom.
22
23
  const COL_EDGES: Edge[] = ["top", "bottom"];
23
24
 
24
25
  type ChildComponent = ComponentType<{
@@ -34,7 +35,6 @@ interface ContainerChildProps {
34
35
  index: number;
35
36
  child: Section;
36
37
  columns: number;
37
- flow: string;
38
38
  cellClass: string | undefined;
39
39
  label: string;
40
40
  hasSettings: boolean;
@@ -49,6 +49,9 @@ interface ContainerChildProps {
49
49
  onOpenSettings?: () => void;
50
50
  onChildContentChange?: (content: unknown) => void;
51
51
  openModal?: (title: string, content: ReactNode) => void;
52
+ /** Drag/drop hitbox edges: ["left","right"] in grid (row flow), ["top","bottom"] in masonry
53
+ * (CSS multi-column stacks items vertically). Defaults to ROW_EDGES. */
54
+ allowedEdges?: Edge[];
52
55
  }
53
56
 
54
57
  /**
@@ -61,7 +64,6 @@ function ContainerChild({
61
64
  index,
62
65
  child,
63
66
  columns,
64
- flow,
65
67
  cellClass,
66
68
  label,
67
69
  hasSettings,
@@ -75,6 +77,7 @@ function ContainerChild({
75
77
  onOpenSettings,
76
78
  onChildContentChange,
77
79
  openModal,
80
+ allowedEdges = ROW_EDGES,
78
81
  }: ContainerChildProps) {
79
82
  const isContainer = Array.isArray((child.content as { children?: unknown })?.children);
80
83
  const { dragRef, handleRef, closestEdge, isDragging } = useBlockDnd({
@@ -82,7 +85,7 @@ function ContainerChild({
82
85
  containerId,
83
86
  index,
84
87
  isContainer,
85
- allowedEdges: flow === "column" ? COL_EDGES : ROW_EDGES,
88
+ allowedEdges,
86
89
  enabled: true,
87
90
  });
88
91
 
@@ -120,8 +123,9 @@ function ContainerChild({
120
123
  }
121
124
 
122
125
  interface ContainerContent {
126
+ layout: "grid" | "masonry";
123
127
  columns: number;
124
- flow: string;
128
+ equalizeRowHeight: boolean;
125
129
  childDefaults?: Record<string, unknown>;
126
130
  children: Section[];
127
131
  }
@@ -175,6 +179,44 @@ function EmptyColumnCell({
175
179
  );
176
180
  }
177
181
 
182
+ /**
183
+ * A masonry insertion drop target — not draggable itself, just a landing zone so new
184
+ * blocks can be dragged in and an empty masonry container is seedable. Unlike
185
+ * `EmptyColumnCell` (grid column-placement, `toColumn`), this OMITS `toColumn` so the
186
+ * central monitor uses the edge→`resolveDropIndex` insert path (masonry has no columns
187
+ * to target — it's a linear reorder like any other block list).
188
+ */
189
+ function MasonryDropZone({
190
+ containerId,
191
+ index,
192
+ testId,
193
+ }: {
194
+ containerId: string;
195
+ index: number;
196
+ testId: string;
197
+ }) {
198
+ const { dropRef, closestEdge } = useBlockDropZone({
199
+ containerId,
200
+ index,
201
+ allowedEdges: COL_EDGES,
202
+ enabled: true,
203
+ });
204
+ const active = closestEdge !== null;
205
+ return (
206
+ <div
207
+ ref={dropRef}
208
+ data-masonry-dropzone
209
+ data-testid={testId}
210
+ className={cn(
211
+ "mb-8 flex min-h-20 items-center justify-center rounded-md border-2 border-dashed break-inside-avoid text-xs text-base-contrast-light/50",
212
+ active ? "border-primary" : "border-base-200",
213
+ )}
214
+ >
215
+ {index === 0 ? "Empty container — drag items here or use the + button" : "Drop here to add"}
216
+ </div>
217
+ );
218
+ }
219
+
178
220
  function newId(): string {
179
221
  return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
180
222
  ? crypto.randomUUID()
@@ -182,7 +224,7 @@ function newId(): string {
182
224
  }
183
225
 
184
226
  export function Container({ content, isEditMode, onChange, openModal }: ContainerProps) {
185
- const { columns, flow, childDefaults, children } = content.content;
227
+ const { layout, columns, equalizeRowHeight, childDefaults, children } = content.content;
186
228
 
187
229
  const editable = isEditMode && !!onChange;
188
230
 
@@ -218,8 +260,138 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
218
260
  "grid",
219
261
  "gap-8",
220
262
  containerGridClass[columns] ?? "grid-cols-1",
221
- flow === "column" && "grid-flow-col",
263
+ equalizeRowHeight && "items-stretch",
222
264
  );
265
+ const cellStretch = equalizeRowHeight ? "h-full [&>*]:h-full" : undefined;
266
+
267
+ // Per-child settings-modal opener, shared by the grid and masonry edit paths.
268
+ function buildOnOpenSettings(child: Section, index: number): (() => void) | undefined {
269
+ const def = getSection(child.type);
270
+ if (!def) return undefined;
271
+ const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
272
+ if (!hasSettings || !openModal) return undefined;
273
+ const childOptions = "options" in child ? (child.options as Record<string, unknown>) : undefined;
274
+ return () => {
275
+ const childOpts = childOptions ?? {};
276
+ const childContent = (child.content ?? {}) as Record<string, unknown>;
277
+ // Seed the form from merged content+options so content-target fields (e.g.
278
+ // media.link, button.link/download) round-trip instead of re-emitting at their
279
+ // default and clobbering the stored value. Mirrors EditorShell's top-level seed.
280
+ const seed = { ...childContent, ...childOpts };
281
+ const applyResult = (result: SettingsFormResult) =>
282
+ updateChild(index, {
283
+ ...child,
284
+ content: { ...(child.content as object), ...result.content },
285
+ options: { ...childOpts, ...result.options },
286
+ });
287
+ if (def.settingsForm) {
288
+ const CustomForm = def.settingsForm;
289
+ openModal(`${def.label} Settings`, <CustomForm {...seed} onChange={applyResult} />);
290
+ } else if (def.settings) {
291
+ openModal(
292
+ `${def.label} Settings`,
293
+ <SettingsForm
294
+ schema={def.settings}
295
+ values={seed}
296
+ onChange={applyResult}
297
+ tabs={def.settingsTabs}
298
+ />,
299
+ );
300
+ }
301
+ };
302
+ }
303
+
304
+ if (layout === "masonry") {
305
+ // Spacers are grid-only bookkeeping (empty-column tracking); masonry has no columns
306
+ // to pad, so exclude them from the layout entirely. `laidOut` carries the TRUE array
307
+ // index alongside each surviving child, so keys/data-child-index/DnD/delete/duplicate
308
+ // all still operate on the real `children` position, not the filtered position.
309
+ const laidOut = children
310
+ .map((child, index) => ({ child, index }))
311
+ .filter(({ child }) => !isSpacer(child));
312
+ const masonryClass = cn("gap-8", masonryColumnsClass[columns] ?? "columns-1");
313
+
314
+ // View mode (zero-JS): real DOM flow via CSS multi-column — items lay out at
315
+ // their natural height, no aspect-ratio math, no absolute positioning.
316
+ if (!editable) {
317
+ if (laidOut.length === 0) return null;
318
+ return (
319
+ <div className="@container">
320
+ <div data-masonry className={masonryClass}>
321
+ {laidOut.map(({ child, index }) => {
322
+ const def = getSection(child.type);
323
+ if (!def) return null;
324
+ const Child = def.component;
325
+ const childOptions =
326
+ "options" in child ? (child.options as Record<string, unknown>) : undefined;
327
+ const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
328
+ return (
329
+ <div
330
+ key={child.id ?? index}
331
+ data-masonry-box
332
+ data-child-index={index}
333
+ className="mb-8 break-inside-avoid"
334
+ >
335
+ <Child content={child} options={mergedOptions} isEditMode={isEditMode} />
336
+ </div>
337
+ );
338
+ })}
339
+ </div>
340
+ </div>
341
+ );
342
+ }
343
+ // Editable masonry: same multi-column flow (WYSIWYG), each child cell wrapped in
344
+ // ContainerChild for drag/reorder/delete/duplicate/settings with VERTICAL drag
345
+ // edges (multi-column stacks items vertically), plus insertion drop zones.
346
+ if (laidOut.length === 0) {
347
+ return (
348
+ <div className="@container">
349
+ <div data-masonry className={masonryClass}>
350
+ <MasonryDropZone containerId={container.id} index={0} testId="masonry-empty" />
351
+ </div>
352
+ </div>
353
+ );
354
+ }
355
+ return (
356
+ <div className="@container">
357
+ <div data-masonry className={masonryClass}>
358
+ {laidOut.map(({ child, index }, k) => {
359
+ const def = getSection(child.type);
360
+ if (!def) return null;
361
+ const childOptions =
362
+ "options" in child ? (child.options as Record<string, unknown>) : undefined;
363
+ const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
364
+ const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
365
+ return (
366
+ <div key={child.id ?? index} data-masonry-box className="mb-8 break-inside-avoid">
367
+ <ContainerChild
368
+ containerId={container.id}
369
+ index={index}
370
+ child={child}
371
+ columns={1 /* hides ColSpanControl (its columns<=1 guard) */}
372
+ cellClass={undefined /* mb-8 break-inside-avoid lives on the outer cell above */}
373
+ label={def.label}
374
+ hasSettings={hasSettings}
375
+ mergedOptions={mergedOptions}
376
+ isEditMode={isEditMode}
377
+ clearSectionChrome={k === 0 /* first laid-out cell (render order), not array index */}
378
+ ChildComp={def.component as ChildComponent}
379
+ onDelete={() => emit(removeChildAt(container, index).container)}
380
+ onDuplicate={() => emit(duplicateChildAt(container, index, newId))}
381
+ onColSpanChange={() => {}}
382
+ onOpenSettings={buildOnOpenSettings(child, index)}
383
+ onChildContentChange={(c: unknown) => updateChild(index, c)}
384
+ openModal={openModal}
385
+ allowedEdges={COL_EDGES}
386
+ />
387
+ </div>
388
+ );
389
+ })}
390
+ <MasonryDropZone containerId={container.id} index={children.length} testId="masonry-append" />
391
+ </div>
392
+ </div>
393
+ );
394
+ }
223
395
 
224
396
  // --- View mode: zero-JS output, identical to the Plan 3 viewer. ---
225
397
  if (!editable) {
@@ -237,6 +409,7 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
237
409
  const cellClass = cn(
238
410
  colSpanClass[columns]?.[Math.min(span, columns)] || undefined,
239
411
  child.type === "spacer" && spacerHiddenClass[columns],
412
+ cellStretch,
240
413
  );
241
414
  return (
242
415
  <div key={child.id ?? index} className={cellClass}>
@@ -281,43 +454,13 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
281
454
  const span = child.layout?.colSpan ?? 1;
282
455
  const childOptions = "options" in child ? (child.options as Record<string, unknown>) : undefined;
283
456
  const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
284
- const cellClass = colSpanClass[columns]?.[Math.min(span, columns)] || undefined;
457
+ const cellClass = cn(
458
+ colSpanClass[columns]?.[Math.min(span, columns)] || undefined,
459
+ cellStretch,
460
+ );
285
461
 
286
462
  const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
287
- const onOpenSettings =
288
- hasSettings && openModal
289
- ? () => {
290
- const childOpts = childOptions ?? {};
291
- const childContent = (child.content ?? {}) as Record<string, unknown>;
292
- // Seed the form from merged content+options so content-target fields (e.g.
293
- // media.link, button.link/download) round-trip instead of re-emitting at their
294
- // default and clobbering the stored value. Mirrors EditorShell's top-level seed.
295
- const seed = { ...childContent, ...childOpts };
296
- const applyResult = (result: SettingsFormResult) =>
297
- updateChild(index, {
298
- ...child,
299
- content: { ...(child.content as object), ...result.content },
300
- options: { ...childOpts, ...result.options },
301
- });
302
- if (def.settingsForm) {
303
- const CustomForm = def.settingsForm;
304
- openModal(
305
- `${def.label} Settings`,
306
- <CustomForm {...seed} onChange={applyResult} />,
307
- );
308
- } else if (def.settings) {
309
- openModal(
310
- `${def.label} Settings`,
311
- <SettingsForm
312
- schema={def.settings}
313
- values={seed}
314
- onChange={applyResult}
315
- tabs={def.settingsTabs}
316
- />,
317
- );
318
- }
319
- }
320
- : undefined;
463
+ const onOpenSettings = buildOnOpenSettings(child, index);
321
464
 
322
465
  return (
323
466
  <ContainerChild
@@ -326,7 +469,6 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
326
469
  index={index}
327
470
  child={child}
328
471
  columns={columns}
329
- flow={flow}
330
472
  cellClass={cellClass}
331
473
  label={def.label}
332
474
  hasSettings={hasSettings}
@@ -9,14 +9,23 @@ interface ChildLike {
9
9
  options?: Record<string, unknown>;
10
10
  }
11
11
 
12
+ type ContainerLayout = "grid" | "masonry";
13
+
12
14
  interface ContainerSettingsFormProps {
13
15
  columns?: number;
14
- flow?: string;
16
+ layout?: ContainerLayout;
17
+ equalizeRowHeight?: boolean;
15
18
  childDefaults?: Record<string, unknown>;
16
19
  children?: ChildLike[]; // the container's child blocks (DATA, not React nodes)
17
20
  onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
18
21
  }
19
22
 
23
+ interface ContainerLayoutState {
24
+ layout: ContainerLayout;
25
+ columns: number;
26
+ equalizeRowHeight: boolean;
27
+ }
28
+
20
29
  /** Union of inheritable option fields across the child types currently present. */
21
30
  function inheritableSchema(children: ChildLike[]): SettingsSchema {
22
31
  const out: SettingsSchema = {};
@@ -33,46 +42,64 @@ function inheritableSchema(children: ChildLike[]): SettingsSchema {
33
42
 
34
43
  export function ContainerSettingsForm({
35
44
  columns = 1,
36
- flow = "row",
45
+ layout: layoutProp = "grid",
46
+ equalizeRowHeight = false,
37
47
  childDefaults = {},
38
48
  children = [],
39
49
  onChange,
40
50
  }: ContainerSettingsFormProps) {
41
- const [layout, setLayout] = useState({ columns, flow });
51
+ const [layout, setLayout] = useState<ContainerLayoutState>({
52
+ layout: layoutProp,
53
+ columns,
54
+ equalizeRowHeight,
55
+ });
42
56
  const [defaults, setDefaults] = useState<Record<string, unknown>>(childDefaults);
43
57
 
44
58
  const childSchema = inheritableSchema(children);
45
59
  const hasChildDefaults = Object.keys(childSchema).length > 0;
46
60
 
47
- function emit(
48
- nextLayout: { columns: number; flow: string },
49
- nextDefaults: Record<string, unknown>,
50
- ) {
61
+ function emit(nextLayout: ContainerLayoutState, nextDefaults: Record<string, unknown>) {
51
62
  onChange({ content: { ...nextLayout, childDefaults: nextDefaults }, options: {} });
52
63
  }
53
64
 
54
65
  const layoutSchema: SettingsSchema = {
66
+ layout: {
67
+ type: "select",
68
+ label: "Layout",
69
+ default: layout.layout,
70
+ target: "content",
71
+ options: [
72
+ { label: "Grid", value: "grid" },
73
+ { label: "Masonry (image gallery)", value: "masonry" },
74
+ ],
75
+ },
55
76
  columns: {
56
77
  type: "select",
57
78
  label: "Columns",
58
79
  default: String(layout.columns),
59
80
  target: "content",
60
81
  coerce: "number",
61
- options: Array.from({ length: MAX_CONTAINER_COLUMNS }, (_, i) => ({
62
- label: String(i + 1),
63
- value: String(i + 1),
64
- })),
65
- },
66
- flow: {
67
- type: "select",
68
- label: "Flow",
69
- default: layout.flow,
70
- target: "content",
71
- options: [
72
- { label: "Rows (wrap across)", value: "row" },
73
- { label: "Columns (fill down)", value: "column" },
74
- ],
82
+ // Masonry's CSS multi-column flow needs at least 2 columns to read as a
83
+ // gallery (1 column would just be a plain stack, same as grid), so floor
84
+ // the options at 2 there; grid still allows 1 (a single-column stack).
85
+ options: Array.from(
86
+ { length: layout.layout === "masonry" ? MAX_CONTAINER_COLUMNS - 1 : MAX_CONTAINER_COLUMNS },
87
+ (_, i) => {
88
+ const n = layout.layout === "masonry" ? i + 2 : i + 1;
89
+ return { label: String(n), value: String(n) };
90
+ },
91
+ ),
75
92
  },
93
+ ...(layout.layout === "grid"
94
+ ? {
95
+ equalizeRowHeight: {
96
+ type: "checkbox" as const,
97
+ label: "Equalize row height",
98
+ default: layout.equalizeRowHeight,
99
+ target: "content" as const,
100
+ },
101
+ }
102
+ : {}),
76
103
  };
77
104
 
78
105
  return (
@@ -80,13 +107,26 @@ export function ContainerSettingsForm({
80
107
  <fieldset className="flex flex-col gap-4">
81
108
  <legend className="mb-2 text-sm font-semibold text-base-contrast">Layout</legend>
82
109
  <SettingsForm
110
+ // Remount on layout-mode switch: grid contributes an extra conditional
111
+ // field (equalizeRowHeight) that masonry doesn't, and SettingsForm only
112
+ // resolves its initial values on mount.
113
+ key={layout.layout}
83
114
  schema={layoutSchema}
84
- values={{ columns: String(layout.columns), flow: layout.flow }}
115
+ values={{
116
+ layout: layout.layout,
117
+ columns: String(layout.columns),
118
+ equalizeRowHeight: layout.equalizeRowHeight,
119
+ }}
85
120
  onChange={(result) => {
86
- const next = {
121
+ const next: ContainerLayoutState = {
122
+ layout: (result.content.layout as ContainerLayout | undefined) ?? layout.layout,
87
123
  columns: Number(result.content.columns ?? layout.columns),
88
- flow: String(result.content.flow ?? layout.flow),
124
+ equalizeRowHeight: (result.content.equalizeRowHeight as boolean | undefined) ?? layout.equalizeRowHeight,
89
125
  };
126
+ // Masonry's multi-column flow needs ≥2 columns (1 would render a plain
127
+ // stack while the floored Columns select shows "2") — keep state in sync
128
+ // with the select when flipping to masonry from a single-column grid.
129
+ if (next.layout === "masonry" && next.columns < 2) next.columns = 2;
90
130
  setLayout(next);
91
131
  emit(next, defaults);
92
132
  }}
@@ -1,4 +1,4 @@
1
- import { defineSection } from "../../../lib/registry";
1
+ import { defineSection, getSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
3
  import { LayoutTemplate } from "lucide-react";
4
4
  import { getSectionSchema } from "../../../schemas/sections";
@@ -11,19 +11,27 @@ const schema = z.object({
11
11
  type: z.literal("container"),
12
12
  content: z
13
13
  .object({
14
+ layout: z
15
+ .enum(["grid", "masonry"])
16
+ .describe(
17
+ "Layout engine: 'grid' arranges children in fixed columns with per-child column spans; 'masonry' flows children through a CSS multi-column gallery at their natural height (no column spans).",
18
+ )
19
+ .default("grid"),
14
20
  columns: z
15
21
  .number()
16
22
  .int()
17
23
  .min(1)
18
24
  .max(MAX_CONTAINER_COLUMNS)
19
- .describe("Number of columns in the grid. Children flow across this many columns.")
25
+ .describe(
26
+ "Grid: number of columns children flow across. Masonry: number of CSS multi-columns children flow through.",
27
+ )
20
28
  .default(1),
21
- flow: z
22
- .enum(["row", "column"])
29
+ equalizeRowHeight: z
30
+ .boolean()
23
31
  .describe(
24
- "Layout direction: 'row' (children flow left-to-right across columns) or 'column' (stacked).",
32
+ "Grid only: stretch every child to the height of the tallest child in its row (best-effort — applies to children whose root element can stretch, e.g. media/cards). Ignored in masonry.",
25
33
  )
26
- .default("row"),
34
+ .default(false),
27
35
  childDefaults: z
28
36
  .record(z.string(), z.unknown())
29
37
  .describe("Optional default option values applied to child sections (inheritable settings only).")
@@ -32,18 +40,29 @@ const schema = z.object({
32
40
  // time, by which point every section schema (incl. container) is registered.
33
41
  children: z.array(z.lazy(() => getSectionSchema())).default([]),
34
42
  })
35
- // Bound each child's colSpan to the available columns (spec §4.2). Non-rejecting
36
- // and idempotent: a hand-edited/migrated file can't persist a phantom span, and
37
- // a column-count reduction auto-clamps on the next save.
38
- .transform((c) => ({
39
- ...c,
40
- children: c.children.map((child) =>
41
- (child as { layout?: { colSpan?: number } }).layout?.colSpan &&
42
- (child as { layout?: { colSpan?: number } }).layout!.colSpan! > c.columns
43
- ? { ...child, layout: { ...(child as { layout?: { colSpan?: number } }).layout, colSpan: c.columns } }
44
- : child,
45
- ),
46
- })),
43
+ // Grid: clamp each child's colSpan to the available columns (idempotent, non-rejecting).
44
+ // Masonry: strip the per-child layout envelope entirely (masonry has no column spans).
45
+ .transform((c) => {
46
+ if (c.layout === "masonry") {
47
+ return {
48
+ ...c,
49
+ children: c.children.map((child) => {
50
+ if (child.layout === undefined) return child;
51
+ const { layout: _drop, ...rest } = child;
52
+ return rest;
53
+ }),
54
+ };
55
+ }
56
+ return {
57
+ ...c,
58
+ children: c.children.map((child) =>
59
+ (child as { layout?: { colSpan?: number } }).layout?.colSpan &&
60
+ (child as { layout?: { colSpan?: number } }).layout!.colSpan! > c.columns
61
+ ? { ...child, layout: { ...(child as { layout?: { colSpan?: number } }).layout, colSpan: c.columns } }
62
+ : child,
63
+ ),
64
+ };
65
+ }),
47
66
  });
48
67
 
49
68
  export default defineSection({
@@ -52,14 +71,20 @@ export default defineSection({
52
71
  icon: <LayoutTemplate size={18} />,
53
72
  schema,
54
73
  authoringNote:
55
- "A layout wrapper that arranges child sections into columns/rows. Use to place blocks side by side or build multi-column layouts; set columns and add children.",
74
+ "A layout wrapper for child sections. 'grid' arranges children into a fixed number of columns (each child can span multiple columns) with an optional equal-row-height toggle; 'masonry' flows children through a CSS multi-column gallery at their natural height (no column spans). Set layout, columns, and add children.",
56
75
  component: ({ content, onChange, isEditMode, openModal }) => (
57
76
  <Container content={content as never} isEditMode={!!isEditMode} onChange={onChange as never} openModal={openModal} />
58
77
  ),
59
78
  defaults: () => ({
60
79
  type: "container" as const,
61
- content: { columns: 1, flow: "row" as const, children: [] },
80
+ content: { layout: "grid" as const, columns: 1, equalizeRowHeight: false, children: [] },
62
81
  }),
63
82
  getLabel: (content) => `Container (${pluralize(content.content.children.length, "item")})`,
83
+ // Aggregate child thumbnails so the reorder-sections modal shows a stacked media
84
+ // preview for a container (restores the retired media_grid preview). Each child is a
85
+ // full section block; delegate to its own type's getThumbnails. The modal caps the
86
+ // stack at 3 + a "+N" badge and resolves each imageId → src itself.
87
+ getThumbnails: (content) =>
88
+ content.content.children.flatMap((child) => getSection(child.type)?.getThumbnails?.(child) ?? []),
64
89
  settingsForm: ContainerSettingsForm,
65
90
  });
@@ -15,14 +15,24 @@ interface Props {
15
15
  type: string;
16
16
  status?: string;
17
17
  dimNonPublished?: boolean;
18
+ /** Unconditional dim, e.g. audience preview: the selected audience can't see this section. */
19
+ dimmed?: boolean;
18
20
  children: ReactNode;
19
21
  }
20
22
 
21
- export function SectionLayout({ type, status, dimNonPublished, children }: Props) {
22
- const isDimmed = dimNonPublished && status && status !== "live";
23
+ export function SectionLayout({ type, status, dimNonPublished, dimmed, children }: Props) {
24
+ const statusDimmed = dimNonPublished && status && status !== "live";
23
25
 
24
26
  return (
25
- <div className={cn("section-wrapper group", getSectionCategory(type), isDimmed && "opacity-50")}>
27
+ <div
28
+ className={cn(
29
+ "section-wrapper group",
30
+ getSectionCategory(type),
31
+ // Audience-preview dim is strong but recovers on hover so the section
32
+ // stays easy to edit; the view-mode status dim is display-only at 50%.
33
+ dimmed ? "opacity-15 transition-opacity duration-200 hover:opacity-100" : statusDimmed && "opacity-50",
34
+ )}
35
+ >
26
36
  {children}
27
37
  </div>
28
38
  );
@@ -3,13 +3,16 @@ import { getSection } from "../../lib/registry";
3
3
 
4
4
  ensureSectionsRegistered();
5
5
  import { SectionLayout } from "./SectionLayout";
6
+ import { audiencePasses } from "../../lib/pages";
6
7
  import type { LoadedSection } from "../../lib/loader";
7
8
 
8
9
  interface Props {
9
10
  sections: LoadedSection[];
11
+ /** Audience-preview mode: dim sections this audience can't access. */
12
+ previewAudience?: string | null;
10
13
  }
11
14
 
12
- export default function ViewRenderer({ sections }: Props) {
15
+ export default function ViewRenderer({ sections, previewAudience = null }: Props) {
13
16
  return (
14
17
  <>
15
18
  {sections.map(({ section, meta }) => {
@@ -17,7 +20,13 @@ export default function ViewRenderer({ sections }: Props) {
17
20
  if (!def) return null;
18
21
  const Component = def.component;
19
22
  return (
20
- <SectionLayout key={section.id} type={section.type} status={meta.status} dimNonPublished>
23
+ <SectionLayout
24
+ key={section.id}
25
+ type={section.type}
26
+ status={meta.status}
27
+ dimNonPublished
28
+ dimmed={previewAudience !== null && !audiencePasses(meta.access, previewAudience)}
29
+ >
21
30
  <Component
22
31
  content={section}
23
32
  options={"options" in section ? (section.options as Record<string, unknown>) : undefined}