@drawnagency/primitives 0.1.56 → 0.1.58

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 (165) hide show
  1. package/dist/adapter-HH47ZPGM.js +1779 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/{chunk-EU6NZ4GS.js → chunk-AN62WPW7.js} +22 -163
  4. package/dist/chunk-ESE5UBQI.js +73 -0
  5. package/dist/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
  6. package/dist/chunk-JSBRDJBE.js +30 -0
  7. package/dist/chunk-NSCT3AMV.js +32 -0
  8. package/dist/chunk-RFZNNCAS.js +160 -0
  9. package/dist/chunk-TG43X7JO.js +123 -0
  10. package/dist/{chunk-7IAWF7LE.js → chunk-V7JN2DDU.js} +2 -19
  11. package/dist/chunk-VKAGMEKE.js +90 -0
  12. package/dist/chunk-ZU2MKPTG.js +29 -0
  13. package/dist/closest-edge-EBOXL3YW.js +72 -0
  14. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  15. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  16. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  17. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  18. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  19. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  20. package/dist/components/editor/SettingsForm.d.ts +5 -1
  21. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  22. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  23. package/dist/components/primitives/IconPicker.d.ts +7 -1
  24. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  25. package/dist/components/sections/Container/Container.d.ts +20 -0
  26. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  27. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  28. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  29. package/dist/components/sections/Container/index.d.ts +11 -0
  30. package/dist/components/sections/Container/index.d.ts.map +1 -0
  31. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  32. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  33. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  34. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  35. package/dist/components/sections/IconList/index.d.ts +1 -0
  36. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  37. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  38. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  39. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  40. package/dist/components/sections/Media/index.d.ts.map +1 -0
  41. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  42. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  43. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  44. package/dist/components/sections/Spacer/index.d.ts +6 -0
  45. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  46. package/dist/components/sections/all-sections.d.ts +29 -103
  47. package/dist/components/sections/all-sections.d.ts.map +1 -1
  48. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  49. package/dist/components/sections/register-schemas.js +4094 -0
  50. package/dist/components/shared/Tabs.d.ts +24 -0
  51. package/dist/components/shared/Tabs.d.ts.map +1 -0
  52. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  53. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  54. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  55. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  56. package/dist/hooks/useBlockDnd.d.ts +48 -0
  57. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  58. package/dist/index.js +69 -56
  59. package/dist/lib/block-dnd.d.ts +42 -0
  60. package/dist/lib/block-dnd.d.ts.map +1 -0
  61. package/dist/lib/block-move.d.ts +31 -0
  62. package/dist/lib/block-move.d.ts.map +1 -0
  63. package/dist/lib/container-grid.d.ts +29 -0
  64. package/dist/lib/container-grid.d.ts.map +1 -0
  65. package/dist/lib/container-ops.d.ts +44 -0
  66. package/dist/lib/container-ops.d.ts.map +1 -0
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +15 -0
  69. package/dist/lib/env.js +1 -0
  70. package/dist/lib/index.js +19 -13
  71. package/dist/lib/loader.d.ts.map +1 -1
  72. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  73. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  74. package/dist/lib/migrate-sections-transform.js +7 -0
  75. package/dist/lib/registry.d.ts +39 -0
  76. package/dist/lib/registry.d.ts.map +1 -1
  77. package/dist/lib/registry.js +27 -0
  78. package/dist/media/index.js +1 -0
  79. package/dist/schemas/auth.js +1 -0
  80. package/dist/schemas/block.d.ts +20 -0
  81. package/dist/schemas/block.d.ts.map +1 -0
  82. package/dist/schemas/block.js +15 -0
  83. package/dist/schemas/index.js +13 -4
  84. package/dist/schemas/link.d.ts +7 -0
  85. package/dist/schemas/link.d.ts.map +1 -1
  86. package/dist/schemas/rich-text.d.ts +9 -0
  87. package/dist/schemas/rich-text.d.ts.map +1 -0
  88. package/dist/schemas/sections.d.ts +2 -0
  89. package/dist/schemas/sections.d.ts.map +1 -1
  90. package/dist/schemas/shared.d.ts +30 -0
  91. package/dist/schemas/shared.d.ts.map +1 -1
  92. package/dist/types/database.js +2 -0
  93. package/package.json +17 -1
  94. package/src/components/brandguide/Colors.tsx +35 -33
  95. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  96. package/src/components/editor/ColSpanControl.tsx +56 -0
  97. package/src/components/editor/SectionWrapper.tsx +44 -20
  98. package/src/components/editor/SettingsForm.tsx +100 -73
  99. package/src/components/primitives/EditableGrid.tsx +40 -36
  100. package/src/components/primitives/IconPicker.tsx +116 -26
  101. package/src/components/sections/Container/Container.tsx +354 -0
  102. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  103. package/src/components/sections/Container/index.tsx +51 -0
  104. package/src/components/sections/IconList/IconList.tsx +113 -43
  105. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  106. package/src/components/sections/IconList/index.tsx +1 -1
  107. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  108. package/src/components/sections/Media/index.tsx +85 -0
  109. package/src/components/sections/Prose/index.tsx +1 -0
  110. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  111. package/src/components/sections/Spacer/index.tsx +18 -0
  112. package/src/components/sections/all-sections.ts +10 -8
  113. package/src/components/sections/register-schemas.ts +5 -2
  114. package/src/components/shared/Tabs.tsx +63 -0
  115. package/src/components/shell/EditorShell.tsx +105 -13
  116. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  117. package/src/components/shell/blockMoveDispatch.ts +40 -0
  118. package/src/hooks/useBlockDnd.ts +144 -0
  119. package/src/lib/block-dnd.ts +58 -0
  120. package/src/lib/block-move.ts +236 -0
  121. package/src/lib/container-grid.ts +58 -0
  122. package/src/lib/container-ops.ts +159 -0
  123. package/src/lib/dexie.ts +22 -0
  124. package/src/lib/loader.ts +16 -4
  125. package/src/lib/migrate-sections-transform.ts +147 -0
  126. package/src/lib/registry.ts +48 -0
  127. package/src/schemas/block.ts +40 -0
  128. package/src/schemas/link.ts +19 -1
  129. package/src/schemas/rich-text.ts +11 -0
  130. package/src/schemas/sections.ts +5 -1
  131. package/src/schemas/shared.ts +6 -0
  132. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  133. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  134. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  135. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  136. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  137. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  138. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  139. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  140. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  141. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  142. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  143. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  144. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  145. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  146. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  147. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  148. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  149. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  150. package/src/components/brandguide/DoDontList.tsx +0 -67
  151. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  152. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  153. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  154. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  155. package/src/components/sections/DoDontList/index.tsx +0 -45
  156. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  157. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  158. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  159. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  160. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  161. package/src/components/sections/MediaGrid/index.tsx +0 -57
  162. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  163. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  164. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  165. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -5,6 +5,7 @@ import { getIcon } from "../../../lib/icons";
5
5
  import { useEditableCollection } from "../../primitives/useEditableCollection";
6
6
  import { EditablePlainText } from "../../primitives/EditablePlainText";
7
7
  import { DragHandle, DeleteIcon, AddIcon } from "../../shared/icons";
8
+ import { CopyPlus, Pencil } from "lucide-react";
8
9
  import { IconButton } from "../../shared/IconButton";
9
10
  import { IconPicker } from "../../primitives/IconPicker";
10
11
 
@@ -12,6 +13,7 @@ interface IconListItem {
12
13
  label: string;
13
14
  text: string;
14
15
  icon?: string;
16
+ dodont?: "do" | "dont";
15
17
  }
16
18
 
17
19
  interface Props {
@@ -84,10 +86,20 @@ export default function IconList({
84
86
  // ---------------------------------------------------------------------------
85
87
 
86
88
  function resolveIcon(item: IconListItem, defaultIcon: string | null | undefined) {
89
+ // dodont WINS over item.icon and the section default — forces the DoDontList glyph.
90
+ if (item.dodont === "do") return getIcon("check") ?? null;
91
+ if (item.dodont === "dont") return getIcon("x") ?? null;
87
92
  const id = item.icon ?? defaultIcon;
88
93
  return id ? getIcon(id) : null;
89
94
  }
90
95
 
96
+ // Per-item icon color: green for "do", red for "dont", else the section-wide class.
97
+ function resolveIconColor(item: IconListItem, iconClassName: string) {
98
+ if (item.dodont === "do") return "text-green-600";
99
+ if (item.dodont === "dont") return "text-red-600";
100
+ return iconClassName;
101
+ }
102
+
91
103
  // ---------------------------------------------------------------------------
92
104
  // View mode
93
105
  // ---------------------------------------------------------------------------
@@ -115,6 +127,7 @@ function ViewIconList({
115
127
  <div className="grid gap-4 pb-4">
116
128
  {items.map((item, i) => {
117
129
  const iconEntry = resolveIcon(item, defaultIcon);
130
+ const iconColor = resolveIconColor(item, iconClassName);
118
131
  return (
119
132
  <div
120
133
  key={i}
@@ -126,7 +139,7 @@ function ViewIconList({
126
139
  {hasAnyIcon && (
127
140
  <div
128
141
  data-testid="icon-list-icon"
129
- className={cn("flex items-start pt-0.5", iconClassName)}
142
+ className={cn("flex items-start pt-0.5", iconColor)}
130
143
  >
131
144
  {iconEntry && <iconEntry.icon size={18} />}
132
145
  </div>
@@ -191,7 +204,22 @@ function EditIconList({
191
204
  [wrappedItems, onItemsChange],
192
205
  );
193
206
 
194
- const hasAnyIcon = items.some((item) => resolveIcon(item, defaultIcon));
207
+ const duplicateItem = useCallback(
208
+ (index: number) => {
209
+ const copy = { ...wrappedItems[index].data };
210
+ const next = [
211
+ ...wrappedItems.slice(0, index + 1).map((w) => w.data),
212
+ copy,
213
+ ...wrappedItems.slice(index + 1).map((w) => w.data),
214
+ ];
215
+ onItemsChange(next);
216
+ },
217
+ [wrappedItems, onItemsChange],
218
+ );
219
+
220
+ // In edit mode the icon column always renders — even items with no icon need
221
+ // the always-present picker trigger (icon + Do/Don't tagging) in the icon cell.
222
+ const hasAnyIcon = true;
195
223
 
196
224
  return (
197
225
  <div className="group/list relative grid gap-4 pb-4">
@@ -213,6 +241,7 @@ function EditIconList({
213
241
  onReorder={onReorder}
214
242
  onRemove={onRemove}
215
243
  onUpdateItem={(patch) => updateItem(index, patch)}
244
+ onDuplicate={() => duplicateItem(index)}
216
245
  />
217
246
  ))}
218
247
 
@@ -252,6 +281,7 @@ function EditableRow({
252
281
  onReorder,
253
282
  onRemove,
254
283
  onUpdateItem,
284
+ onDuplicate,
255
285
  }: {
256
286
  id: string;
257
287
  index: number;
@@ -268,14 +298,17 @@ function EditableRow({
268
298
  onReorder: (from: number, to: number) => void;
269
299
  onRemove: (id: string) => void;
270
300
  onUpdateItem: (patch: Partial<IconListItem>) => void;
301
+ onDuplicate: () => void;
271
302
  }) {
272
303
  const rowRef = useRef<HTMLDivElement>(null);
273
304
  const handleRef = useRef<HTMLButtonElement>(null);
274
305
  const [iconHover, setIconHover] = useState(false);
275
306
  const [pickerOpen, setPickerOpen] = useState(false);
307
+ const [editingSuppressed, setEditingSuppressed] = useState(false);
276
308
 
277
309
  const iconEntry = resolveIcon(item, defaultIcon);
278
310
  const effectiveIconId = item.icon ?? defaultIcon ?? null;
311
+ const iconColor = resolveIconColor(item, iconClassName);
279
312
 
280
313
  useEffect(() => {
281
314
  const row = rowRef.current;
@@ -324,6 +357,13 @@ function EditableRow({
324
357
 
325
358
  const isDropTarget = dragState.targetId === id && dragState.sourceId !== id;
326
359
 
360
+ const onEditableActivity = (e: React.SyntheticEvent) => {
361
+ const t = e.target as HTMLElement;
362
+ if (t.matches?.("input, textarea, [contenteditable], [contenteditable='true']")) {
363
+ setEditingSuppressed(true);
364
+ }
365
+ };
366
+
327
367
  return (
328
368
  <div
329
369
  ref={rowRef}
@@ -332,55 +372,60 @@ function EditableRow({
332
372
  hasAnyIcon ? "grid-cols-[24px_1fr]" : "grid-cols-1",
333
373
  isDropTarget && "border-t-2 border-primary",
334
374
  )}
375
+ onFocusCapture={onEditableActivity}
376
+ onInput={onEditableActivity}
377
+ onMouseMove={() => setEditingSuppressed(false)}
335
378
  >
336
- {/* Drag handle — absolute in left gutter, no layout shift */}
337
- <IconButton
338
- ref={handleRef}
339
- icon={<DragHandle size={16} />}
340
- label="Drag to reorder"
341
- size="sm"
342
- className={cn(
343
- "absolute -left-7 top-0 shrink-0 cursor-grab rounded-md text-base-contrast-light/80 hover:bg-base-contrast-light/10 hover:text-base-contrast active:cursor-grabbing",
344
- iconHover
345
- ? "opacity-0 pointer-events-none"
346
- : "opacity-0 group-hover/row:opacity-100 no-hover:opacity-100",
347
- )}
348
- tabIndex={-1}
349
- />
350
379
 
351
- {/* Icon — matches ViewIconList layout: 24px column, flex items-start pt-0.5 */}
380
+ {/* Icon — matches ViewIconList layout: 24px column, flex items-start pt-0.5.
381
+ A SINGLE always-present trigger shows the current effective glyph
382
+ (resolveIcon returns the forced check/x for tagged items) and opens
383
+ the IconPicker, which carries both the curated grid (None mode) and
384
+ the Do/Don't tiles (Do-Don't mode). Reachable for every item, tagged
385
+ or not, so tags can be edited or cleared via the picker. */}
352
386
  {hasAnyIcon && (
353
387
  <div
354
- className={cn("relative flex items-start pt-0.5", iconClassName)}
388
+ data-testid="icon-list-icon"
389
+ className={cn("relative flex items-start pt-0.5", iconColor)}
355
390
  onMouseEnter={() => setIconHover(true)}
356
391
  onMouseLeave={() => setIconHover(false)}
357
392
  >
358
- {iconEntry ? (
359
- <button
360
- className="cursor-pointer transition-opacity hover:opacity-70"
361
- aria-label="Change icon"
362
- onClick={() => setPickerOpen((prev) => !prev)}
363
- >
393
+ <button
394
+ className="cursor-pointer transition-opacity hover:opacity-70"
395
+ aria-label="Change icon"
396
+ onClick={() => setPickerOpen((prev) => !prev)}
397
+ >
398
+ {iconHover && !pickerOpen ? (
399
+ <Pencil size={18} className="text-base-contrast-light" />
400
+ ) : iconEntry ? (
364
401
  <iconEntry.icon size={18} />
365
- </button>
366
- ) : null}
367
-
368
- {iconHover && !pickerOpen && iconEntry && (
369
- <span className="pointer-events-none absolute right-full mr-3 top-0.5 whitespace-nowrap text-xs text-base-contrast-light/80">
370
- Edit
371
- </span>
372
- )}
402
+ ) : (
403
+ <span className="flex h-[18px] w-[18px] items-center justify-center rounded-sm border border-dashed border-base-contrast-light/40" />
404
+ )}
405
+ </button>
373
406
 
374
407
  {pickerOpen && (
375
408
  <div className="absolute top-full left-0 z-50 mt-1">
376
409
  <IconPicker
377
410
  selected={effectiveIconId}
378
- showRemove={false}
411
+ showRemove={true}
412
+ allowDoDont={true}
413
+ dodont={item.dodont}
379
414
  onSelect={(newIcon) => {
380
- if (newIcon !== null) onUpdateItem({ icon: newIcon });
415
+ onUpdateItem({ icon: newIcon ?? undefined, dodont: undefined });
381
416
  setPickerOpen(false);
382
417
  setIconHover(false);
383
418
  }}
419
+ onSelectDoDont={(v) => {
420
+ onUpdateItem({ dodont: v });
421
+ // Switching to None mode (v === undefined) clears the tag but
422
+ // keeps the picker open so the grid is reachable; choosing a
423
+ // Do/Don't tile commits and closes.
424
+ if (v !== undefined) {
425
+ setPickerOpen(false);
426
+ setIconHover(false);
427
+ }
428
+ }}
384
429
  onClose={() => {
385
430
  setPickerOpen(false);
386
431
  setIconHover(false);
@@ -413,15 +458,40 @@ function EditableRow({
413
458
  />
414
459
  </div>
415
460
 
416
- {/* Delete buttonabsolute positioned, no layout shift */}
417
- <IconButton
418
- icon={<DeleteIcon size={14} />}
419
- label="Delete item"
420
- size="sm"
421
- intent="destructive"
422
- onClick={() => onRemove(id)}
423
- className="absolute -right-7 top-0 opacity-0 group-hover/row:opacity-100 no-hover:opacity-100"
424
- />
461
+ {/* Per-row controlsdrag grip + duplicate + delete, overlaid at top-right of the text.
462
+ Hidden while the user is actively editing this row's text; restored on cursor movement. */}
463
+ <div
464
+ className={cn(
465
+ "absolute right-1 top-1 z-10 flex items-center gap-1",
466
+ editingSuppressed
467
+ ? "opacity-0 pointer-events-none"
468
+ : "opacity-0 group-hover/row:opacity-100 no-hover:opacity-100",
469
+ )}
470
+ >
471
+ <IconButton
472
+ ref={handleRef}
473
+ icon={<DragHandle size={14} />}
474
+ label="Drag to reorder"
475
+ size="sm"
476
+ className="cursor-grab rounded bg-base/80 p-1 shadow-sm text-base-contrast-light/80 hover:text-base-contrast active:cursor-grabbing"
477
+ tabIndex={-1}
478
+ />
479
+ <button
480
+ aria-label="Duplicate item"
481
+ onClick={onDuplicate}
482
+ className="cursor-pointer rounded bg-base/80 p-1 shadow-sm text-base-contrast-light hover:text-primary"
483
+ >
484
+ <CopyPlus size={14} />
485
+ </button>
486
+ <IconButton
487
+ icon={<DeleteIcon size={14} />}
488
+ label="Delete item"
489
+ size="sm"
490
+ intent="destructive"
491
+ onClick={() => onRemove(id)}
492
+ className="bg-base/80 shadow-sm"
493
+ />
494
+ </div>
425
495
  </div>
426
496
  );
427
497
  }
@@ -13,7 +13,7 @@ export function IconListSettings({
13
13
  icon: string | null;
14
14
  showLabel: boolean;
15
15
  stackText: boolean;
16
- onChange: (values: { icon: string | null; showLabel: boolean; stackText: boolean }) => void;
16
+ onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
17
17
  }) {
18
18
  const [icon, setIcon] = useState(initialIcon);
19
19
  const [showLabel, setShowLabel] = useState(initialShowLabel);
@@ -21,7 +21,7 @@ export function IconListSettings({
21
21
  const [pickerOpen, setPickerOpen] = useState(false);
22
22
 
23
23
  const emit = (overrides: Partial<{ icon: string | null; showLabel: boolean; stackText: boolean }>) =>
24
- onChange({ icon, showLabel, stackText, ...overrides });
24
+ onChange({ content: {}, options: { icon, showLabel, stackText, ...overrides } });
25
25
 
26
26
  const showIcons = icon !== null;
27
27
  const iconEntry = icon ? getIcon(icon) : null;
@@ -7,7 +7,7 @@ import { IconListSettings } from "./IconListSettings";
7
7
  const schema = z.object({
8
8
  type: z.literal("icon_list"),
9
9
  content: z.object({
10
- items: z.array(z.object({ label: z.string(), text: z.string(), icon: z.string().optional() })),
10
+ items: z.array(z.object({ label: z.string(), text: z.string(), icon: z.string().optional(), dodont: z.enum(["do", "dont"]).optional() })),
11
11
  }),
12
12
  options: z.object({
13
13
  icon: z.string().nullable().optional(),
@@ -0,0 +1,103 @@
1
+ import type { SingleMediaReference } from "../../../schemas/shared";
2
+ import type { LinkValue } from "../../../schemas/link";
3
+ import { Check, X } from "lucide-react";
4
+ import { cn } from "../../../lib/cn";
5
+ import { ResolvedMedia } from "../../primitives/ResolvedMedia";
6
+ import { ImageDropZone } from "../../primitives/ImageDropZone";
7
+ import { EditablePlainText } from "../../primitives/EditablePlainText";
8
+ import { useMediaLibrary } from "../../shell/MediaLibraryContext";
9
+
10
+ export interface MediaBlockProps {
11
+ refValue: SingleMediaReference;
12
+ options: { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" };
13
+ /** Per-item link (viewer: already resolved to kind:"external" upstream). */
14
+ link?: LinkValue;
15
+ /** Per-item do/dont badge. */
16
+ dodont?: "do" | "dont";
17
+ isEditMode: boolean;
18
+ onRefChange?: (ref: SingleMediaReference) => void;
19
+ }
20
+
21
+ export function MediaBlock({ refValue, options, link, dodont, isEditMode, onRefChange }: MediaBlockProps) {
22
+ const mediaLibrary = useMediaLibrary();
23
+ // Per-item ref value overrides the inherited option (childDefaults); default contain.
24
+ const fit = refValue.objectFit ?? options.objectFit;
25
+ const fitClass = fit === "cover" ? "object-cover" : "object-contain";
26
+ const showBorder = refValue.border ?? options.border;
27
+ const captionStr = refValue.caption
28
+ ? Array.isArray(refValue.caption) ? refValue.caption.join("\n") : refValue.caption
29
+ : "";
30
+
31
+ const replace = onRefChange ? (imageId: string) => onRefChange({ ...refValue, imageId }) : undefined;
32
+
33
+ const resolved = (
34
+ <ResolvedMedia
35
+ imageId={refValue.imageId || undefined}
36
+ className="h-full w-full"
37
+ imgClassName={fitClass}
38
+ invertFrom={refValue.invertFrom}
39
+ />
40
+ );
41
+
42
+ let media = isEditMode && replace ? (
43
+ <ImageDropZone onImageSelected={replace} className="h-full w-full">
44
+ {resolved}
45
+ </ImageDropZone>
46
+ ) : resolved;
47
+
48
+ // Viewer link wrapper (CTAButton pattern). resolveInternalLinks has collapsed
49
+ // internal links to kind:"external" before render. No wrapper in edit mode.
50
+ if (!isEditMode && link && link.kind === "external" && link.href) {
51
+ media = (
52
+ <a href={link.href} target={link.target} className="group block">
53
+ {media}
54
+ </a>
55
+ );
56
+ }
57
+
58
+ const onImageClick = isEditMode && replace && mediaLibrary
59
+ ? () => mediaLibrary.openSelectModal((imageId) => replace(imageId))
60
+ : undefined;
61
+
62
+ return (
63
+ <figure>
64
+ {dodont && (
65
+ <div className="flex h-10 items-center justify-center pb-1">
66
+ {dodont === "do"
67
+ ? <Check size={28} className="text-green-600" />
68
+ : <X size={28} className="text-red-600" />}
69
+ </div>
70
+ )}
71
+ <div
72
+ data-testid="media-open-surface"
73
+ className={cn(
74
+ "overflow-hidden rounded-md",
75
+ showBorder && "border border-base-200",
76
+ options.square && "aspect-square",
77
+ onImageClick && "cursor-pointer",
78
+ )}
79
+ onClick={onImageClick}
80
+ onPointerDown={onImageClick ? (e) => e.stopPropagation() : undefined}
81
+ >
82
+ {media}
83
+ </div>
84
+
85
+ {options.showCaption && (
86
+ <figcaption className="mt-2 min-h-[1em] text-sm text-base-contrast-light">
87
+ {isEditMode && onRefChange ? (
88
+ <EditablePlainText
89
+ tag="span"
90
+ value={captionStr}
91
+ onChange={(caption) => onRefChange({ ...refValue, caption: caption || undefined })}
92
+ isEditMode={true}
93
+ placeholder="Caption"
94
+ className="block min-h-[1em]"
95
+ />
96
+ ) : (
97
+ captionStr || " "
98
+ )}
99
+ </figcaption>
100
+ )}
101
+ </figure>
102
+ );
103
+ }
@@ -0,0 +1,85 @@
1
+ import { defineSection } from "../../../lib/registry";
2
+ import { z } from "zod";
3
+ import { Image as ImageIcon } from "lucide-react";
4
+ import { SingleMediaReferenceSchema } from "../../../schemas/shared";
5
+ import { LinkValueSchema, DEFAULT_LINK } from "../../../schemas/link";
6
+ import { MediaBlock } from "./MediaBlock";
7
+
8
+ const schema = z.object({
9
+ type: z.literal("media"),
10
+ content: z.object({
11
+ ref: SingleMediaReferenceSchema,
12
+ link: LinkValueSchema.optional(),
13
+ dodont: z.enum(["do", "dont"]).optional(),
14
+ }),
15
+ options: z
16
+ .object({
17
+ square: z.boolean().optional(),
18
+ showCaption: z.boolean().optional(),
19
+ border: z.boolean().optional(),
20
+ objectFit: z.enum(["cover", "contain"]).optional(),
21
+ })
22
+ .default({}),
23
+ });
24
+
25
+ export default defineSection({
26
+ type: "media",
27
+ label: "Media",
28
+ icon: <ImageIcon size={18} />,
29
+ schema,
30
+ component: ({ content, options, onChange }) => (
31
+ <MediaBlock
32
+ refValue={content.content.ref}
33
+ options={(options ?? {}) as { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" }}
34
+ link={content.content.link}
35
+ dodont={content.content.dodont}
36
+ isEditMode={!!onChange}
37
+ onRefChange={
38
+ onChange ? (ref) => onChange({ ...content, content: { ...content.content, ref } }) : undefined
39
+ }
40
+ />
41
+ ),
42
+ defaults: () => ({
43
+ type: "media" as const,
44
+ content: { ref: { type: "image" as const, imageId: "" } },
45
+ options: {},
46
+ }),
47
+ getLabel: () => "Media",
48
+ getThumbnails: (content) =>
49
+ content.content.ref.imageId
50
+ ? [{ type: "image" as const, src: content.content.ref.imageId }]
51
+ : [],
52
+ settings: {
53
+ square: { type: "checkbox", label: "Square aspect ratio", default: false },
54
+ showCaption: { type: "checkbox", label: "Show caption", default: false },
55
+ border: { type: "checkbox", label: "Border", default: false },
56
+ objectFit: {
57
+ type: "select",
58
+ label: "Image fit",
59
+ default: "contain",
60
+ options: [
61
+ { label: "Contain", value: "contain" },
62
+ { label: "Cover", value: "cover" },
63
+ ],
64
+ },
65
+ link: { type: "link", label: "Link", default: DEFAULT_LINK, target: "content" },
66
+ dodont: {
67
+ type: "select",
68
+ label: "Do / Don't",
69
+ target: "content",
70
+ default: "",
71
+ emptyIsUndefined: true,
72
+ options: [
73
+ { label: "None", value: "" },
74
+ { label: "Do", value: "do" },
75
+ { label: "Don't", value: "dont" },
76
+ ],
77
+ },
78
+ },
79
+ inheritableSettings: ["square", "showCaption", "border", "objectFit"],
80
+ settingsTabs: [
81
+ { label: "Display", fields: ["square", "showCaption", "border", "objectFit"] },
82
+ { label: "Link", fields: ["link"] },
83
+ { label: "Do / Don't", fields: ["dodont"] },
84
+ ],
85
+ });
@@ -14,6 +14,7 @@ export default defineSection({
14
14
  label: "Prose",
15
15
  icon: <AlignLeft size={18} />,
16
16
  schema,
17
+ richTextFields: ["body"],
17
18
  component: ({ content, onChange }) => (
18
19
  <Prose body={content.content.body} onChange={onChange ? (c) => onChange(c as typeof content) : undefined} />
19
20
  ),
@@ -0,0 +1,6 @@
1
+ // Spacer view cell — deliberately empty. It occupies its grid column so the gap is
2
+ // preserved on publish. The hide-on-collapse class (which depends on the parent's column
3
+ // count) is applied by the Container to the cell wrapper, not here.
4
+ export default function Spacer() {
5
+ return <div data-spacer aria-hidden="true" className="h-full" />;
6
+ }
@@ -0,0 +1,18 @@
1
+ import { defineSection } from "../../../lib/registry";
2
+ import { z } from "zod";
3
+ import { SquareDashed } from "lucide-react";
4
+ import Spacer from "./Spacer";
5
+
6
+ const schema = z.object({
7
+ type: z.literal("spacer"),
8
+ content: z.object({}).default({}),
9
+ });
10
+
11
+ export default defineSection({
12
+ type: "spacer",
13
+ label: "Spacer",
14
+ icon: <SquareDashed size={18} />,
15
+ schema,
16
+ component: () => <Spacer />,
17
+ defaults: () => ({ type: "spacer" as const, content: {} }),
18
+ });
@@ -2,13 +2,12 @@ import linkHeading from "./LinkHeading";
2
2
  import subHeading from "./SubHeading";
3
3
  import subSubHeading from "./SubSubHeading";
4
4
  import prose from "./Prose";
5
- import mediaGrid from "./MediaGrid";
6
- import splitContent from "./SplitContent";
5
+ import media from "./Media";
7
6
  import button from "./Button";
8
7
  import colors from "./Colors";
9
- import doDontList from "./DoDontList";
10
- import doDontImageGrid from "./DoDontMediaGrid";
11
8
  import iconList from "./IconList";
9
+ import container from "./Container";
10
+ import spacer from "./Spacer";
12
11
 
13
12
  /**
14
13
  * The single ordered list of built-in section definitions.
@@ -18,6 +17,10 @@ import iconList from "./IconList";
18
17
  * array, so the two can no longer drift — previously they hand-maintained
19
18
  * parallel 11-entry lists, and a section added to one but not the other would
20
19
  * make the editor render a type that save rejects with a 400 (or vice versa).
20
+ *
21
+ * Retired: split_content, media_grid, do_dont_grid, do_dont — fully decomposable
22
+ * into container sections. The loader fallback in mergeSiteContent transforms any
23
+ * legacy-typed JSON to container before validation.
21
24
  */
22
25
  // Intentionally un-annotated: each section is SectionDefinition<its-own-shape>,
23
26
  // and a wider `SectionDefinition<unknown>[]` annotation isn't assignable from
@@ -28,11 +31,10 @@ export const allSectionDefs = [
28
31
  subHeading,
29
32
  subSubHeading,
30
33
  prose,
31
- mediaGrid,
32
- splitContent,
34
+ media,
33
35
  button,
34
36
  colors,
35
- doDontList,
36
- doDontImageGrid,
37
37
  iconList,
38
+ container,
39
+ spacer,
38
40
  ];
@@ -1,4 +1,4 @@
1
- import { registerSchema } from "../../lib/registry";
1
+ import { registerSchema, registerRichText } from "../../lib/registry";
2
2
  import { allSectionDefs } from "./all-sections";
3
3
 
4
4
  // NOTE: this still imports the full (component-bearing) section modules via the
@@ -10,7 +10,10 @@ import { allSectionDefs } from "./all-sections";
10
10
  let _ensured = false;
11
11
  export function ensureSchemasRegistered(): number {
12
12
  if (!_ensured) {
13
- allSectionDefs.forEach((def) => registerSchema(def.type, def.schema));
13
+ allSectionDefs.forEach((def) => {
14
+ registerSchema(def.type, def.schema);
15
+ if (def.richTextFields) registerRichText(def.type, def.richTextFields);
16
+ });
14
17
  _ensured = true;
15
18
  }
16
19
  return allSectionDefs.length;
@@ -0,0 +1,63 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export interface TabItem {
5
+ id: string;
6
+ label: string;
7
+ content: ReactNode;
8
+ }
9
+
10
+ interface TabsProps {
11
+ tabs: TabItem[];
12
+ /** Initial active tab when uncontrolled. Defaults to the first tab. */
13
+ defaultTabId?: string;
14
+ /** When provided, the component is controlled by the parent. */
15
+ activeTabId?: string;
16
+ onTabChange?: (id: string) => void;
17
+ /**
18
+ * When true, the tab-bar row uses -mx-6 to break out of the parent's px-6 padding
19
+ * (e.g. EditorModal's content wrapper) and re-pads with px-6 so buttons stay aligned.
20
+ * The tab panel inherits the parent's padding unchanged.
21
+ * Use only when the parent container has px-6; leave false for noPadding contexts.
22
+ */
23
+ fullBleedTabBar?: boolean;
24
+ }
25
+
26
+ export function Tabs({ tabs, defaultTabId, activeTabId, onTabChange, fullBleedTabBar }: TabsProps) {
27
+ const [uncontrolledId, setUncontrolledId] = useState(defaultTabId ?? tabs[0]?.id);
28
+ const isControlled = activeTabId !== undefined;
29
+ const activeId = isControlled ? activeTabId : uncontrolledId;
30
+ const activeTab = tabs.find((t) => t.id === activeId) ?? tabs[0];
31
+
32
+ function handleSelect(id: string) {
33
+ if (!isControlled) setUncontrolledId(id);
34
+ onTabChange?.(id);
35
+ }
36
+
37
+ return (
38
+ <div className="flex flex-1 flex-col overflow-hidden">
39
+ <div className={cn("border-b border-base-200", fullBleedTabBar && "-mx-6 px-6")}>
40
+ <div className="flex" role="tablist">
41
+ {tabs.map((tab) => (
42
+ <button
43
+ key={tab.id}
44
+ type="button"
45
+ role="tab"
46
+ aria-selected={activeTab?.id === tab.id}
47
+ onClick={() => handleSelect(tab.id)}
48
+ className={cn(
49
+ "cursor-pointer px-4 py-2 text-sm font-medium border-b-2 -mb-px",
50
+ activeTab?.id === tab.id
51
+ ? "border-brand text-brand"
52
+ : "border-transparent text-base-contrast-light hover:text-base-contrast",
53
+ )}
54
+ >
55
+ {tab.label}
56
+ </button>
57
+ ))}
58
+ </div>
59
+ </div>
60
+ <div role="tabpanel">{activeTab?.content}</div>
61
+ </div>
62
+ );
63
+ }