@drawnagency/primitives 0.1.65 → 0.1.67

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 (79) hide show
  1. package/dist/chunk-4VMAZZXT.js +968 -0
  2. package/dist/{chunk-QYQ3DQHI.js → chunk-KM6HOW4H.js} +127 -40
  3. package/dist/{chunk-CBWK3KPV.js → chunk-VU3XR362.js} +4 -0
  4. package/dist/{chunk-FECCYOPJ.js → chunk-WJ7RZ6CC.js} +12 -3
  5. package/dist/{chunk-VMDKWUMT.js → chunk-XNGG5AGI.js} +74 -17
  6. package/dist/components/primitives/ResolvedMedia.d.ts +3 -1
  7. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  8. package/dist/components/primitives/defineHeadingSection.d.ts +2 -0
  9. package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -1
  10. package/dist/components/primitives/index.js +3 -3
  11. package/dist/components/sections/Button/index.d.ts.map +1 -1
  12. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  13. package/dist/components/sections/Colors/schema.d.ts.map +1 -1
  14. package/dist/components/sections/Container/index.d.ts.map +1 -1
  15. package/dist/components/sections/DoDontMedia/index.d.ts.map +1 -1
  16. package/dist/components/sections/IconList/IconList.d.ts +2 -1
  17. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  18. package/dist/components/sections/IconList/IconListSettings.d.ts +2 -1
  19. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  20. package/dist/components/sections/IconList/index.d.ts +1 -0
  21. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  22. package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
  23. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
  24. package/dist/components/sections/Media/index.d.ts.map +1 -1
  25. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  26. package/dist/components/sections/Spacer/index.d.ts.map +1 -1
  27. package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
  28. package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
  29. package/dist/components/sections/all-sections.d.ts +1 -0
  30. package/dist/components/sections/all-sections.d.ts.map +1 -1
  31. package/dist/components/sections/brand-guide.d.ts +1 -0
  32. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  33. package/dist/components/sections/brand-guide.js +3 -3
  34. package/dist/components/sections/register-schemas.js +12 -942
  35. package/dist/components/sections/register.js +36 -0
  36. package/dist/components/shell/EditorLoading.d.ts +13 -0
  37. package/dist/components/shell/EditorLoading.d.ts.map +1 -0
  38. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  39. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  40. package/dist/hooks/useResolvedMedia.d.ts +2 -0
  41. package/dist/hooks/useResolvedMedia.d.ts.map +1 -1
  42. package/dist/index.js +21 -21
  43. package/dist/lib/index.js +2 -2
  44. package/dist/lib/insert-menu.d.ts +19 -0
  45. package/dist/lib/insert-menu.d.ts.map +1 -0
  46. package/dist/lib/registry.d.ts +23 -0
  47. package/dist/lib/registry.d.ts.map +1 -1
  48. package/dist/media/index.js +1 -1
  49. package/dist/media/resolve.d.ts.map +1 -1
  50. package/dist/types/database.d.ts +248 -34
  51. package/dist/types/database.d.ts.map +1 -1
  52. package/package.json +5 -1
  53. package/src/components/primitives/ResolvedMedia.tsx +51 -7
  54. package/src/components/primitives/defineHeadingSection.tsx +18 -2
  55. package/src/components/sections/Button/index.tsx +10 -3
  56. package/src/components/sections/Colors/index.tsx +15 -7
  57. package/src/components/sections/Colors/schema.ts +5 -2
  58. package/src/components/sections/Container/index.tsx +19 -3
  59. package/src/components/sections/DoDontMedia/index.tsx +18 -7
  60. package/src/components/sections/IconList/IconList.tsx +43 -19
  61. package/src/components/sections/IconList/IconListSettings.tsx +58 -11
  62. package/src/components/sections/IconList/index.tsx +51 -7
  63. package/src/components/sections/LinkHeading/index.tsx +2 -0
  64. package/src/components/sections/Media/MediaBlock.tsx +7 -0
  65. package/src/components/sections/Media/index.tsx +15 -6
  66. package/src/components/sections/Prose/index.tsx +9 -1
  67. package/src/components/sections/Spacer/index.tsx +2 -0
  68. package/src/components/sections/SubHeading/index.tsx +2 -0
  69. package/src/components/sections/SubSubHeading/index.tsx +2 -0
  70. package/src/components/shared/PasswordInput.tsx +3 -3
  71. package/src/components/shell/EditorLoading.tsx +37 -0
  72. package/src/components/shell/EditorShell.tsx +15 -20
  73. package/src/components/shell/SiteSettingsDisplay.tsx +28 -2
  74. package/src/hooks/useResolvedMedia.ts +9 -3
  75. package/src/lib/insert-menu.ts +42 -0
  76. package/src/lib/registry.ts +24 -0
  77. package/src/media/resolve.ts +4 -0
  78. package/src/types/database.ts +236 -36
  79. package/dist/{chunk-ZYXLNJ5M.js → chunk-AFK45HX2.js} +3 -3
@@ -1,45 +1,89 @@
1
1
  import { useState } from "react";
2
+ import { Check, X } from "lucide-react";
2
3
  import { Checkbox } from "../../shared/Checkbox";
3
4
  import { FormLabel } from "../../shared/FormLabel";
4
5
  import { IconPicker } from "../../primitives/IconPicker";
5
6
  import { getIcon } from "../../../lib/icons";
7
+ import { cn } from "../../../lib/cn";
6
8
 
7
9
  export function IconListSettings({
8
10
  icon: initialIcon,
9
11
  showLabel: initialShowLabel,
10
12
  stackText: initialStackText,
13
+ dodont: initialDodont,
11
14
  onChange,
12
15
  }: {
13
16
  icon: string | null;
14
17
  showLabel: boolean;
15
18
  stackText: boolean;
19
+ dodont?: "do" | "dont";
16
20
  onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
17
21
  }) {
18
22
  const [icon, setIcon] = useState(initialIcon);
19
23
  const [showLabel, setShowLabel] = useState(initialShowLabel);
20
24
  const [stackText, setStackText] = useState(initialStackText);
25
+ const [dodont, setDodont] = useState<"do" | "dont" | undefined>(initialDodont);
21
26
  const [pickerOpen, setPickerOpen] = useState(false);
22
27
 
23
- const emit = (overrides: Partial<{ icon: string | null; showLabel: boolean; stackText: boolean }>) =>
24
- onChange({ content: {}, options: { icon, showLabel, stackText, ...overrides } });
28
+ const emit = (
29
+ overrides: Partial<{ icon: string | null; showLabel: boolean; stackText: boolean; dodont: "do" | "dont" | undefined }>,
30
+ ) => onChange({ content: {}, options: { icon, showLabel, stackText, dodont, ...overrides } });
25
31
 
26
- const showIcons = icon !== null;
27
32
  const iconEntry = icon ? getIcon(icon) : null;
33
+ const dodontActive = dodont === "do" || dodont === "dont";
34
+
35
+ const tile = (value: "do" | "dont" | undefined, label: string, aria: string, glyph: React.ReactNode, tint: string) => (
36
+ <button
37
+ type="button"
38
+ aria-label={aria}
39
+ aria-pressed={dodont === value}
40
+ className={cn(
41
+ "cursor-pointer flex flex-col items-center gap-1 rounded border py-2 transition-colors",
42
+ tint,
43
+ dodont === value ? "border-primary bg-base-accent" : "border-base-200",
44
+ )}
45
+ onClick={() => { setDodont(value); emit({ dodont: value }); }}
46
+ >
47
+ {glyph}
48
+ <span className="text-xs font-medium">{label}</span>
49
+ </button>
50
+ );
28
51
 
29
52
  return (
30
53
  <div className="space-y-4">
54
+ <div>
55
+ <FormLabel>Do / Don't list</FormLabel>
56
+ <div className="grid grid-cols-3 gap-2">
57
+ {tile(
58
+ undefined,
59
+ "None",
60
+ "No do/don't",
61
+ <span className="flex h-5 w-5 items-center justify-center rounded-sm border border-dashed border-base-contrast-light/40" />,
62
+ "text-base-contrast-light",
63
+ )}
64
+ {tile("do", "Do", "Do list", <Check size={20} />, "text-green-600")}
65
+ {tile("dont", "Don't", "Don't list", <X size={20} />, "text-red-600")}
66
+ </div>
67
+ </div>
68
+
31
69
  <div className="relative">
32
70
  <FormLabel htmlFor="icon-picker-btn">Default Icon</FormLabel>
33
71
  <div className="flex items-center gap-2">
34
72
  <button
35
73
  id="icon-picker-btn"
36
- className="cursor-pointer flex items-center gap-2 rounded border border-base-200 px-3 py-1.5 text-sm text-base-contrast-light hover:border-primary hover:text-primary"
37
- onClick={() => setPickerOpen((prev) => !prev)}
74
+ disabled={dodontActive}
75
+ className={cn(
76
+ "flex items-center gap-2 rounded border border-base-200 px-3 py-1.5 text-sm text-base-contrast-light",
77
+ dodontActive
78
+ ? "cursor-not-allowed opacity-50"
79
+ : "cursor-pointer hover:border-primary hover:text-primary",
80
+ )}
81
+ onClick={() => { if (!dodontActive) setPickerOpen((prev) => !prev); }}
38
82
  >
39
83
  {iconEntry && <iconEntry.icon size={16} />}
40
84
  <span>{iconEntry?.label ?? "None"}</span>
41
85
  </button>
42
- {icon && (
86
+ {icon && !dodontActive && (
43
87
  <button
44
88
  className="cursor-pointer text-xs text-base-contrast-light hover:text-base-contrast"
45
89
  onClick={() => { setIcon(null); emit({ icon: null }); }}
@@ -48,15 +92,17 @@ export function IconListSettings({
48
92
  </button>
49
93
  )}
50
94
  </div>
51
- {pickerOpen && (
95
+ {dodontActive && (
96
+ <p className="mt-1 text-xs text-base-contrast-light/70">
97
+ Whole-list Do/Don't overrides the default icon.
98
+ </p>
99
+ )}
100
+ {pickerOpen && !dodontActive && (
52
101
  <div className="absolute top-full left-0 z-50 mt-1">
53
102
  <IconPicker
54
103
  selected={icon}
55
104
  onSelect={(newIcon) => {
56
- if (newIcon !== null) {
57
- setIcon(newIcon);
58
- emit({ icon: newIcon });
59
- }
105
+ if (newIcon !== null) { setIcon(newIcon); emit({ icon: newIcon }); }
60
106
  setPickerOpen(false);
61
107
  }}
62
108
  onClose={() => setPickerOpen(false)}
@@ -64,6 +110,7 @@ export function IconListSettings({
64
110
  </div>
65
111
  )}
66
112
  </div>
113
+
67
114
  <Checkbox
68
115
  checked={showLabel}
69
116
  onChange={(v) => { setShowLabel(v); emit({ showLabel: v }); }}
@@ -1,6 +1,8 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
- import { List } from "lucide-react";
3
+ import { List, Columns2 } from "lucide-react";
4
+ import type { SectionPreset } from "../../../lib/registry";
5
+ import type { SectionContent } from "../../../schemas/sections";
4
6
  import IconList from "./IconList";
5
7
  import { IconListSettings } from "./IconListSettings";
6
8
  import { pluralize } from "../../../lib/text";
@@ -16,27 +18,68 @@ export type IconListItem = z.infer<typeof IconListItemSchema>;
16
18
  const schema = z.object({
17
19
  type: z.literal("icon_list"),
18
20
  content: z.object({
19
- items: z.array(IconListItemSchema),
21
+ items: z
22
+ .array(IconListItemSchema)
23
+ .describe(
24
+ "The list entries. Each item has a label, descriptive text, an optional icon, and an optional do/don't marker.",
25
+ ),
20
26
  }),
21
- options: z.object({
22
- icon: z.string().nullable().optional(),
23
- showLabel: z.boolean().optional(),
24
- stackText: z.boolean().optional(),
25
- }).optional(),
27
+ options: z
28
+ .object({
29
+ icon: z
30
+ .string()
31
+ .nullable()
32
+ .describe("Default icon name applied to items that don't specify their own; null for no icon.")
33
+ .optional(),
34
+ showLabel: z.boolean().describe("When true, render each item's label.").optional(),
35
+ stackText: z
36
+ .boolean()
37
+ .describe("When true, stack the label and text vertically instead of inline.")
38
+ .optional(),
39
+ dodont: z
40
+ .enum(["do", "dont"])
41
+ .describe("Whole-list do/don't mode: render every item with a green check (do) or red x (don't).")
42
+ .optional(),
43
+ })
44
+ .optional(),
26
45
  });
27
46
 
47
+ // One-click "Do / Don't list": a 2-column container holding a green-check "Do"
48
+ // icon_list and a red-x "Don't" icon_list, using the whole-list options.dodont
49
+ // default. Same shape the legacy do_dont migration produces. Lives here (brand-guide
50
+ // layer); it reaches the core Add-menu only via SectionDefinition.presets.
51
+ const doDontListPreset: SectionPreset = {
52
+ id: "do_dont_list",
53
+ label: "Do / Don't list",
54
+ icon: <Columns2 size={18} />,
55
+ build: (newId): SectionContent => ({
56
+ type: "container",
57
+ content: {
58
+ columns: 2,
59
+ flow: "row",
60
+ children: [
61
+ { id: newId(), type: "icon_list", content: { items: [{ label: "", text: "" }] }, options: { dodont: "do" } },
62
+ { id: newId(), type: "icon_list", content: { items: [{ label: "", text: "" }] }, options: { dodont: "dont" } },
63
+ ],
64
+ },
65
+ }),
66
+ };
67
+
28
68
  export default defineSection({
29
69
  type: "icon_list",
30
70
  category: "brand-guide",
31
71
  label: "Icon List",
32
72
  icon: <List size={18} />,
33
73
  schema,
74
+ authoringNote:
75
+ "A list of labeled items, optionally with per-item icons and do/don't markers. Use for feature lists, guidelines, or do/don't guidance.",
34
76
  component: ({ content, options, onChange }) => (
35
77
  <IconList
36
78
  items={content.content.items}
37
79
  icon={options?.icon as string | null}
38
80
  showLabel={options?.showLabel as boolean}
39
81
  stackText={options?.stackText as boolean}
82
+ dodont={options?.dodont as "do" | "dont" | undefined}
40
83
  onChange={onChange ? (c) => onChange(c as typeof content) : undefined}
41
84
  />
42
85
  ),
@@ -46,4 +89,5 @@ export default defineSection({
46
89
  }),
47
90
  getLabel: (content) => pluralize(content.content.items.length, "icon"),
48
91
  settingsForm: IconListSettings,
92
+ presets: [doDontListPreset],
49
93
  });
@@ -9,4 +9,6 @@ export default defineHeadingSection({
9
9
  tag: "h2",
10
10
  placeholder: "Section heading",
11
11
  defaultHeading: "New Section",
12
+ authoringNote:
13
+ "A top-level section heading (renders an <h2>, reported to navigation as h1). Use it to begin a major named section of the guide; its text becomes a navigation entry.",
12
14
  });
@@ -33,12 +33,18 @@ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }:
33
33
  // placeholder. In edit mode these are absent and ResolvedMedia falls back to the
34
34
  // live manifest via context. (Mirrors a3e8913, which did this for MediaGrid/SplitContent.)
35
35
  const baked = refValue as Record<string, unknown>;
36
+ const bakedWidth = typeof baked.width === "number" ? baked.width : undefined;
37
+ const bakedHeight = typeof baked.height === "number" ? baked.height : undefined;
38
+ const aspectRatio =
39
+ bakedWidth && bakedHeight && !options.square ? `${bakedWidth} / ${bakedHeight}` : undefined;
36
40
  const resolved = (
37
41
  <ResolvedMedia
38
42
  imageId={refValue.imageId || undefined}
39
43
  src={baked.src as string | undefined}
40
44
  srcset={baked.srcset as string | undefined}
41
45
  alt={baked.alt as string | undefined}
46
+ width={bakedWidth}
47
+ height={bakedHeight}
42
48
  className="h-full w-full"
43
49
  imgClassName={fitClass}
44
50
  invertFrom={refValue.invertFrom}
@@ -75,6 +81,7 @@ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }:
75
81
  options.square && "aspect-square",
76
82
  onImageClick && "cursor-pointer",
77
83
  )}
84
+ style={aspectRatio ? { aspectRatio } : undefined}
78
85
  onClick={onImageClick}
79
86
  onPointerDown={onImageClick ? (e) => e.stopPropagation() : undefined}
80
87
  >
@@ -8,15 +8,22 @@ import { MediaBlock } from "./MediaBlock";
8
8
  const schema = z.object({
9
9
  type: z.literal("media"),
10
10
  content: z.object({
11
- ref: SingleMediaReferenceSchema,
12
- link: LinkValueSchema.optional(),
11
+ ref: SingleMediaReferenceSchema.describe(
12
+ "The image or video asset to display (a media reference by id). Required.",
13
+ ),
14
+ link: LinkValueSchema.describe(
15
+ "Optional link wrapping the media — an internal page or a safe external URL.",
16
+ ).optional(),
13
17
  }),
14
18
  options: z
15
19
  .object({
16
- square: z.boolean().optional(),
17
- showCaption: z.boolean().optional(),
18
- border: z.boolean().optional(),
19
- objectFit: z.enum(["cover", "contain"]).optional(),
20
+ square: z.boolean().describe("When true, crop the media to a 1:1 square aspect ratio.").optional(),
21
+ showCaption: z.boolean().describe("When true, render the asset's caption beneath it.").optional(),
22
+ border: z.boolean().describe("When true, draw a border around the media.").optional(),
23
+ objectFit: z
24
+ .enum(["cover", "contain"])
25
+ .describe("How the image fills its box: 'contain' (fit whole image) or 'cover' (fill and crop).")
26
+ .optional(),
20
27
  })
21
28
  .default({}),
22
29
  });
@@ -26,6 +33,8 @@ export default defineSection({
26
33
  label: "Media",
27
34
  icon: <ImageIcon size={18} />,
28
35
  schema,
36
+ authoringNote:
37
+ "A single image or video with optional caption and link. Use to show one asset; for several assets side by side, place multiple Media blocks inside a Container.",
29
38
  component: ({ content, options, onChange }) => (
30
39
  <MediaBlock
31
40
  refValue={content.content.ref}
@@ -6,7 +6,13 @@ import { stripHtmlToPlainText, truncate } from "../../../lib/text";
6
6
 
7
7
  const schema = z.object({
8
8
  type: z.literal("prose"),
9
- content: z.object({ body: z.string() }),
9
+ content: z.object({
10
+ body: z
11
+ .string()
12
+ .describe(
13
+ "Rich-text HTML for the paragraph block. Author with the editor; stored as sanitized HTML.",
14
+ ),
15
+ }),
10
16
  });
11
17
 
12
18
  export default defineSection({
@@ -14,6 +20,8 @@ export default defineSection({
14
20
  label: "Prose",
15
21
  icon: <AlignLeft size={18} />,
16
22
  schema,
23
+ authoringNote:
24
+ "A block of rich-text body copy (paragraphs, lists, inline emphasis, links). Use for any explanatory prose; author with the editor, not raw HTML.",
17
25
  richTextFields: ["body"],
18
26
  component: ({ content, onChange }) => (
19
27
  <Prose body={content.content.body} onChange={onChange ? (c) => onChange(c as typeof content) : undefined} />
@@ -13,6 +13,8 @@ export default defineSection({
13
13
  label: "Spacer",
14
14
  icon: <SquareDashed size={18} />,
15
15
  schema,
16
+ authoringNote:
17
+ "Vertical whitespace between sections. Use sparingly to add breathing room where default section spacing is insufficient.",
16
18
  component: () => <Spacer />,
17
19
  defaults: () => ({ type: "spacer" as const, content: {} }),
18
20
  });
@@ -10,4 +10,6 @@ export default defineHeadingSection({
10
10
  placeholder: "Sub heading",
11
11
  defaultHeading: "New Sub Heading",
12
12
  navExclusion: true,
13
+ authoringNote:
14
+ "A second-level heading (renders <h3>, nav level h2). Use under a Link Heading to divide a section; toggle Exclude from navigation to keep it out of the nav tree.",
13
15
  });
@@ -10,4 +10,6 @@ export default defineHeadingSection({
10
10
  placeholder: "Sub sub heading",
11
11
  defaultHeading: "New Sub Sub Heading",
12
12
  navExclusion: true,
13
+ authoringNote:
14
+ "A third-level heading (renders <h4>, nav level h3). Use for the finest-grained labeled subsection; toggle Exclude from navigation to omit it from the nav tree.",
13
15
  });
@@ -43,15 +43,15 @@ export function PasswordInput({
43
43
  className={cn(
44
44
  "w-full rounded border border-base-200 bg-base px-3 py-2 text-sm text-base-contrast",
45
45
  "focus:border-base-contrast focus:outline-none focus:ring-1 focus:ring-base-contrast",
46
- showToggle && "pr-9",
46
+ showToggle && "pr-10",
47
47
  disabled && "cursor-not-allowed opacity-50",
48
48
  )}
49
49
  />
50
50
  {showToggle && (
51
51
  <IconButton
52
- icon={revealed ? <EyeOff size={16} /> : <Eye size={16} />}
52
+ icon={revealed ? <EyeOff size={20} /> : <Eye size={20} />}
53
53
  label={revealed ? "Hide password" : "Show password"}
54
- size="sm"
54
+ size="md"
55
55
  aria-pressed={revealed}
56
56
  disabled={disabled}
57
57
  onClick={() => setRevealed((v) => !v)}
@@ -0,0 +1,37 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Loader2 } from "lucide-react";
3
+
4
+ interface EditorLoadingProps {
5
+ /** Delay before the spinner appears, to avoid a flash on instant cache hits. */
6
+ delayMs?: number;
7
+ }
8
+
9
+ /**
10
+ * Full-screen loading state shown while EditorShell hydrates and loads content.
11
+ * Keeps the existing aria-busy contract on the wrapper at all times; the visible
12
+ * spinner is delayed so a fast Dexie cache hit doesn't flash it. SSR-safe: the
13
+ * initial render shows the wrapper with no spinner, matching the server output.
14
+ */
15
+ export function EditorLoading({ delayMs = 200 }: EditorLoadingProps) {
16
+ const [showSpinner, setShowSpinner] = useState(false);
17
+
18
+ useEffect(() => {
19
+ const timer = setTimeout(() => setShowSpinner(true), delayMs);
20
+ return () => clearTimeout(timer);
21
+ }, [delayMs]);
22
+
23
+ return (
24
+ <div
25
+ className="flex min-h-screen items-center justify-center"
26
+ aria-busy="true"
27
+ aria-label="Loading content"
28
+ >
29
+ {showSpinner && (
30
+ <Loader2
31
+ className="size-8 animate-spin text-primary"
32
+ data-testid="editor-loading-spinner"
33
+ />
34
+ )}
35
+ </div>
36
+ );
37
+ }
@@ -27,6 +27,7 @@ import { SectionTypePicker } from "./SectionTypePicker";
27
27
  import { ensureSectionsRegistered } from "../sections/register";
28
28
  import { getSection, getAllSections } from "../../lib/registry";
29
29
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
30
+ import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
30
31
 
31
32
  ensureSectionsRegistered();
32
33
  import { BugReportFAB } from "./BugReportFAB";
@@ -75,6 +76,7 @@ import { ImageIcon, ListOrderedIcon, FilesIcon, X } from "lucide-react";
75
76
  import { ErrorBoundary } from "../shared/ErrorBoundary";
76
77
  import { HistoryToolbar } from "./HistoryToolbar";
77
78
  import { RestoreModal } from "./RestoreModal";
79
+ import { EditorLoading } from "./EditorLoading";
78
80
  import ViewRenderer from "../sections/ViewRenderer";
79
81
 
80
82
  export { useMediaLibrary } from "./MediaLibraryContext";
@@ -590,13 +592,16 @@ export default function EditorShell({
590
592
  );
591
593
 
592
594
  const onAddSection = useCallback(
593
- (insertIndex: number, type: string) => {
594
- const definition = getSection(type);
595
- if (!definition || !resolvedActivePage) return;
596
- const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
597
- ? crypto.randomUUID() : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
598
- const content = definition.defaults() as object;
599
- const meta = { type, status: "draft" as const, access: [] as string[] };
595
+ (insertIndex: number, selection: string) => {
596
+ if (!resolvedActivePage) return;
597
+ const newId = () =>
598
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
599
+ ? crypto.randomUUID()
600
+ : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
601
+ const content = resolveInsertedContent(selection, getAllSections(), newId);
602
+ if (!content) return;
603
+ const id = newId();
604
+ const meta = { type: content.type, status: "draft" as const, access: [] as string[] };
600
605
  const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
601
606
 
602
607
  setSections((prev) => [...prev, newSection]);
@@ -808,7 +813,7 @@ export default function EditorShell({
808
813
  // --- Render ---
809
814
 
810
815
  if (shellState.phase === "loading-content") {
811
- return <div className="min-h-screen" aria-busy="true" aria-label="Loading content" />;
816
+ return <EditorLoading />;
812
817
  }
813
818
 
814
819
  if (shellState.phase === "error") {
@@ -1188,7 +1193,7 @@ function EditorContent({
1188
1193
  dirtySectionIds: Set<string>;
1189
1194
  isPublishing: boolean;
1190
1195
  onSectionChange: (sectionId: string, content: SectionContent) => void;
1191
- onAddSection: (insertIndex: number, type: string) => void;
1196
+ onAddSection: (insertIndex: number, selection: string) => void;
1192
1197
  onDeleteSection: (sectionId: string) => void;
1193
1198
  onReorderSections: (fromIndex: number, toIndex: number) => void;
1194
1199
  onBlockMove: (move: BlockMove) => void;
@@ -1204,17 +1209,7 @@ function EditorContent({
1204
1209
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
1205
1210
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
1206
1211
 
1207
- const typeOptions = useMemo(
1208
- () =>
1209
- getAllSections()
1210
- .filter((def) => def.type !== "spacer")
1211
- .map((def) => ({
1212
- type: def.type,
1213
- label: def.label,
1214
- icon: def.icon,
1215
- })),
1216
- [],
1217
- );
1212
+ const typeOptions = useMemo(() => buildInsertMenuOptions(getAllSections()), []);
1218
1213
 
1219
1214
  const editingEnabled = isEditMode && !isPublishing;
1220
1215
 
@@ -48,8 +48,15 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
48
48
  }
49
49
 
50
50
  function handleColorChange(color: string) {
51
- const contrast = deriveContrast(color);
52
- onChange({ ...siteConfig, primaryColor: color, primaryContrast: contrast });
51
+ update({ primaryColor: color });
52
+ }
53
+
54
+ function handleContrastChange(color: string) {
55
+ update({ primaryContrast: color });
56
+ }
57
+
58
+ function resetContrastToAuto() {
59
+ update({ primaryContrast: deriveContrast(siteConfig.primaryColor) });
53
60
  }
54
61
 
55
62
  function handleFontChange(field: "headingFont" | "bodyFont", family: string) {
@@ -79,6 +86,25 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
79
86
  </div>
80
87
  </div>
81
88
 
89
+ <div>
90
+ <FormLabel>Accent (button &amp; highlight text)</FormLabel>
91
+ <div className="flex items-center gap-3">
92
+ <ColorPicker
93
+ value={siteConfig.primaryContrast}
94
+ onChange={handleContrastChange}
95
+ label="Accent color"
96
+ />
97
+ <span className="text-sm text-base-contrast-light">{siteConfig.primaryContrast}</span>
98
+ <Button type="button" variant="ghost" size="sm" onClick={resetContrastToAuto}>
99
+ Reset to auto
100
+ </Button>
101
+ </div>
102
+ <p className="mt-1 text-xs text-base-contrast/70">
103
+ Shown as text on primary-colored buttons and as the text-selection color. "Reset to auto" derives a
104
+ legible light or dark value from your primary brand color.
105
+ </p>
106
+ </div>
107
+
82
108
  <div>
83
109
  <FormLabel>Favicon</FormLabel>
84
110
  <div className="flex items-center gap-3">
@@ -8,6 +8,8 @@ export interface ResolvedMediaResult {
8
8
  poster: string | undefined;
9
9
  alt: string;
10
10
  kind: "image" | "animated" | "video";
11
+ width?: number;
12
+ height?: number;
11
13
  }
12
14
 
13
15
  export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResult {
@@ -26,10 +28,12 @@ export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResu
26
28
  const item = manifestItem ?? pendingItem;
27
29
  const alt = item?.alt ?? "";
28
30
  const kind = item?.kind ?? "image";
31
+ const width = item?.width;
32
+ const height = item?.height;
29
33
 
30
34
  const localUrl = pendingLocalUrls[imageId];
31
35
  if (localUrl) {
32
- return { src: localUrl, srcset: undefined, poster: undefined, alt, kind };
36
+ return { src: localUrl, srcset: undefined, poster: undefined, alt, kind, width, height };
33
37
  }
34
38
 
35
39
  if (manifestItem) {
@@ -42,13 +46,15 @@ export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResu
42
46
  poster: undefined,
43
47
  alt,
44
48
  kind,
49
+ width: resolved.width,
50
+ height: resolved.height,
45
51
  };
46
52
  }
47
53
  if (resolved && resolved.tag === "video") {
48
- return { src: resolved.src, srcset: undefined, poster: resolved.poster, alt, kind };
54
+ return { src: resolved.src, srcset: undefined, poster: resolved.poster, alt, kind, width: resolved.width, height: resolved.height };
49
55
  }
50
56
  }
51
57
 
52
- return { src: undefined, srcset: undefined, poster: undefined, alt, kind };
58
+ return { src: undefined, srcset: undefined, poster: undefined, alt, kind, width, height };
53
59
  }, [imageId, ctx]);
54
60
  }
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from "react";
2
+ import type { SectionDefinition } from "./registry";
3
+ import type { SectionContent } from "../schemas/sections";
4
+
5
+ /** Namespacing prefix for preset selections in the Add-section menu. */
6
+ export const PRESET_PREFIX = "__preset:";
7
+
8
+ /** One Add-section menu entry: a section `type`, or `__preset:<presetId>`. */
9
+ export interface InsertMenuOption {
10
+ type: string;
11
+ label: string;
12
+ icon?: ReactNode;
13
+ }
14
+
15
+ /** Every non-spacer section type, followed by presets contributed by registered
16
+ * sections. Presets appear only when their owning section is registered, so a
17
+ * core-only build (no brand-guide sections) shows no brand-guide presets. */
18
+ export function buildInsertMenuOptions(sections: SectionDefinition[]): InsertMenuOption[] {
19
+ const typeOptions = sections
20
+ .filter((def) => def.type !== "spacer")
21
+ .map((def) => ({ type: def.type, label: def.label, icon: def.icon }));
22
+ const presetOptions = sections
23
+ .flatMap((def) => def.presets ?? [])
24
+ .map((p) => ({ type: `${PRESET_PREFIX}${p.id}`, label: p.label, icon: p.icon }));
25
+ return [...typeOptions, ...presetOptions];
26
+ }
27
+
28
+ /** Resolve an Add-menu selection to ready-to-insert content. `newId` mints ids for
29
+ * any nested children a preset creates. Returns null for an unknown type/preset. */
30
+ export function resolveInsertedContent(
31
+ selection: string,
32
+ sections: SectionDefinition[],
33
+ newId: () => string,
34
+ ): SectionContent | null {
35
+ if (selection.startsWith(PRESET_PREFIX)) {
36
+ const id = selection.slice(PRESET_PREFIX.length);
37
+ const preset = sections.flatMap((def) => def.presets ?? []).find((p) => p.id === id);
38
+ return preset ? preset.build(newId) : null;
39
+ }
40
+ const def = sections.find((d) => d.type === selection);
41
+ return def ? (def.defaults() as SectionContent) : null;
42
+ }
@@ -2,6 +2,7 @@ import type { ZodType, z } from "zod";
2
2
  import type { ComponentType, ReactNode } from "react";
3
3
  import type { Audience } from "../schemas/auth";
4
4
  import type { LinkValue } from "../schemas/link";
5
+ import type { SectionContent } from "../schemas/sections";
5
6
 
6
7
  // --- Settings field types ---
7
8
 
@@ -106,6 +107,19 @@ export interface WrapperProps {
106
107
 
107
108
  // --- Section definition ---
108
109
 
110
+ /**
111
+ * A ready-to-insert composite (e.g. a container of preconfigured child sections),
112
+ * surfaced in the Add-section menu. Contributed by the section definition that owns
113
+ * the composition, so it rides the section registry and disappears in a core-only
114
+ * build that never registers that section. `newId` mints unique ids for nested children.
115
+ */
116
+ export interface SectionPreset {
117
+ id: string;
118
+ label: string;
119
+ icon?: ReactNode;
120
+ build(newId: () => string): SectionContent;
121
+ }
122
+
109
123
  export interface SectionDefinition<T = unknown> {
110
124
  type: string;
111
125
  /** Grouping label. `"brand-guide"` marks opinionated sections separable from the
@@ -136,6 +150,14 @@ export interface SectionDefinition<T = unknown> {
136
150
  * into a trailing 'Other' tab.
137
151
  */
138
152
  settingsTabs?: { label: string; fields: string[] }[];
153
+ /** Composite blueprints for the Add-section menu (see SectionPreset). */
154
+ presets?: SectionPreset[];
155
+ /**
156
+ * Human/agent-facing prose describing when to use this section type and how to
157
+ * author it well. Surfaced by the get_section_schema tool as the JSON Schema
158
+ * top-level `description`. Presentation/metadata only — not validated.
159
+ */
160
+ authoringNote?: string;
139
161
  }
140
162
 
141
163
  // --- defineSection ---
@@ -162,6 +184,8 @@ type DefineSectionInput<S extends ZodType> = {
162
184
  * into a trailing 'Other' tab.
163
185
  */
164
186
  settingsTabs?: { label: string; fields: string[] }[];
187
+ presets?: SectionPreset[];
188
+ authoringNote?: string;
165
189
  };
166
190
 
167
191
  export function defineSection<S extends ZodType>(