@drawnagency/primitives 0.1.66 → 0.1.68

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 (88) hide show
  1. package/dist/chunk-4YEOTFVU.js +14 -0
  2. package/dist/{chunk-VU3XR362.js → chunk-6UDYJQL6.js} +1 -1
  3. package/dist/{chunk-7IMQVDDJ.js → chunk-EWKCEPMW.js} +1 -1
  4. package/dist/{chunk-XNGG5AGI.js → chunk-KWBM27UR.js} +1 -1
  5. package/dist/{chunk-TEYTLQVH.js → chunk-QKOIEWZ4.js} +33 -1
  6. package/dist/chunk-TYYCKVAW.js +968 -0
  7. package/dist/{chunk-VEIDANEU.js → chunk-VVK6ZRCN.js} +128 -41
  8. package/dist/{chunk-ZYXLNJ5M.js → chunk-WNPMBFD6.js} +4 -4
  9. package/dist/{chunk-DQTKWKSK.js → chunk-WXVKX6PY.js} +12 -3
  10. package/dist/components/chat/ChatPanel.d.ts +31 -0
  11. package/dist/components/chat/ChatPanel.d.ts.map +1 -0
  12. package/dist/components/chat/ChatWidget.d.ts +11 -0
  13. package/dist/components/chat/ChatWidget.d.ts.map +1 -0
  14. package/dist/components/primitives/defineHeadingSection.d.ts +2 -0
  15. package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -1
  16. package/dist/components/primitives/index.js +5 -5
  17. package/dist/components/sections/Button/index.d.ts.map +1 -1
  18. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  19. package/dist/components/sections/Colors/schema.d.ts.map +1 -1
  20. package/dist/components/sections/Container/index.d.ts.map +1 -1
  21. package/dist/components/sections/DoDontMedia/index.d.ts.map +1 -1
  22. package/dist/components/sections/IconList/IconList.d.ts +2 -1
  23. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  24. package/dist/components/sections/IconList/IconListSettings.d.ts +2 -1
  25. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  26. package/dist/components/sections/IconList/index.d.ts +1 -0
  27. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  28. package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
  29. package/dist/components/sections/Media/index.d.ts.map +1 -1
  30. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  31. package/dist/components/sections/Spacer/index.d.ts.map +1 -1
  32. package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
  33. package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
  34. package/dist/components/sections/all-sections.d.ts +1 -0
  35. package/dist/components/sections/all-sections.d.ts.map +1 -1
  36. package/dist/components/sections/brand-guide.d.ts +1 -0
  37. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  38. package/dist/components/sections/brand-guide.js +5 -5
  39. package/dist/components/sections/register-schemas.js +13 -943
  40. package/dist/components/sections/register.js +36 -0
  41. package/dist/components/shell/ChatbotConfigModal.d.ts +13 -0
  42. package/dist/components/shell/ChatbotConfigModal.d.ts.map +1 -0
  43. package/dist/components/shell/EditorShell.d.ts +2 -1
  44. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  45. package/dist/index.js +31 -24
  46. package/dist/lib/index.d.ts +1 -0
  47. package/dist/lib/index.d.ts.map +1 -1
  48. package/dist/lib/index.js +7 -4
  49. package/dist/lib/insert-menu.d.ts +19 -0
  50. package/dist/lib/insert-menu.d.ts.map +1 -0
  51. package/dist/lib/platform-broker.d.ts +7 -0
  52. package/dist/lib/platform-broker.d.ts.map +1 -1
  53. package/dist/lib/platform-broker.js +27 -1
  54. package/dist/lib/registry.d.ts +23 -0
  55. package/dist/lib/registry.d.ts.map +1 -1
  56. package/dist/media/index.js +3 -3
  57. package/dist/schemas/index.js +6 -2
  58. package/dist/schemas/site-config.d.ts +30 -0
  59. package/dist/schemas/site-config.d.ts.map +1 -1
  60. package/dist/types/database.d.ts +296 -34
  61. package/dist/types/database.d.ts.map +1 -1
  62. package/package.json +5 -1
  63. package/src/components/chat/ChatPanel.tsx +152 -0
  64. package/src/components/chat/ChatWidget.tsx +38 -0
  65. package/src/components/primitives/defineHeadingSection.tsx +18 -2
  66. package/src/components/sections/Button/index.tsx +10 -3
  67. package/src/components/sections/Colors/index.tsx +15 -7
  68. package/src/components/sections/Colors/schema.ts +5 -2
  69. package/src/components/sections/Container/index.tsx +19 -3
  70. package/src/components/sections/DoDontMedia/index.tsx +18 -7
  71. package/src/components/sections/IconList/IconList.tsx +43 -19
  72. package/src/components/sections/IconList/IconListSettings.tsx +58 -11
  73. package/src/components/sections/IconList/index.tsx +51 -7
  74. package/src/components/sections/LinkHeading/index.tsx +2 -0
  75. package/src/components/sections/Media/index.tsx +15 -6
  76. package/src/components/sections/Prose/index.tsx +9 -1
  77. package/src/components/sections/Spacer/index.tsx +2 -0
  78. package/src/components/sections/SubHeading/index.tsx +2 -0
  79. package/src/components/sections/SubSubHeading/index.tsx +2 -0
  80. package/src/components/shell/ChatbotConfigModal.tsx +152 -0
  81. package/src/components/shell/EditorShell.tsx +41 -20
  82. package/src/lib/index.ts +1 -0
  83. package/src/lib/insert-menu.ts +42 -0
  84. package/src/lib/platform-broker.ts +36 -0
  85. package/src/lib/registry.ts +24 -0
  86. package/src/schemas/site-config.ts +36 -0
  87. package/src/types/database.ts +284 -36
  88. package/dist/chunk-ZGFAYZWB.js +0 -8
@@ -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
  });
@@ -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
  });
@@ -0,0 +1,152 @@
1
+ import { useState } from "react";
2
+ import { EditorModal } from "./EditorModal";
3
+ import { Button } from "../shared/Button";
4
+ import { cn } from "../../lib/cn";
5
+ import ChatPanel from "../chat/ChatPanel";
6
+ import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
7
+ import type { Audience } from "../../auth/types";
8
+
9
+ interface Props {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ siteConfig: SiteConfig;
13
+ onSiteConfigChange: (config: SiteConfig) => void;
14
+ audiences: Audience[];
15
+ platformEnabled: boolean;
16
+ }
17
+
18
+ function slugify(label: string): string {
19
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
20
+ }
21
+
22
+ function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[]; selected: string[]; onToggle: (name: string) => void }) {
23
+ return (
24
+ <div className="flex flex-wrap gap-1">
25
+ {audiences.map((a) => (
26
+ <button key={a.name} type="button" onClick={() => onToggle(a.name)}
27
+ className={cn("rounded-full border px-2 py-0.5 text-xs",
28
+ selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
29
+ style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color } : undefined}>
30
+ {a.displayName}
31
+ </button>
32
+ ))}
33
+ {audiences.length === 0 && <span className="text-xs text-base-contrast-light">No audiences configured</span>}
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props) {
39
+ const chatbot = siteConfig.chatbot;
40
+ const [newTaskLabel, setNewTaskLabel] = useState("");
41
+ const [showTest, setShowTest] = useState(false);
42
+
43
+ const patch = (p: Partial<ChatbotConfig>) =>
44
+ onSiteConfigChange({ ...siteConfig, chatbot: { ...chatbot, ...p } });
45
+
46
+ const patchTask = (i: number, p: Partial<ChatbotTask>) =>
47
+ patch({ tasks: chatbot.tasks.map((t, idx) => (idx === i ? { ...t, ...p } : t)) });
48
+
49
+ function addTask() {
50
+ const label = newTaskLabel.trim();
51
+ if (!label || chatbot.tasks.length >= 20) return;
52
+ let id = slugify(label);
53
+ if (chatbot.tasks.some((t) => t.id === id)) {
54
+ for (let n = 2; ; n++) {
55
+ const suffix = `-${n}`;
56
+ const candidate = `${id.slice(0, 32 - suffix.length)}${suffix}`;
57
+ if (!chatbot.tasks.some((t) => t.id === candidate)) {
58
+ id = candidate;
59
+ break;
60
+ }
61
+ }
62
+ }
63
+ patch({ tasks: [...chatbot.tasks, { id, label, prompt: "", access: [] }] });
64
+ setNewTaskLabel("");
65
+ }
66
+
67
+ const toggleName = (list: string[], name: string) =>
68
+ list.includes(name) ? list.filter((n) => n !== name) : [...list, name];
69
+
70
+ return (
71
+ <EditorModal isOpen={isOpen} onClose={onClose} title="Chatbot" size="settings" noPadding>
72
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4">
73
+ {!platformEnabled && (
74
+ <div className="rounded border border-base-200 bg-base-accent px-3 py-2 text-sm text-base-contrast">
75
+ Chat is not enabled for this site — contact Drawn Agency. You can still edit the
76
+ configuration below; it activates when chat is switched on.
77
+ </div>
78
+ )}
79
+
80
+ <label className="flex items-center gap-2 text-sm text-base-contrast">
81
+ <input type="checkbox" aria-label="Enable chatbot" checked={chatbot.enabled}
82
+ onChange={(e) => patch({ enabled: e.target.checked })} />
83
+ Show the chat widget to viewers
84
+ </label>
85
+
86
+ <div>
87
+ <p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
88
+ <p className="mb-2 text-xs text-base-contrast-light">Who sees the widget. None selected = all audiences.</p>
89
+ <AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
90
+ </div>
91
+
92
+ <div>
93
+ <label htmlFor="chatbot-system-prompt" className="mb-1 block text-sm font-medium text-base-contrast">System prompt</label>
94
+ <textarea id="chatbot-system-prompt" aria-label="System prompt" rows={5} maxLength={8000}
95
+ value={chatbot.systemPrompt}
96
+ onChange={(e) => patch({ systemPrompt: e.target.value })}
97
+ placeholder="Describe the brand's voice and how the assistant should behave — e.g. “Speak warmly and concisely; always reference our color names, never hex codes.”"
98
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
99
+ <p className="text-right text-xs text-base-contrast-light">{chatbot.systemPrompt.length}/8000</p>
100
+ </div>
101
+
102
+ <div>
103
+ <p className="mb-1 text-sm font-medium text-base-contrast">Tasks</p>
104
+ <p className="mb-2 text-xs text-base-contrast-light">Goal presets shown as a dropdown in the widget. Each task layers its own instructions onto the system prompt.</p>
105
+ <div className="space-y-3">
106
+ {chatbot.tasks.map((t, i) => (
107
+ <div key={t.id} className="rounded border border-base-200 p-2">
108
+ <div className="mb-1 flex items-center justify-between gap-2">
109
+ <input aria-label={`Task ${t.id} label`} value={t.label} maxLength={60}
110
+ onChange={(e) => patchTask(i, { label: e.target.value })}
111
+ className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
112
+ <Button type="button" variant="destructive"
113
+ onClick={() => patch({ tasks: chatbot.tasks.filter((_, idx) => idx !== i) })}>
114
+ Remove
115
+ </Button>
116
+ </div>
117
+ <textarea aria-label={`Task ${t.id} prompt`} rows={2} maxLength={4000} value={t.prompt}
118
+ onChange={(e) => patchTask(i, { prompt: e.target.value })}
119
+ placeholder="Instructions for this task…"
120
+ className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm" />
121
+ <AudienceChips audiences={audiences} selected={t.access} onToggle={(n) => patchTask(i, { access: toggleName(t.access, n) })} />
122
+ </div>
123
+ ))}
124
+ </div>
125
+ <div className="mt-2 flex gap-2">
126
+ <input aria-label="New task label" value={newTaskLabel} maxLength={60}
127
+ onChange={(e) => setNewTaskLabel(e.target.value)}
128
+ placeholder="e.g. Draft a social post"
129
+ className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
130
+ <Button type="button" onClick={addTask}>Add task</Button>
131
+ </div>
132
+ </div>
133
+
134
+ <div>
135
+ <Button type="button" onClick={() => setShowTest((s) => !s)} disabled={!platformEnabled}>
136
+ {showTest ? "Hide test chat" : "Test with current draft"}
137
+ </Button>
138
+ <p className="mt-1 text-xs text-base-contrast-light">
139
+ Tests your unsaved prompt and tasks. Answers ground on published content.
140
+ </p>
141
+ {showTest && platformEnabled && (
142
+ <ChatPanel
143
+ open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
144
+ tasks={chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
145
+ isEditor draftChatbot={chatbot}
146
+ />
147
+ )}
148
+ </div>
149
+ </div>
150
+ </EditorModal>
151
+ );
152
+ }
@@ -19,6 +19,7 @@ import type { BlockMove } from "../../lib/block-move";
19
19
  import { EditorModalProvider, useEditorModal } from "./EditorModalContext";
20
20
  import { EditorModal } from "./EditorModal";
21
21
  import { SiteSettingsModal } from "./SiteSettingsModal";
22
+ import { ChatbotConfigModal } from "./ChatbotConfigModal";
22
23
  import { MediaLibraryModal } from "./MediaLibraryModal";
23
24
  import { MediaLibraryContext } from "./MediaLibraryContext";
24
25
  import { ProcessingIndicator } from "./ProcessingIndicator";
@@ -27,6 +28,7 @@ import { SectionTypePicker } from "./SectionTypePicker";
27
28
  import { ensureSectionsRegistered } from "../sections/register";
28
29
  import { getSection, getAllSections } from "../../lib/registry";
29
30
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
31
+ import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
30
32
 
31
33
  ensureSectionsRegistered();
32
34
  import { BugReportFAB } from "./BugReportFAB";
@@ -71,7 +73,7 @@ import { IconButton } from "../shared/IconButton";
71
73
  import { SegmentedControl } from "../shared/SegmentedControl";
72
74
  import { computeDisplaySections } from "./view-branch";
73
75
  import { SettingsIcon } from "../shared/icons";
74
- import { ImageIcon, ListOrderedIcon, FilesIcon, X } from "lucide-react";
76
+ import { ImageIcon, ListOrderedIcon, FilesIcon, MessageCircleIcon, X } from "lucide-react";
75
77
  import { ErrorBoundary } from "../shared/ErrorBoundary";
76
78
  import { HistoryToolbar } from "./HistoryToolbar";
77
79
  import { RestoreModal } from "./RestoreModal";
@@ -103,6 +105,10 @@ interface Props {
103
105
  passwordToggle: boolean;
104
106
  };
105
107
  currentUser: { email: string; role: "owner" | "editor" } | null;
108
+ // Whether the platform (admin) has enabled the chatbot for this site.
109
+ // Controls the configurator's banner + test panel; the config UI itself
110
+ // always works so editors can prepare before activation.
111
+ chatbotPlatformEnabled?: boolean;
106
112
  }
107
113
 
108
114
  export default function EditorShell({
@@ -112,6 +118,7 @@ export default function EditorShell({
112
118
  audiences: initialAudiences,
113
119
  capabilities,
114
120
  currentUser,
121
+ chatbotPlatformEnabled = false,
115
122
  }: Props) {
116
123
  const [shellState, setShellState] = useState<ShellState>({ phase: "loading-content" });
117
124
  const [sections, setSections] = useState<LoadedSection[]>([]);
@@ -142,6 +149,7 @@ export default function EditorShell({
142
149
  const [showRestoreModal, setShowRestoreModal] = useState(false);
143
150
  const [showOrderingModal, setShowOrderingModal] = useState(false);
144
151
  const [showPagesModal, setShowPagesModal] = useState(false);
152
+ const [showChatbotConfig, setShowChatbotConfig] = useState(false);
145
153
  const [isRestoring, setIsRestoring] = useState(false);
146
154
  const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
147
155
  const [activePageId, setActivePageId] = useState<string | null>(null);
@@ -591,13 +599,16 @@ export default function EditorShell({
591
599
  );
592
600
 
593
601
  const onAddSection = useCallback(
594
- (insertIndex: number, type: string) => {
595
- const definition = getSection(type);
596
- if (!definition || !resolvedActivePage) return;
597
- const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
598
- ? crypto.randomUUID() : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
599
- const content = definition.defaults() as object;
600
- const meta = { type, status: "draft" as const, access: [] as string[] };
602
+ (insertIndex: number, selection: string) => {
603
+ if (!resolvedActivePage) return;
604
+ const newId = () =>
605
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
606
+ ? crypto.randomUUID()
607
+ : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
608
+ const content = resolveInsertedContent(selection, getAllSections(), newId);
609
+ if (!content) return;
610
+ const id = newId();
611
+ const meta = { type: content.type, status: "draft" as const, access: [] as string[] };
601
612
  const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
602
613
 
603
614
  setSections((prev) => [...prev, newSection]);
@@ -883,6 +894,7 @@ export default function EditorShell({
883
894
  onRestoreClick={() => setShowRestoreModal(true)}
884
895
  onOrderingClick={() => setShowOrderingModal(true)}
885
896
  onPagesClick={() => setShowPagesModal(true)}
897
+ onChatbotClick={() => setShowChatbotConfig(true)}
886
898
  />
887
899
 
888
900
  {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
@@ -933,6 +945,16 @@ export default function EditorShell({
933
945
  capabilities={capabilities}
934
946
  currentUser={currentUser}
935
947
  />
948
+ {siteConfig && (
949
+ <ChatbotConfigModal
950
+ isOpen={showChatbotConfig}
951
+ onClose={() => setShowChatbotConfig(false)}
952
+ siteConfig={siteConfig}
953
+ onSiteConfigChange={handleSiteConfigChange}
954
+ audiences={audiences}
955
+ platformEnabled={chatbotPlatformEnabled}
956
+ />
957
+ )}
936
958
  <EditorModal
937
959
  isOpen={pendingDeleteSectionId !== null}
938
960
  onClose={() => setPendingDeleteSectionId(null)}
@@ -1189,7 +1211,7 @@ function EditorContent({
1189
1211
  dirtySectionIds: Set<string>;
1190
1212
  isPublishing: boolean;
1191
1213
  onSectionChange: (sectionId: string, content: SectionContent) => void;
1192
- onAddSection: (insertIndex: number, type: string) => void;
1214
+ onAddSection: (insertIndex: number, selection: string) => void;
1193
1215
  onDeleteSection: (sectionId: string) => void;
1194
1216
  onReorderSections: (fromIndex: number, toIndex: number) => void;
1195
1217
  onBlockMove: (move: BlockMove) => void;
@@ -1205,17 +1227,7 @@ function EditorContent({
1205
1227
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
1206
1228
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
1207
1229
 
1208
- const typeOptions = useMemo(
1209
- () =>
1210
- getAllSections()
1211
- .filter((def) => def.type !== "spacer")
1212
- .map((def) => ({
1213
- type: def.type,
1214
- label: def.label,
1215
- icon: def.icon,
1216
- })),
1217
- [],
1218
- );
1230
+ const typeOptions = useMemo(() => buildInsertMenuOptions(getAllSections()), []);
1219
1231
 
1220
1232
  const editingEnabled = isEditMode && !isPublishing;
1221
1233
 
@@ -1528,6 +1540,7 @@ function EditorToolbar({
1528
1540
  onRestoreClick,
1529
1541
  onOrderingClick,
1530
1542
  onPagesClick,
1543
+ onChatbotClick,
1531
1544
  }: {
1532
1545
  buttonState: "synced" | "publish" | "saveAndPublish";
1533
1546
  localChangesExist: boolean;
@@ -1546,6 +1559,7 @@ function EditorToolbar({
1546
1559
  onRestoreClick: () => void;
1547
1560
  onOrderingClick: () => void;
1548
1561
  onPagesClick: () => void;
1562
+ onChatbotClick: () => void;
1549
1563
  }) {
1550
1564
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState } = useEditorContext();
1551
1565
 
@@ -1631,6 +1645,13 @@ function EditorToolbar({
1631
1645
  onClick={onPagesClick}
1632
1646
  className="border border-base-200 bg-base-accent"
1633
1647
  />
1648
+ <IconButton
1649
+ icon={<MessageCircleIcon size={16} />}
1650
+ label="Chatbot"
1651
+ size="md"
1652
+ onClick={onChatbotClick}
1653
+ className="border border-base-200 bg-base-accent"
1654
+ />
1634
1655
  <IconButton
1635
1656
  icon={<SettingsIcon size={16} />}
1636
1657
  label="Site settings"
package/src/lib/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { env } from "./env";
2
2
  export { cn } from "./cn";
3
+ export { audiencePasses } from "./pages";
3
4
  export { generateNavLinks, toSectionId, type NavItem } from "./nav";
4
5
  export { deriveContrast } from "./contrast";
5
6
  export { sanitizeHtml, ensureSanitizer } from "./sanitize";