@drawnagency/primitives 0.1.61 → 0.1.63

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 (78) hide show
  1. package/dist/{chunk-UQ4OYRW7.js → chunk-2BHPWO7M.js} +54 -166
  2. package/dist/chunk-4E66N6UV.js +10 -0
  3. package/dist/chunk-5THS4K5D.js +123 -0
  4. package/dist/{chunk-TG43X7JO.js → chunk-75P6HWPN.js} +29 -5
  5. package/dist/{chunk-ODCJQOVO.js → chunk-7IMQVDDJ.js} +1 -1
  6. package/dist/{chunk-4OX3Z7SB.js → chunk-FECCYOPJ.js} +32 -671
  7. package/dist/{chunk-WLMJCNU7.js → chunk-I5737FJY.js} +0 -8
  8. package/dist/chunk-QYQ3DQHI.js +1201 -0
  9. package/dist/{chunk-BT7STGDW.js → chunk-TEYTLQVH.js} +0 -15
  10. package/dist/chunk-VMDKWUMT.js +652 -0
  11. package/dist/{chunk-AM5LZD6Q.js → chunk-ZYXLNJ5M.js} +6 -5
  12. package/dist/components/brandguide/ColorSwatchSettings.d.ts +1 -1
  13. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  14. package/dist/components/brandguide/Colors.d.ts +1 -1
  15. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  16. package/dist/components/editor/index.js +4 -2
  17. package/dist/components/primitives/IconPicker.d.ts +1 -7
  18. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  19. package/dist/components/primitives/index.js +10 -7
  20. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  21. package/dist/components/sections/Colors/schema.d.ts +19 -0
  22. package/dist/components/sections/Colors/schema.d.ts.map +1 -0
  23. package/dist/components/sections/DoDontMedia/DoDontMediaBlock.d.ts +20 -0
  24. package/dist/components/sections/DoDontMedia/DoDontMediaBlock.d.ts.map +1 -0
  25. package/dist/components/sections/DoDontMedia/index.d.ts +45 -0
  26. package/dist/components/sections/DoDontMedia/index.d.ts.map +1 -0
  27. package/dist/components/sections/IconList/DoDontToggle.d.ts +14 -0
  28. package/dist/components/sections/IconList/DoDontToggle.d.ts.map +1 -0
  29. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  30. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  31. package/dist/components/sections/Media/MediaBlock.d.ts +1 -3
  32. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
  33. package/dist/components/sections/Media/index.d.ts +0 -1
  34. package/dist/components/sections/Media/index.d.ts.map +1 -1
  35. package/dist/components/sections/all-sections.d.ts +54 -18
  36. package/dist/components/sections/all-sections.d.ts.map +1 -1
  37. package/dist/components/sections/brand-guide.d.ts +84 -0
  38. package/dist/components/sections/brand-guide.d.ts.map +1 -0
  39. package/dist/components/sections/brand-guide.js +21 -0
  40. package/dist/components/sections/core-sections.d.ts +93 -0
  41. package/dist/components/sections/core-sections.d.ts.map +1 -0
  42. package/dist/components/sections/register-schemas.js +108 -1155
  43. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  44. package/dist/components/shell/view-branch.d.ts +12 -0
  45. package/dist/components/shell/view-branch.d.ts.map +1 -0
  46. package/dist/index.js +29 -33
  47. package/dist/lib/index.js +4 -4
  48. package/dist/lib/loader.d.ts.map +1 -1
  49. package/dist/lib/migrate-sections-transform.d.ts.map +1 -1
  50. package/dist/lib/migrate-sections-transform.js +1 -1
  51. package/dist/lib/registry.d.ts +6 -1
  52. package/dist/lib/registry.d.ts.map +1 -1
  53. package/dist/media/index.js +1 -1
  54. package/dist/schemas/index.js +3 -7
  55. package/dist/schemas/shared.d.ts +0 -17
  56. package/dist/schemas/shared.d.ts.map +1 -1
  57. package/package.json +5 -1
  58. package/src/components/brandguide/ColorSwatchSettings.tsx +1 -1
  59. package/src/components/brandguide/Colors.tsx +1 -1
  60. package/src/components/primitives/IconPicker.tsx +27 -119
  61. package/src/components/sections/Colors/index.tsx +2 -1
  62. package/src/components/sections/Colors/schema.ts +24 -0
  63. package/src/components/sections/DoDontMedia/DoDontMediaBlock.tsx +35 -0
  64. package/src/components/sections/DoDontMedia/index.tsx +84 -0
  65. package/src/components/sections/IconList/DoDontToggle.tsx +76 -0
  66. package/src/components/sections/IconList/IconList.tsx +24 -14
  67. package/src/components/sections/IconList/index.tsx +1 -0
  68. package/src/components/sections/Media/MediaBlock.tsx +10 -11
  69. package/src/components/sections/Media/index.tsx +0 -15
  70. package/src/components/sections/all-sections.ts +7 -35
  71. package/src/components/sections/brand-guide.ts +13 -0
  72. package/src/components/sections/core-sections.ts +26 -0
  73. package/src/components/shell/EditorShell.tsx +7 -5
  74. package/src/components/shell/view-branch.ts +14 -0
  75. package/src/lib/loader.ts +10 -4
  76. package/src/lib/migrate-sections-transform.ts +33 -5
  77. package/src/lib/registry.ts +6 -1
  78. package/src/schemas/shared.ts +0 -18
@@ -0,0 +1,84 @@
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 { DoDontMediaBlock } from "./DoDontMediaBlock";
7
+
8
+ const schema = z.object({
9
+ type: z.literal("dodont_media"),
10
+ content: z.object({
11
+ ref: SingleMediaReferenceSchema,
12
+ link: LinkValueSchema.optional(),
13
+ dodont: z.enum(["do", "dont"]),
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: "dodont_media",
27
+ label: "Do / Don't Media",
28
+ category: "brand-guide",
29
+ icon: <ImageIcon size={18} />,
30
+ schema,
31
+ component: ({ content, options, onChange }) => (
32
+ <DoDontMediaBlock
33
+ refValue={content.content.ref}
34
+ options={(options ?? {}) as { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" }}
35
+ link={content.content.link}
36
+ dodont={content.content.dodont}
37
+ isEditMode={!!onChange}
38
+ onRefChange={
39
+ onChange ? (ref) => onChange({ ...content, content: { ...content.content, ref } }) : undefined
40
+ }
41
+ />
42
+ ),
43
+ defaults: () => ({
44
+ type: "dodont_media" as const,
45
+ content: { ref: { type: "image" as const, imageId: "" }, dodont: "do" as const },
46
+ options: {},
47
+ }),
48
+ getLabel: () => "Do / Don't Media",
49
+ getThumbnails: (content) =>
50
+ content.content.ref.imageId
51
+ ? [{ type: "image" as const, src: content.content.ref.imageId }]
52
+ : [],
53
+ settings: {
54
+ square: { type: "checkbox", label: "Square aspect ratio", default: false },
55
+ showCaption: { type: "checkbox", label: "Show caption", default: false },
56
+ border: { type: "checkbox", label: "Border", default: false },
57
+ objectFit: {
58
+ type: "select",
59
+ label: "Image fit",
60
+ default: "contain",
61
+ options: [
62
+ { label: "Contain", value: "contain" },
63
+ { label: "Cover", value: "cover" },
64
+ ],
65
+ },
66
+ link: { type: "link", label: "Link", default: DEFAULT_LINK, target: "content" },
67
+ dodont: {
68
+ type: "select",
69
+ label: "Do / Don't",
70
+ target: "content",
71
+ default: "do",
72
+ options: [
73
+ { label: "Do", value: "do" },
74
+ { label: "Don't", value: "dont" },
75
+ ],
76
+ },
77
+ },
78
+ inheritableSettings: ["square", "showCaption", "border", "objectFit"],
79
+ settingsTabs: [
80
+ { label: "Display", fields: ["square", "showCaption", "border", "objectFit"] },
81
+ { label: "Link", fields: ["link"] },
82
+ { label: "Do / Don't", fields: ["dodont"] },
83
+ ],
84
+ });
@@ -0,0 +1,76 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Check, X } from "lucide-react";
3
+ import { cn } from "../../../lib/cn";
4
+
5
+ interface DoDontToggleProps {
6
+ value?: "do" | "dont";
7
+ onSelect: (v: "do" | "dont" | undefined) => void;
8
+ onClose: () => void;
9
+ }
10
+
11
+ /**
12
+ * Brand-guide do/dont tagging control. Kept OUT of the generic IconPicker so
13
+ * that primitive stays brand-agnostic — do/dont semantics (do = check/green,
14
+ * dont = x/red) live here, in the brand-guide layer. This is the reference
15
+ * example of extending a true primitive with a branded composition.
16
+ */
17
+ export function DoDontToggle({ value, onSelect, onClose }: DoDontToggleProps) {
18
+ const panelRef = useRef<HTMLDivElement>(null);
19
+
20
+ useEffect(() => {
21
+ function handleMouseDown(e: MouseEvent) {
22
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
23
+ }
24
+ document.addEventListener("mousedown", handleMouseDown);
25
+ return () => document.removeEventListener("mousedown", handleMouseDown);
26
+ }, [onClose]);
27
+
28
+ return (
29
+ <div
30
+ ref={panelRef}
31
+ className="absolute z-50 w-44 rounded-lg border border-base-200 bg-base p-2 shadow-lg"
32
+ >
33
+ <div className="grid grid-cols-3 gap-2">
34
+ <button
35
+ type="button"
36
+ aria-label="No do/don't tag"
37
+ className={cn(
38
+ "cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
39
+ "text-base-contrast-light hover:bg-base-accent",
40
+ value === undefined && "ring-2 ring-primary bg-base-accent",
41
+ )}
42
+ onClick={() => onSelect(undefined)}
43
+ >
44
+ <span className="flex h-5 w-5 items-center justify-center rounded-sm border border-dashed border-base-contrast-light/40" />
45
+ <span className="text-xs font-medium">None</span>
46
+ </button>
47
+ <button
48
+ type="button"
49
+ aria-label="Do"
50
+ className={cn(
51
+ "cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
52
+ "text-green-600 hover:bg-base-accent",
53
+ value === "do" && "ring-2 ring-primary bg-base-accent",
54
+ )}
55
+ onClick={() => onSelect("do")}
56
+ >
57
+ <Check size={20} />
58
+ <span className="text-xs font-medium">Do</span>
59
+ </button>
60
+ <button
61
+ type="button"
62
+ aria-label="Don't"
63
+ className={cn(
64
+ "cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
65
+ "text-red-600 hover:bg-base-accent",
66
+ value === "dont" && "ring-2 ring-primary bg-base-accent",
67
+ )}
68
+ onClick={() => onSelect("dont")}
69
+ >
70
+ <X size={20} />
71
+ <span className="text-xs font-medium">Don't</span>
72
+ </button>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -5,9 +5,10 @@ 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
+ import { Check, CopyPlus, Pencil, X } from "lucide-react";
9
9
  import { IconButton } from "../../shared/IconButton";
10
10
  import { IconPicker } from "../../primitives/IconPicker";
11
+ import { DoDontToggle } from "./DoDontToggle";
11
12
  import type { IconListItem } from "./index";
12
13
 
13
14
  interface Props {
@@ -298,6 +299,7 @@ function EditableRow({
298
299
  const handleRef = useRef<HTMLButtonElement>(null);
299
300
  const [iconHover, setIconHover] = useState(false);
300
301
  const [pickerOpen, setPickerOpen] = useState(false);
302
+ const [dodontOpen, setDodontOpen] = useState(false);
301
303
  const [editingSuppressed, setEditingSuppressed] = useState(false);
302
304
 
303
305
  const iconEntry = resolveIcon(item, defaultIcon);
@@ -387,7 +389,7 @@ function EditableRow({
387
389
  <button
388
390
  className="cursor-pointer transition-opacity hover:opacity-70"
389
391
  aria-label="Change icon"
390
- onClick={() => setPickerOpen((prev) => !prev)}
392
+ onClick={() => { setPickerOpen((prev) => !prev); setDodontOpen(false); }}
391
393
  >
392
394
  {iconHover && !pickerOpen ? (
393
395
  <Pencil size={18} className="text-base-contrast-light" />
@@ -403,23 +405,11 @@ function EditableRow({
403
405
  <IconPicker
404
406
  selected={effectiveIconId}
405
407
  showRemove={true}
406
- allowDoDont={true}
407
- dodont={item.dodont}
408
408
  onSelect={(newIcon) => {
409
409
  onUpdateItem({ icon: newIcon ?? undefined, dodont: undefined });
410
410
  setPickerOpen(false);
411
411
  setIconHover(false);
412
412
  }}
413
- onSelectDoDont={(v) => {
414
- onUpdateItem({ dodont: v });
415
- // Switching to None mode (v === undefined) clears the tag but
416
- // keeps the picker open so the grid is reachable; choosing a
417
- // Do/Don't tile commits and closes.
418
- if (v !== undefined) {
419
- setPickerOpen(false);
420
- setIconHover(false);
421
- }
422
- }}
423
413
  onClose={() => {
424
414
  setPickerOpen(false);
425
415
  setIconHover(false);
@@ -462,6 +452,26 @@ function EditableRow({
462
452
  : "opacity-0 group-hover/row:opacity-100 no-hover:opacity-100",
463
453
  )}
464
454
  >
455
+ <div className="relative">
456
+ <button
457
+ aria-label="Set do/don't"
458
+ onClick={() => { setDodontOpen((p) => !p); setPickerOpen(false); }}
459
+ className="cursor-pointer rounded bg-base/80 p-1 shadow-sm text-base-contrast-light hover:text-primary"
460
+ >
461
+ {item.dodont === "do" ? <Check size={14} className="text-green-600" />
462
+ : item.dodont === "dont" ? <X size={14} className="text-red-600" />
463
+ : <Pencil size={14} />}
464
+ </button>
465
+ {dodontOpen && (
466
+ <div className="absolute top-full right-0 z-50 mt-1">
467
+ <DoDontToggle
468
+ value={item.dodont}
469
+ onSelect={(v) => { onUpdateItem({ dodont: v }); setDodontOpen(false); }}
470
+ onClose={() => setDodontOpen(false)}
471
+ />
472
+ </div>
473
+ )}
474
+ </div>
465
475
  <IconButton
466
476
  ref={handleRef}
467
477
  icon={<DragHandle size={14} />}
@@ -27,6 +27,7 @@ const schema = z.object({
27
27
 
28
28
  export default defineSection({
29
29
  type: "icon_list",
30
+ category: "brand-guide",
30
31
  label: "Icon List",
31
32
  icon: <List size={18} />,
32
33
  schema,
@@ -1,6 +1,5 @@
1
1
  import type { SingleMediaReference } from "../../../schemas/shared";
2
2
  import type { LinkValue } from "../../../schemas/link";
3
- import { Check, X } from "lucide-react";
4
3
  import { cn } from "../../../lib/cn";
5
4
  import { ResolvedMedia } from "../../primitives/ResolvedMedia";
6
5
  import { ImageDropZone } from "../../primitives/ImageDropZone";
@@ -12,13 +11,11 @@ export interface MediaBlockProps {
12
11
  options: { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" };
13
12
  /** Per-item link (viewer: already resolved to kind:"external" upstream). */
14
13
  link?: LinkValue;
15
- /** Per-item do/dont badge. */
16
- dodont?: "do" | "dont";
17
14
  isEditMode: boolean;
18
15
  onRefChange?: (ref: SingleMediaReference) => void;
19
16
  }
20
17
 
21
- export function MediaBlock({ refValue, options, link, dodont, isEditMode, onRefChange }: MediaBlockProps) {
18
+ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }: MediaBlockProps) {
22
19
  const mediaLibrary = useMediaLibrary();
23
20
  // Per-item ref value overrides the inherited option (childDefaults); default contain.
24
21
  const fit = refValue.objectFit ?? options.objectFit;
@@ -30,9 +27,18 @@ export function MediaBlock({ refValue, options, link, dodont, isEditMode, onRefC
30
27
 
31
28
  const replace = onRefChange ? (imageId: string) => onRefChange({ ...refValue, imageId }) : undefined;
32
29
 
30
+ // The zero-JS viewer has no MediaLibraryContext, so useResolvedMedia() can't
31
+ // resolve the URL. The SSR pass (resolveManifestReferences) bakes src/srcset/alt
32
+ // onto the ref instead — forward them so a real <img> renders rather than the
33
+ // placeholder. In edit mode these are absent and ResolvedMedia falls back to the
34
+ // live manifest via context. (Mirrors a3e8913, which did this for MediaGrid/SplitContent.)
35
+ const baked = refValue as Record<string, unknown>;
33
36
  const resolved = (
34
37
  <ResolvedMedia
35
38
  imageId={refValue.imageId || undefined}
39
+ src={baked.src as string | undefined}
40
+ srcset={baked.srcset as string | undefined}
41
+ alt={baked.alt as string | undefined}
36
42
  className="h-full w-full"
37
43
  imgClassName={fitClass}
38
44
  invertFrom={refValue.invertFrom}
@@ -61,13 +67,6 @@ export function MediaBlock({ refValue, options, link, dodont, isEditMode, onRefC
61
67
 
62
68
  return (
63
69
  <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
70
  <div
72
71
  data-testid="media-open-surface"
73
72
  className={cn(
@@ -10,7 +10,6 @@ const schema = z.object({
10
10
  content: z.object({
11
11
  ref: SingleMediaReferenceSchema,
12
12
  link: LinkValueSchema.optional(),
13
- dodont: z.enum(["do", "dont"]).optional(),
14
13
  }),
15
14
  options: z
16
15
  .object({
@@ -32,7 +31,6 @@ export default defineSection({
32
31
  refValue={content.content.ref}
33
32
  options={(options ?? {}) as { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" }}
34
33
  link={content.content.link}
35
- dodont={content.content.dodont}
36
34
  isEditMode={!!onChange}
37
35
  onRefChange={
38
36
  onChange ? (ref) => onChange({ ...content, content: { ...content.content, ref } }) : undefined
@@ -63,23 +61,10 @@ export default defineSection({
63
61
  ],
64
62
  },
65
63
  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
64
  },
79
65
  inheritableSettings: ["square", "showCaption", "border", "objectFit"],
80
66
  settingsTabs: [
81
67
  { label: "Display", fields: ["square", "showCaption", "border", "objectFit"] },
82
68
  { label: "Link", fields: ["link"] },
83
- { label: "Do / Don't", fields: ["dodont"] },
84
69
  ],
85
70
  });
@@ -1,40 +1,12 @@
1
- import linkHeading from "./LinkHeading";
2
- import subHeading from "./SubHeading";
3
- import subSubHeading from "./SubSubHeading";
4
- import prose from "./Prose";
5
- import media from "./Media";
6
- import button from "./Button";
7
- import colors from "./Colors";
8
- import iconList from "./IconList";
9
- import container from "./Container";
10
- import spacer from "./Spacer";
1
+ import { coreSectionDefs } from "./core-sections";
2
+ import { brandGuideSectionDefs } from "./brand-guide";
11
3
 
12
4
  /**
13
- * The single ordered list of built-in section definitions.
14
- *
15
- * Both `register.ts` (full registration for rendering) and `register-schemas.ts`
16
- * (schema-only registration for /api/save validation) derive from this one
17
- * array, so the two can no longer drift — previously they hand-maintained
18
- * parallel 11-entry lists, and a section added to one but not the other would
19
- * make the editor render a type that save rejects with a 400 (or vice versa).
5
+ * The single ordered list of built-in section definitions, composed from the
6
+ * core (generic) and brand-guide groups. Both `register.ts` and
7
+ * `register-schemas.ts` derive from this one array, so they cannot drift.
20
8
  *
21
9
  * 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.
10
+ * into container sections by the loader fallback (mergeSiteContent).
24
11
  */
25
- // Intentionally un-annotated: each section is SectionDefinition<its-own-shape>,
26
- // and a wider `SectionDefinition<unknown>[]` annotation isn't assignable from
27
- // them (generic param variance). The inferred union is what registerSection /
28
- // registerSchema already accept.
29
- export const allSectionDefs = [
30
- linkHeading,
31
- subHeading,
32
- subSubHeading,
33
- prose,
34
- media,
35
- button,
36
- colors,
37
- iconList,
38
- container,
39
- spacer,
40
- ];
12
+ export const allSectionDefs = [...coreSectionDefs, ...brandGuideSectionDefs];
@@ -0,0 +1,13 @@
1
+ import colors from "./Colors";
2
+ import iconList from "./IconList";
3
+ import doDontMedia from "./DoDontMedia";
4
+
5
+ /** Opinionated brand-guide sections. Self-contained subtree: a build that does
6
+ * not import this module drops these sections (and their cmyk/pantone schemas
7
+ * and do/dont compositions) entirely. Exposed at
8
+ * `@drawnagency/primitives/components/sections/brand-guide`. */
9
+ // Un-annotated for the same generic-variance reason as coreSectionDefs.
10
+ export const brandGuideSectionDefs = [colors, iconList, doDontMedia];
11
+
12
+ export { ColorSpaceSchema, ColorItemSchema } from "./Colors/schema";
13
+ export type { ColorSpace, ColorItem } from "./Colors/schema";
@@ -0,0 +1,26 @@
1
+ import linkHeading from "./LinkHeading";
2
+ import subHeading from "./SubHeading";
3
+ import subSubHeading from "./SubSubHeading";
4
+ import prose from "./Prose";
5
+ import media from "./Media";
6
+ import button from "./Button";
7
+ import container from "./Container";
8
+ import spacer from "./Spacer";
9
+
10
+ /** Generic, brand-agnostic built-in sections. A non-brand site can register
11
+ * exactly these (plus its own custom sections) and tree-shake the brand-guide
12
+ * group out of its bundle. */
13
+ // Intentionally un-annotated: each section is SectionDefinition<its-own-shape>,
14
+ // and a wider `SectionDefinition<unknown>[]` annotation isn't assignable from
15
+ // them (generic param variance). The inferred union is what registerSection /
16
+ // registerSchema already accept.
17
+ export const coreSectionDefs = [
18
+ linkHeading,
19
+ subHeading,
20
+ subSubHeading,
21
+ prose,
22
+ media,
23
+ button,
24
+ container,
25
+ spacer,
26
+ ];
@@ -69,6 +69,7 @@ import { Button } from "../shared/Button";
69
69
  import { SplitButton } from "../shared/SplitButton";
70
70
  import { IconButton } from "../shared/IconButton";
71
71
  import { SegmentedControl } from "../shared/SegmentedControl";
72
+ import { isSectionVisibleInViewBranch } from "./view-branch";
72
73
  import { SettingsIcon } from "../shared/icons";
73
74
  import { ImageIcon, ListOrderedIcon, FilesIcon, X } from "lucide-react";
74
75
  import { ErrorBoundary } from "../shared/ErrorBoundary";
@@ -1216,12 +1217,13 @@ function EditorContent({
1216
1217
  const editingEnabled = isEditMode && !isPublishing;
1217
1218
 
1218
1219
  // View mode: render the active page's sections from the fetched branch, in page
1219
- // order (Live additionally filters to status === "live"). Edit mode: the active
1220
- // page's in-memory sections (the `sections` prop is already `activeSections`).
1220
+ // order. "Published" (live) shows only status === "live"; "Unpublished" (saved)
1221
+ // shows all in-progress edits except archived (see view-branch.ts). Edit mode:
1222
+ // the active page's in-memory sections (the `sections` prop is already `activeSections`).
1221
1223
  const displaySections = !isEditMode && viewSections !== null
1222
1224
  ? activePageSectionIds
1223
1225
  .map((id) => viewSections.find((s) => s.section.id === id))
1224
- .filter((s): s is LoadedSection => !!s && (viewBranch !== "live" || s.meta.status === "live"))
1226
+ .filter((s): s is LoadedSection => !!s && isSectionVisibleInViewBranch(s.meta.status, viewBranch))
1225
1227
  : sections;
1226
1228
 
1227
1229
  useEffect(() => {
@@ -1642,8 +1644,8 @@ function EditorToolbar({
1642
1644
  <div className="mx-auto max-w-screen-xl flex items-center justify-center px-4 py-2">
1643
1645
  <SegmentedControl
1644
1646
  options={[
1645
- { value: "saved", label: "Draft" },
1646
- { value: "live", label: "Live" },
1647
+ { value: "live", label: "Published" },
1648
+ { value: "saved", label: "Unpublished" },
1647
1649
  ]}
1648
1650
  value={viewBranch}
1649
1651
  onChange={(v) => setViewBranch(v as "saved" | "live")}
@@ -0,0 +1,14 @@
1
+ export type ViewBranch = "saved" | "live";
2
+
3
+ /**
4
+ * Section visibility for the editor's view-mode toggle.
5
+ *
6
+ * - `"live"` ("Published"): mirrors what an anonymous visitor sees on the
7
+ * deployed site — only `status === "live"` (the same predicate the real viewer
8
+ * applies in `viewer-page.ts`).
9
+ * - `"saved"` ("Unpublished"): all in-progress edits — every status EXCEPT
10
+ * `archived` — so edits to live content show alongside drafts.
11
+ */
12
+ export function isSectionVisibleInViewBranch(status: string, viewBranch: ViewBranch): boolean {
13
+ return viewBranch === "live" ? status === "live" : status !== "archived";
14
+ }
package/src/lib/loader.ts CHANGED
@@ -35,13 +35,19 @@ export function mergeSiteContent(
35
35
  console.warn(`Section file missing for id: ${id}, skipping`);
36
36
  continue;
37
37
  }
38
- // Transitional upgrade: rewrite legacy-typed JSON to a container BEFORE
39
- // validation, so retired/legacy files render on every load path. Returns the
40
- // same reference for non-legacy input (idempotent). We normalize the emitted
38
+ // Transitional upgrade: rewrite legacy-typed JSON BEFORE validation so
39
+ // retired/legacy files render on every load path. Handles: (1) plain section
40
+ // types promoted to container, (2) media+dodont → dodont_media (and the same
41
+ // for doDontImage grid items), and (3) recursion into container children so
42
+ // persisted containers with legacy children are also upgraded. Returns the same
43
+ // reference for non-legacy input (idempotent). We normalize the emitted
41
44
  // section's meta.type when an upgrade happens (no mutation of the caller's index).
42
45
  const raw = upgradeLegacySection(rawFile);
43
46
  const baseMeta = index.sections[id];
44
- const meta = raw !== rawFile ? { ...baseMeta, type: "container" } : baseMeta;
47
+ const upgradedType = raw && typeof raw === "object" ? (raw as Record<string, unknown>).type : undefined;
48
+ const meta = raw !== rawFile && typeof upgradedType === "string"
49
+ ? { ...baseMeta, type: upgradedType }
50
+ : baseMeta;
45
51
 
46
52
  if (canValidate && schema) {
47
53
  const result = schema.safeParse(raw);
@@ -16,12 +16,36 @@ const RETIRED = new Set(["split_content", "media_grid", "do_dont_grid", "do_dont
16
16
  export function upgradeLegacySection(raw: unknown): unknown {
17
17
  if (!raw || typeof raw !== "object") return raw;
18
18
  const section = raw as Obj;
19
- if (!RETIRED.has(section.type as string)) return raw; // same reference → idempotent
20
- if (section.type === "split_content") return upgradeSplitContent(section);
21
- if (section.type === "do_dont") return upgradeDoDont(section);
19
+ const type = section.type as string;
20
+ if (type === "media" && hasDoDont(section)) return { ...section, type: "dodont_media" };
21
+ if (type === "container") return upgradeContainerChildren(section);
22
+ if (!RETIRED.has(type)) return raw; // same reference → idempotent
23
+ if (type === "split_content") return upgradeSplitContent(section);
24
+ if (type === "do_dont") return upgradeDoDont(section);
22
25
  return upgradeGrid(section); // media_grid | do_dont_grid (per-item type drives the mapping)
23
26
  }
24
27
 
28
+ function hasDoDont(section: Obj): boolean {
29
+ const content = section.content;
30
+ return !!content && typeof content === "object" && (content as Obj).dodont != null;
31
+ }
32
+
33
+ /** Recurse into container children, upgrading media+dodont (and nested legacy)
34
+ * in place. Returns the SAME reference when nothing changed (idempotent). */
35
+ function upgradeContainerChildren(section: Obj): unknown {
36
+ const content = section.content as Obj | undefined;
37
+ const children = content && Array.isArray(content.children) ? (content.children as unknown[]) : null;
38
+ if (!children) return section;
39
+ let changed = false;
40
+ const mapped = children.map((child) => {
41
+ const up = upgradeLegacySection(child);
42
+ if (up !== child) changed = true;
43
+ return up;
44
+ });
45
+ if (!changed) return section;
46
+ return { ...section, content: { ...content, children: mapped } };
47
+ }
48
+
25
49
  /** Carry only known BaseMediaRef (+ video) fields; drops render artifacts (src/srcset/alt) and any stray keys. */
26
50
  function buildRef(item: Obj, forceImage: boolean): Obj {
27
51
  const type = forceImage ? "image" : (item.type as string);
@@ -42,16 +66,20 @@ function buildRef(item: Obj, forceImage: boolean): Obj {
42
66
 
43
67
  function mapRefItem(item: Obj, parentId: string, index: number): Obj {
44
68
  const content: Obj = {};
69
+ let type = "media";
45
70
  if (item.type === "linkedImage") {
46
71
  content.ref = buildRef(item, true);
47
72
  content.link = { kind: "external", href: item.href ?? "", target: item.target === "_blank" ? "_blank" : "_self" };
48
73
  } else if (item.type === "doDontImage") {
49
74
  content.ref = buildRef(item, true);
50
- if (item.doDont !== undefined) content.dodont = item.doDont;
75
+ if (item.doDont !== undefined) {
76
+ content.dodont = item.doDont;
77
+ type = "dodont_media";
78
+ }
51
79
  } else {
52
80
  content.ref = buildRef(item, false); // image | video
53
81
  }
54
- return { id: `${parentId}__media_${index}`, type: "media", content, options: {} };
82
+ return { id: `${parentId}__media_${index}`, type, content, options: {} };
55
83
  }
56
84
 
57
85
  function upgradeGrid(section: Obj): Obj {
@@ -35,7 +35,7 @@ export type SettingsFieldDef =
35
35
  coerce?: "number";
36
36
  /** When set, an empty-string ("") selection is normalized to `undefined`
37
37
  * before the content/options split, so an optional-enum field clears cleanly
38
- * (e.g. Media `dodont`, whose enum is strictly ["do","dont"]). Opt-in so other
38
+ * (e.g. an optional-enum content field). Opt-in so other
39
39
  * selects keep their "" value. */
40
40
  emptyIsUndefined?: boolean;
41
41
  options: { label: string; value: string }[];
@@ -108,6 +108,10 @@ export interface WrapperProps {
108
108
 
109
109
  export interface SectionDefinition<T = unknown> {
110
110
  type: string;
111
+ /** Grouping label. `"brand-guide"` marks opinionated sections separable from the
112
+ * generic core set; omitted/`"core"` is a generic section. Presentation/grouping
113
+ * only — does not affect registration. */
114
+ category?: "core" | "brand-guide";
111
115
  label: string;
112
116
  icon?: ReactNode;
113
117
  schema: ZodType<T>;
@@ -138,6 +142,7 @@ export interface SectionDefinition<T = unknown> {
138
142
 
139
143
  type DefineSectionInput<S extends ZodType> = {
140
144
  type: string;
145
+ category?: "core" | "brand-guide";
141
146
  label: string;
142
147
  icon?: ReactNode;
143
148
  schema: S;
@@ -92,21 +92,3 @@ export const FontNameSchema = z
92
92
  .max(120)
93
93
  .regex(/^[a-zA-Z0-9 ,'"-]+$/, "contains invalid font-name characters");
94
94
 
95
- export const ColorSpaceSchema = z.object({
96
- hex: HexColorSchema.optional(),
97
- rgb: z.string().optional(),
98
- cmyk: z.string().optional(),
99
- pantone: z.string().optional(),
100
- }).refine(
101
- (data) => data.hex !== undefined || data.rgb !== undefined || data.cmyk !== undefined || data.pantone !== undefined,
102
- { message: "At least one color space must be defined" },
103
- );
104
-
105
- export type ColorSpace = z.infer<typeof ColorSpaceSchema>;
106
-
107
- export const ColorItemSchema = z.object({
108
- name: z.string().optional(),
109
- spaces: z.array(ColorSpaceSchema).min(1),
110
- });
111
-
112
- export type ColorItem = z.infer<typeof ColorItemSchema>;