@drawnagency/primitives 0.1.88 → 0.1.90

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 (124) hide show
  1. package/dist/{chunk-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
  2. package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
  3. package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
  4. package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
  5. package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
  6. package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
  7. package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
  8. package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
  9. package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
  10. package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
  11. package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
  12. package/dist/components/chat/ChatPanel.d.ts +20 -3
  13. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  14. package/dist/components/chat/ChatWidget.d.ts +7 -1
  15. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  16. package/dist/components/chat/chat-history.d.ts +24 -0
  17. package/dist/components/chat/chat-history.d.ts.map +1 -0
  18. package/dist/components/editor/AudienceIndicator.d.ts +6 -0
  19. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  20. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
  21. package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
  22. package/dist/components/editor/DragHandle.d.ts +3 -1
  23. package/dist/components/editor/DragHandle.d.ts.map +1 -1
  24. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  25. package/dist/components/editor/StatusIndicator.d.ts +7 -0
  26. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  27. package/dist/components/editor/index.js +100 -78
  28. package/dist/components/primitives/index.js +5 -5
  29. package/dist/components/sections/Container/Container.d.ts +3 -2
  30. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  31. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
  32. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
  33. package/dist/components/sections/Container/index.d.ts +8 -2
  34. package/dist/components/sections/Container/index.d.ts.map +1 -1
  35. package/dist/components/sections/SectionLayout.d.ts +3 -1
  36. package/dist/components/sections/SectionLayout.d.ts.map +1 -1
  37. package/dist/components/sections/ViewRenderer.d.ts +3 -1
  38. package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
  39. package/dist/components/sections/all-sections.d.ts +8 -2
  40. package/dist/components/sections/all-sections.d.ts.map +1 -1
  41. package/dist/components/sections/brand-guide.js +5 -5
  42. package/dist/components/sections/core-sections.d.ts +8 -2
  43. package/dist/components/sections/core-sections.d.ts.map +1 -1
  44. package/dist/components/sections/register-schemas.js +8 -8
  45. package/dist/components/sections/register.js +8 -8
  46. package/dist/components/shared/Navigation.d.ts +6 -1
  47. package/dist/components/shared/Navigation.d.ts.map +1 -1
  48. package/dist/components/shared/Tooltip.d.ts +8 -1
  49. package/dist/components/shared/Tooltip.d.ts.map +1 -1
  50. package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
  51. package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
  52. package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
  53. package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
  54. package/dist/components/shell/EditorContext.d.ts +3 -0
  55. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  56. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  57. package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
  58. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  59. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  60. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  61. package/dist/hooks/useMediaPipeline.d.ts +5 -1
  62. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  63. package/dist/index.js +17 -5
  64. package/dist/lib/authoring-defaults.d.ts +17 -0
  65. package/dist/lib/authoring-defaults.d.ts.map +1 -0
  66. package/dist/lib/chatbot.d.ts +10 -0
  67. package/dist/lib/chatbot.d.ts.map +1 -0
  68. package/dist/lib/container-grid.d.ts +6 -0
  69. package/dist/lib/container-grid.d.ts.map +1 -1
  70. package/dist/lib/contrast.d.ts +5 -0
  71. package/dist/lib/contrast.d.ts.map +1 -1
  72. package/dist/lib/index.d.ts +2 -1
  73. package/dist/lib/index.d.ts.map +1 -1
  74. package/dist/lib/index.js +11 -3
  75. package/dist/lib/migrate-sections-transform.js +1 -1
  76. package/dist/media/compose-preview.d.ts +23 -0
  77. package/dist/media/compose-preview.d.ts.map +1 -0
  78. package/dist/media/index.d.ts +1 -0
  79. package/dist/media/index.d.ts.map +1 -1
  80. package/dist/media/index.js +4 -2
  81. package/dist/media/preview-inputs.d.ts +31 -0
  82. package/dist/media/preview-inputs.d.ts.map +1 -0
  83. package/dist/schemas/index.js +4 -2
  84. package/dist/schemas/media.d.ts +2 -0
  85. package/dist/schemas/media.d.ts.map +1 -1
  86. package/dist/schemas/site-config.d.ts +18 -0
  87. package/dist/schemas/site-config.d.ts.map +1 -1
  88. package/package.json +1 -1
  89. package/src/components/chat/ChatPanel.tsx +437 -65
  90. package/src/components/chat/ChatWidget.tsx +90 -32
  91. package/src/components/chat/chat-history.ts +105 -0
  92. package/src/components/editor/AudienceIndicator.tsx +50 -37
  93. package/src/components/editor/ChildBlockWrapper.tsx +8 -7
  94. package/src/components/editor/ColSpanControl.tsx +14 -11
  95. package/src/components/editor/DragHandle.tsx +5 -2
  96. package/src/components/editor/PagesModal.tsx +10 -10
  97. package/src/components/editor/SectionWrapper.tsx +32 -21
  98. package/src/components/editor/StatusIndicator.tsx +37 -24
  99. package/src/components/sections/Container/Container.tsx +185 -43
  100. package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
  101. package/src/components/sections/Container/index.tsx +45 -20
  102. package/src/components/sections/SectionLayout.tsx +13 -3
  103. package/src/components/sections/ViewRenderer.tsx +11 -2
  104. package/src/components/shared/Navigation.tsx +18 -5
  105. package/src/components/shared/Tooltip.tsx +18 -2
  106. package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
  107. package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
  108. package/src/components/shell/EditorContext.tsx +5 -1
  109. package/src/components/shell/EditorShell.tsx +130 -13
  110. package/src/components/shell/MediaLibraryContext.tsx +1 -1
  111. package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
  112. package/src/hooks/useBuildStatus.ts +35 -4
  113. package/src/hooks/useMediaPipeline.ts +7 -3
  114. package/src/lib/authoring-defaults.ts +42 -0
  115. package/src/lib/chatbot.ts +30 -0
  116. package/src/lib/container-grid.ts +14 -0
  117. package/src/lib/contrast.ts +12 -0
  118. package/src/lib/index.ts +2 -1
  119. package/src/lib/migrate-sections-transform.ts +5 -3
  120. package/src/media/compose-preview.ts +90 -0
  121. package/src/media/index.ts +1 -0
  122. package/src/media/preview-inputs.ts +77 -0
  123. package/src/schemas/media.ts +3 -0
  124. package/src/schemas/site-config.ts +27 -2
@@ -1,10 +1,22 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { Button } from "../shared/Button";
3
3
  import { cn } from "../../lib/cn";
4
+ import { contrastColor } from "../../lib/contrast";
4
5
  import ChatPanel from "../chat/ChatPanel";
5
6
  import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
6
7
  import type { Audience } from "../../auth/types";
7
8
  import { audiencePasses } from "../../lib/pages";
9
+ import { resolveChatbotName, DEFAULT_CHATBOT_NAME } from "../../lib/chatbot";
10
+
11
+ /** Comma-separated textarea → schema list: trimmed, blanks dropped, clamped
12
+ * to the schema caps (30 phrases × 80 chars). */
13
+ function parseThinkingPhrases(text: string): string[] {
14
+ return text
15
+ .split(",")
16
+ .map((s) => s.trim().slice(0, 80))
17
+ .filter(Boolean)
18
+ .slice(0, 30);
19
+ }
8
20
 
9
21
  interface Props {
10
22
  siteConfig: SiteConfig;
@@ -24,7 +36,7 @@ function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[
24
36
  <button key={a.name} type="button" onClick={() => onToggle(a.name)}
25
37
  className={cn("rounded-full border px-2 py-0.5 text-xs",
26
38
  selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
27
- style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color } : undefined}>
39
+ style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color, color: contrastColor(a.color) } : undefined}>
28
40
  {a.displayName}
29
41
  </button>
30
42
  ))}
@@ -38,6 +50,9 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
38
50
  const [newTaskLabel, setNewTaskLabel] = useState("");
39
51
  const [showTest, setShowTest] = useState(false);
40
52
  const [previewAudience, setPreviewAudience] = useState("");
53
+ // Local text mirror of thinkingPhrases: parsing on every keystroke while
54
+ // round-tripping through the array would fight trailing commas/spaces.
55
+ const [thinkingText, setThinkingText] = useState(() => (chatbot.thinkingPhrases ?? []).join(", "));
41
56
 
42
57
  // A deselected audience can no longer chat — never keep simulating it.
43
58
  useEffect(() => {
@@ -77,6 +92,10 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
77
92
  const v = chatbot.audiencePrompts[name];
78
93
  return typeof v === "string" ? v : "";
79
94
  };
95
+ const audienceNameValue = (name: string) => {
96
+ const v = chatbot.audienceNames?.[name];
97
+ return typeof v === "string" ? v : "";
98
+ };
80
99
 
81
100
  return (
82
101
  <div className="flex flex-col gap-4">
@@ -94,12 +113,39 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
94
113
  Show the chat widget to viewers
95
114
  </label>
96
115
 
116
+ <div>
117
+ <label htmlFor="chatbot-name" className="mb-1 block text-sm font-medium text-base-contrast">Assistant name</label>
118
+ <input id="chatbot-name" aria-label="Assistant name" maxLength={60} value={chatbot.name}
119
+ onChange={(e) => patch({ name: e.target.value })}
120
+ placeholder={DEFAULT_CHATBOT_NAME}
121
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
122
+ <p className="mt-1 text-xs text-base-contrast-light">Shown in the widget header. Leave blank for “{DEFAULT_CHATBOT_NAME}”. Audiences can override it below.</p>
123
+ </div>
124
+
125
+ <div>
126
+ <label htmlFor="chatbot-thinking-phrases" className="mb-1 block text-sm font-medium text-base-contrast">Thinking phrases</label>
127
+ <textarea id="chatbot-thinking-phrases" aria-label="Thinking phrases" rows={2}
128
+ value={thinkingText}
129
+ onChange={(e) => { setThinkingText(e.target.value); patch({ thinkingPhrases: parseThinkingPhrases(e.target.value) }); }}
130
+ placeholder="Mixing the palette, Checking the guide, Consulting the brand book"
131
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
132
+ <p className="mt-1 text-xs text-base-contrast-light">Comma-separated; one is shown at random while the assistant thinks, with “…” added automatically. Leave blank for “Thinking…”.</p>
133
+ </div>
134
+
97
135
  <div>
98
136
  <p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
99
137
  <p className="mb-2 text-xs text-base-contrast-light">Who sees the widget — only the selected audiences; none selected means no one.</p>
100
138
  <AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
101
139
  {chatbot.audiences.map((name) => (
102
140
  <div key={name} className="mt-2">
141
+ <label htmlFor={`audience-name-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
142
+ Assistant name for {name}
143
+ </label>
144
+ <input id={`audience-name-${name}`} aria-label={`Assistant name for ${name}`} maxLength={60}
145
+ value={audienceNameValue(name)}
146
+ onChange={(e) => patch({ audienceNames: { ...chatbot.audienceNames, [name]: e.target.value } })}
147
+ placeholder={resolveChatbotName(chatbot, null)}
148
+ className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
103
149
  <label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
104
150
  Additional guidance for {name}
105
151
  </label>
@@ -175,6 +221,8 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
175
221
  {showTest && platformEnabled && (
176
222
  <ChatPanel
177
223
  open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
224
+ botName={resolveChatbotName(chatbot, previewAudience || null)}
225
+ thinkingPhrases={chatbot.thinkingPhrases}
178
226
  tasks={chatbot.tasks
179
227
  .filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
180
228
  .map((t) => ({ id: t.id, label: t.label }))}
@@ -23,6 +23,14 @@ const FADE_DURATION = 1000;
23
23
  // whose deploy was never confirmed (e.g. superseded by a later build) — show
24
24
  // idle rather than a phantom in-progress publish.
25
25
  const STALE_BUILD_MS = 15 * 60 * 1000;
26
+ // Hard cap on an ACTIVE tracking session. Completion normally arrives via a terminal
27
+ // poll (ready/error), which the Netlify webhook writes to the deploy-status row. If
28
+ // that signal is lost/delayed (webhook dropped or unconfigured) the row stays
29
+ // "building" forever and the elapsed counter would climb indefinitely. This backstop
30
+ // stops the runaway counter and hides the indicator after the cap — the same "a build
31
+ // shouldn't run longer than this" invariant as STALE_BUILD_MS, applied to the live
32
+ // session rather than only on mount.
33
+ const MAX_TRACKING_MS = STALE_BUILD_MS;
26
34
 
27
35
  export function useBuildStatus(): BuildStatusResult {
28
36
  const [state, setState] = useState<BuildState>("idle");
@@ -31,6 +39,7 @@ export function useBuildStatus(): BuildStatusResult {
31
39
  const clearRef = useRef<ReturnType<typeof setTimeout> | null>(null);
32
40
  const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
33
41
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
42
+ const giveUpRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
43
  const isPolling = useRef(false);
35
44
  const mountedRef = useRef(true);
36
45
 
@@ -52,6 +61,23 @@ export function useBuildStatus(): BuildStatusResult {
52
61
  isPolling.current = false;
53
62
  }, []);
54
63
 
64
+ const clearGiveUp = useCallback(() => {
65
+ if (giveUpRef.current) { clearTimeout(giveUpRef.current); giveUpRef.current = null; }
66
+ }, []);
67
+
68
+ // Arm the runaway backstop: if the cap elapses before a terminal signal, stop
69
+ // polling + the timer and drop back to idle (hide the indicator). `elapsedMs` lets a
70
+ // build already in progress at mount count against the same absolute cap.
71
+ const armGiveUp = useCallback((elapsedMs = 0) => {
72
+ clearGiveUp();
73
+ giveUpRef.current = setTimeout(() => {
74
+ if (!mountedRef.current) return;
75
+ stopPolling();
76
+ stopTimer();
77
+ setState("idle");
78
+ }, Math.max(0, MAX_TRACKING_MS - elapsedMs));
79
+ }, [clearGiveUp, stopPolling, stopTimer]);
80
+
55
81
  const fetchStatus = useCallback(async (): Promise<BuildStatusResponse | null> => {
56
82
  try {
57
83
  const res = await fetch("/api/build-status");
@@ -86,6 +112,7 @@ export function useBuildStatus(): BuildStatusResult {
86
112
  if (data.state === "ready" || data.state === "error") {
87
113
  stopPolling();
88
114
  stopTimer();
115
+ clearGiveUp();
89
116
  if (data.state === "ready") {
90
117
  clearRef.current = setTimeout(() => {
91
118
  if (!mountedRef.current) return;
@@ -98,7 +125,7 @@ export function useBuildStatus(): BuildStatusResult {
98
125
  }
99
126
  }
100
127
  },
101
- [stopPolling, stopTimer],
128
+ [stopPolling, stopTimer, clearGiveUp],
102
129
  );
103
130
 
104
131
  const startPolling = useCallback(() => {
@@ -130,6 +157,7 @@ export function useBuildStatus(): BuildStatusResult {
130
157
  // counts real elapsed time instead of sitting frozen at 0:00.
131
158
  startTimer(Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 1000)) : 0);
132
159
  startPolling();
160
+ armGiveUp(Number.isFinite(ageMs) ? ageMs : 0);
133
161
  return;
134
162
  }
135
163
 
@@ -143,24 +171,27 @@ export function useBuildStatus(): BuildStatusResult {
143
171
  mountedRef.current = false;
144
172
  stopPolling();
145
173
  stopTimer();
174
+ clearGiveUp();
146
175
  if (clearRef.current) clearTimeout(clearRef.current);
147
176
  if (fadeRef.current) clearTimeout(fadeRef.current);
148
177
  };
149
- }, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer]);
178
+ }, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer, armGiveUp, clearGiveUp]);
150
179
 
151
180
  const startTracking = useCallback(() => {
152
181
  if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
153
182
  setState("building");
154
183
  startTimer();
155
184
  startPolling();
156
- }, [startPolling, startTimer]);
185
+ armGiveUp(0);
186
+ }, [startPolling, startTimer, armGiveUp]);
157
187
 
158
188
  const dismiss = useCallback(() => {
159
189
  if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
160
190
  setState("idle");
161
191
  stopPolling();
162
192
  stopTimer();
163
- }, [stopPolling, stopTimer]);
193
+ clearGiveUp();
194
+ }, [stopPolling, stopTimer, clearGiveUp]);
164
195
 
165
196
  return { state, elapsedSeconds, startTracking, dismiss };
166
197
  }
@@ -76,6 +76,7 @@ export function useMediaPipeline({
76
76
  // Display-folder target per in-flight upload, keyed by content hash. Set at
77
77
  // enqueue time, consumed when the processing queue finalizes the item.
78
78
  const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
79
+ const hiddenUploadsRef = useRef<Map<string, boolean>>(new Map());
79
80
  const destroyedRef = useRef(false);
80
81
 
81
82
  // Shared persistence path for every pending media item — the processing
@@ -171,10 +172,12 @@ export function useMediaPipeline({
171
172
  alt: "",
172
173
  storage: "github",
173
174
  ...(targetFolderId ? { folderId: targetFolderId } : {}),
175
+ ...(hiddenUploadsRef.current.get(event.item.hash) ? { hidden: true } : {}),
174
176
  };
175
177
 
176
178
  const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
177
179
  uploadTargetsRef.current.delete(event.item.hash);
180
+ hiddenUploadsRef.current.delete(event.item.hash);
178
181
  if (!persisted) return;
179
182
 
180
183
  const callback = uploadCallbacksRef.current.get(event.item.hash);
@@ -603,7 +606,7 @@ export function useMediaPipeline({
603
606
 
604
607
  // --- Unified enqueue ---
605
608
 
606
- const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void) => {
609
+ const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void, opts?: { hidden?: boolean }) => {
607
610
  if (!siteConfig) return;
608
611
  const mediaConfig = siteConfig.media;
609
612
  if (file.size > mediaConfig.maxFileSize) return;
@@ -627,6 +630,7 @@ export function useMediaPipeline({
627
630
  if (onComplete) {
628
631
  uploadCallbacksRef.current.set(hash, onComplete);
629
632
  }
633
+ if (opts?.hidden) hiddenUploadsRef.current.set(hash, true);
630
634
 
631
635
  let kind: "image" | "animated" | "video" = "image";
632
636
  if (file.type === "image/gif" || file.type === "image/apng") kind = "animated";
@@ -646,12 +650,12 @@ export function useMediaPipeline({
646
650
  // Placeholder — EditorShell overrides this
647
651
  },
648
652
  uploadFile: (file: File) => handleMediaUpload([file]),
649
- uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => {
653
+ uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => {
650
654
  const blobUrl = URL.createObjectURL(file);
651
655
  enqueueFile(file, (imageId) => {
652
656
  URL.revokeObjectURL(blobUrl);
653
657
  onComplete(imageId);
654
- });
658
+ }, opts);
655
659
  return blobUrl;
656
660
  },
657
661
  reportRejectedUploads: () => {
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { StatusSchema } from "../schemas/site-config";
3
+ import type { Audience } from "../auth/types";
4
+
5
+ export const AuthoringDefaultsSchema = z.object({
6
+ status: StatusSchema,
7
+ access: z.array(z.string()),
8
+ });
9
+
10
+ export type AuthoringDefaults = z.infer<typeof AuthoringDefaultsSchema>;
11
+
12
+ export const FALLBACK_AUTHORING_DEFAULTS: AuthoringDefaults = { status: "draft", access: [] };
13
+
14
+ // Per-origin key — every client site is its own domain, so the preference is
15
+ // automatically per-site.
16
+ const STORAGE_KEY = "portal-authoring-defaults";
17
+
18
+ export function loadAuthoringDefaults(): AuthoringDefaults {
19
+ if (typeof localStorage === "undefined") return FALLBACK_AUTHORING_DEFAULTS;
20
+ try {
21
+ const raw = localStorage.getItem(STORAGE_KEY);
22
+ if (!raw) return FALLBACK_AUTHORING_DEFAULTS;
23
+ return AuthoringDefaultsSchema.parse(JSON.parse(raw));
24
+ } catch {
25
+ return FALLBACK_AUTHORING_DEFAULTS;
26
+ }
27
+ }
28
+
29
+ export function saveAuthoringDefaults(defaults: AuthoringDefaults): void {
30
+ if (typeof localStorage === "undefined") return;
31
+ try {
32
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
33
+ } catch {
34
+ // Quota/private-mode failures just lose the preference.
35
+ }
36
+ }
37
+
38
+ // Stored default audiences can outlive the audiences themselves — drop any
39
+ // name the site no longer has before stamping it onto a new section's access.
40
+ export function filterAccessToKnown(access: string[], audiences: Audience[]): string[] {
41
+ return access.filter((name) => audiences.some((a) => a.name === name));
42
+ }
@@ -0,0 +1,30 @@
1
+ // Pure, browser-safe chatbot display helpers shared by the viewer page
2
+ // (server-side name resolution), brand-chat prompt assembly, and the widget.
3
+ import type { ChatbotConfig } from "../schemas/site-config";
4
+
5
+ export const DEFAULT_CHATBOT_NAME = "Portal Assistant";
6
+
7
+ /** Resolve the assistant's display name for an audience (null = base name).
8
+ * Record lookups can hit inherited Object.prototype members ("constructor"
9
+ * is a legal audience name) — anything non-string, and blank strings,
10
+ * behave as absent. */
11
+ export function resolveChatbotName(
12
+ // Partial: pre-migration configs (and test fixtures) may predate the fields.
13
+ chatbot: Partial<Pick<ChatbotConfig, "name" | "audienceNames">>,
14
+ audience: string | null,
15
+ ): string {
16
+ const perAudience = audience !== null ? chatbot.audienceNames?.[audience] : undefined;
17
+ if (typeof perAudience === "string" && perAudience.trim()) return perAudience.trim();
18
+ if (typeof chatbot.name === "string" && chatbot.name.trim()) return chatbot.name.trim();
19
+ return DEFAULT_CHATBOT_NAME;
20
+ }
21
+
22
+ /** The widget's waiting phrases with "…" appended; falls back to "Thinking…". */
23
+ export function resolveThinkingPhrases(phrases: readonly string[] | undefined): string[] {
24
+ const list = (phrases ?? [])
25
+ .filter((p): p is string => typeof p === "string")
26
+ .map((p) => p.trim())
27
+ .filter(Boolean)
28
+ .map((p) => `${p}…`);
29
+ return list.length > 0 ? list : ["Thinking…"];
30
+ }
@@ -42,6 +42,20 @@ export const gapClass: Record<string, string> = {
42
42
  lg: "gap-8",
43
43
  };
44
44
 
45
+ /**
46
+ * Column count -> `columns-N` classes for masonry (CSS multi-column), mirroring
47
+ * `containerGridClass`'s collapse curve (`grid-cols-`→`columns-`). Full static
48
+ * strings so Tailwind's source scan generates them.
49
+ */
50
+ export const masonryColumnsClass: Record<number, string> = {
51
+ 1: "columns-1",
52
+ 2: "columns-1 @sm:columns-2",
53
+ 3: "columns-1 @sm:columns-2 @lg:columns-3",
54
+ 4: "columns-1 @xs:columns-2 @lg:columns-4",
55
+ 5: "columns-1 @xs:columns-2 @md:columns-3 @xl:columns-5",
56
+ 6: "columns-1 @xs:columns-2 @md:columns-3 @2xl:columns-6",
57
+ };
58
+
45
59
  /**
46
60
  * Per-column-count container-query classes that HIDE a spacer cell once the container
47
61
  * collapses to a single column (mobile). Mirrors `containerGridClass`'s collapse curve:
@@ -9,3 +9,15 @@ export function deriveContrast(hex: string): string {
9
9
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
10
10
  return luminance >= 0.5 ? "#1a1a1a" : "#f0f0f0";
11
11
  }
12
+
13
+ /**
14
+ * Perceived-luminance contrast pick (same YIQ formula the admin dashboard
15
+ * uses). Returns near-black for light backgrounds, white for dark ones.
16
+ */
17
+ export function contrastColor(hex: string): "#1a1a1a" | "#ffffff" {
18
+ const h = hex.replace("#", "");
19
+ const r = parseInt(h.slice(0, 2), 16);
20
+ const g = parseInt(h.slice(2, 4), 16);
21
+ const b = parseInt(h.slice(4, 6), 16);
22
+ return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1a1a1a" : "#ffffff";
23
+ }
package/src/lib/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { env } from "./env";
2
2
  export { cn } from "./cn";
3
3
  export { audiencePasses } from "./pages";
4
+ export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from "./chatbot";
4
5
  export { generateNavLinks, toSectionId, type NavItem } from "./nav";
5
- export { deriveContrast } from "./contrast";
6
+ export { deriveContrast, contrastColor } from "./contrast";
6
7
  export { sanitizeHtml, ensureSanitizer } from "./sanitize";
7
8
  export { gridColsClass } from "./grid";
8
9
  export { getIcon, curatedIcons, type IconEntry } from "./icons";
@@ -99,7 +99,8 @@ function upgradeGrid(section: Obj): Obj {
99
99
  type: "container",
100
100
  content: {
101
101
  columns: content.columns,
102
- flow: "row",
102
+ layout: "grid",
103
+ equalizeRowHeight: false,
103
104
  children: media.map((item, i) => mapRefItem(item, id, i)),
104
105
  },
105
106
  };
@@ -126,7 +127,7 @@ function upgradeSplitContent(section: Obj): Obj {
126
127
  };
127
128
  const children = options.imagePosition === "right" ? [proseChild, mediaChild] : [mediaChild, proseChild];
128
129
 
129
- const out: Obj = { id, type: "container", content: { columns: 2, flow: "row", children } };
130
+ const out: Obj = { id, type: "container", content: { columns: 2, layout: "grid", equalizeRowHeight: false, children } };
130
131
  if (section.layout !== undefined) out.layout = section.layout;
131
132
  return out;
132
133
  }
@@ -163,7 +164,8 @@ function upgradeDoDont(section: Obj): Obj {
163
164
  type: "container",
164
165
  content: {
165
166
  columns: 2,
166
- flow: "row",
167
+ layout: "grid",
168
+ equalizeRowHeight: false,
167
169
  children: [
168
170
  dodontChild(doItems, id, 0, "do", childOptions),
169
171
  dodontChild(dontItems, id, 1, "dont", childOptions),
@@ -0,0 +1,90 @@
1
+ export interface PreviewImageInputs {
2
+ /** "logo" draws the resolved logo centered on a solid black/white bg;
3
+ * "text" draws the site name in the heading font on the solid brand color. */
4
+ mode: "logo" | "text";
5
+ /** Solid background color: black/white for logo mode, brand color for text mode. */
6
+ background: string;
7
+ /** Loadable URL of the chosen logo variant (logo mode only). */
8
+ logoUrl: string | null;
9
+ /** Apply a CSS invert filter to the logo (dark bg + invertInDark, no dark variant). */
10
+ invertLogo: boolean;
11
+ siteName: string;
12
+ headingFont: string;
13
+ /** Computed contrasting text color (text mode). */
14
+ textColor: string;
15
+ }
16
+
17
+ const WIDTH = 1200;
18
+ const HEIGHT = 630;
19
+
20
+ /**
21
+ * Composes the 1200×630 social-preview raster in the browser (Canvas API only,
22
+ * no dependency). Returns a WebP blob. Must run client-side (uses document/Image
23
+ * and, for text mode, document.fonts). Falls back to the text treatment if the
24
+ * logo image fails to load.
25
+ */
26
+ export async function composePreviewImage(inputs: PreviewImageInputs): Promise<Blob> {
27
+ const canvas = document.createElement("canvas");
28
+ canvas.width = WIDTH;
29
+ canvas.height = HEIGHT;
30
+ const ctx = canvas.getContext("2d");
31
+ if (!ctx) throw new Error("2D canvas context unavailable");
32
+
33
+ ctx.fillStyle = inputs.background;
34
+ ctx.fillRect(0, 0, WIDTH, HEIGHT);
35
+
36
+ let drewLogo = false;
37
+ if (inputs.mode === "logo" && inputs.logoUrl) {
38
+ try {
39
+ const img = await loadImage(inputs.logoUrl);
40
+ // An SVG (or any image) with no intrinsic size yields width/height 0, which
41
+ // would make scale Infinity and drawImage silently paint nothing while
42
+ // drewLogo stays true — a blank solid-color card with no text fallback.
43
+ // Throw here so control drops to the catch below and the text treatment runs.
44
+ if (!(img.width > 0 && img.height > 0)) {
45
+ throw new Error("Logo image has no intrinsic size");
46
+ }
47
+ const maxW = WIDTH * 0.6;
48
+ const maxH = HEIGHT * 0.5;
49
+ const scale = Math.min(maxW / img.width, maxH / img.height);
50
+ const w = img.width * scale;
51
+ const h = img.height * scale;
52
+ ctx.filter = inputs.invertLogo ? "invert(1)" : "none";
53
+ ctx.drawImage(img, (WIDTH - w) / 2, (HEIGHT - h) / 2, w, h);
54
+ ctx.filter = "none";
55
+ drewLogo = true;
56
+ } catch {
57
+ // fall through to text treatment
58
+ }
59
+ }
60
+
61
+ if (!drewLogo) {
62
+ if (document.fonts?.ready) await document.fonts.ready;
63
+ // fillText doesn't itself trigger a font load — explicitly load the exact
64
+ // weight/family used below so it's available before we draw.
65
+ await document.fonts.load(`600 84px ${inputs.headingFont}`).catch(() => {});
66
+ ctx.fillStyle = inputs.textColor;
67
+ ctx.textAlign = "center";
68
+ ctx.textBaseline = "middle";
69
+ ctx.font = `600 84px ${inputs.headingFont}, system-ui, sans-serif`;
70
+ ctx.fillText(inputs.siteName, WIDTH / 2, HEIGHT / 2, WIDTH * 0.86);
71
+ }
72
+
73
+ return await new Promise<Blob>((resolve, reject) => {
74
+ canvas.toBlob(
75
+ (blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned null"))),
76
+ "image/webp",
77
+ 0.9,
78
+ );
79
+ });
80
+ }
81
+
82
+ function loadImage(url: string): Promise<HTMLImageElement> {
83
+ return new Promise((resolve, reject) => {
84
+ const img = new Image();
85
+ img.crossOrigin = "anonymous";
86
+ img.onload = () => resolve(img);
87
+ img.onerror = () => reject(new Error(`failed to load ${url}`));
88
+ img.src = url;
89
+ });
90
+ }
@@ -6,6 +6,7 @@ export * from "./utils";
6
6
  export * from "./queue";
7
7
  export * from "./resolve";
8
8
  export { generateVideoPoster } from "./videoPoster";
9
+ export { composePreviewImage, type PreviewImageInputs } from "./compose-preview";
9
10
 
10
11
  const MEDIA_KEY = Symbol.for("@drawnagency/primitives/media");
11
12
  const _g = globalThis as Record<symbol, MediaProvider | null>;
@@ -0,0 +1,77 @@
1
+ import type { SiteConfig } from "../schemas/site-config";
2
+ import { contrastColor } from "../lib/contrast";
3
+ import type { PreviewImageInputs } from "./compose-preview";
4
+
5
+ /**
6
+ * Derives the canvas composition inputs from a SiteConfig, plus a signature
7
+ * string over only the composition-relevant fields (so the editor recomposes
8
+ * when brand inputs change but not for unrelated edits like `description`).
9
+ *
10
+ * `resolveLogoUrl` maps a logo imageId to a loadable URL (or null if it can't
11
+ * be resolved yet); the caller supplies it so this stays pure/testable.
12
+ *
13
+ * With-logo background/variant rules (static raster; darkMode "optional" bakes
14
+ * the light default since a baked image can't follow the viewer's theme):
15
+ * light | optional -> white bg + light variant
16
+ * dark -> black bg + (dark variant ?? invert(light) if invertInDark ?? light)
17
+ */
18
+ export function derivePreviewInputs(
19
+ siteConfig: SiteConfig,
20
+ resolveLogoUrl: (imageId: string) => string | null,
21
+ ): { inputs: PreviewImageInputs; signature: string } {
22
+ const { logo, darkMode, primaryColor, siteName, headingFont } = siteConfig;
23
+ const dark = darkMode === "dark";
24
+
25
+ let mode: "logo" | "text" = "text";
26
+ let logoUrl: string | null = null;
27
+ let invertLogo = false;
28
+
29
+ if (logo) {
30
+ if (dark && logo.dark) {
31
+ logoUrl = resolveLogoUrl(logo.dark.imageId);
32
+ } else {
33
+ logoUrl = resolveLogoUrl(logo.light.imageId);
34
+ invertLogo = dark && !logo.dark && logo.invertInDark;
35
+ }
36
+ if (logoUrl) mode = "logo";
37
+ }
38
+
39
+ const inputs: PreviewImageInputs = {
40
+ mode,
41
+ background: mode === "logo" ? (dark ? "#000000" : "#ffffff") : primaryColor,
42
+ logoUrl,
43
+ invertLogo,
44
+ siteName,
45
+ headingFont,
46
+ textColor: contrastColor(primaryColor),
47
+ };
48
+
49
+ const signature = JSON.stringify({
50
+ mode: inputs.mode,
51
+ background: inputs.background,
52
+ logoUrl: inputs.logoUrl,
53
+ invertLogo: inputs.invertLogo,
54
+ siteName: inputs.siteName,
55
+ headingFont: inputs.headingFont,
56
+ textColor: inputs.textColor,
57
+ });
58
+
59
+ return { inputs, signature };
60
+ }
61
+
62
+ const PREVIEW_WIDTH = 1200;
63
+ const PREVIEW_HEIGHT = 630;
64
+
65
+ /**
66
+ * The actual largest variant width/height the /api/media route will serve for
67
+ * the 1200×630 preview, mirroring media/worker.ts: variants are generated for
68
+ * each configured size ≤ source width; if none qualify, the source width is
69
+ * used. The route 404s any non-variant width, so URLs must use this width.
70
+ * NOTE: keep in sync with worker.ts's applicableSizes logic.
71
+ */
72
+ export function servedPreviewSize(sizes: number[]): { width: number; height: number } {
73
+ const applicable = sizes.filter((s) => s <= PREVIEW_WIDTH);
74
+ const width = applicable.length ? Math.max(...applicable) : PREVIEW_WIDTH;
75
+ const height = Math.round((PREVIEW_HEIGHT * width) / PREVIEW_WIDTH);
76
+ return { width, height };
77
+ }
@@ -41,6 +41,9 @@ export const MediaItemSchema = z.object({
41
41
  // Display folder (MediaFolderSchema key). Absent = root/unfiled. NOT the
42
42
  // git storage dir — that's `folder` above, which never changes.
43
43
  folderId: z.string().optional(),
44
+ // System-generated assets (e.g. the Phase 3 social-preview raster) set this
45
+ // so the media-library UI can exclude them. Absent on all user uploads.
46
+ hidden: z.boolean().optional(),
44
47
  });
45
48
 
46
49
  export type MediaItem = z.infer<typeof MediaItemSchema>;
@@ -7,7 +7,7 @@ import { CORNER_RADIUS_OPTIONS } from "../lib/corner-radius";
7
7
  // document options block onto PageSchema.
8
8
  import { documentOptionsSchema } from "../document/engine/settings";
9
9
 
10
- const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
10
+ export const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
11
11
  (val) => val === "published" ? "live" as const : val,
12
12
  );
13
13
 
@@ -164,6 +164,15 @@ export type ChatbotTask = z.infer<typeof ChatbotTaskSchema>;
164
164
  export const ChatbotConfigSchema = z
165
165
  .object({
166
166
  enabled: z.boolean().default(false),
167
+ // Assistant display name shown in the widget header and used in the system
168
+ // prompt. Blank falls back to "Portal Assistant" (resolveChatbotName).
169
+ name: z.string().max(60).default("Portal Assistant"),
170
+ // Per-audience name overrides, keyed by audience name — same inert-when-
171
+ // deselected semantics as audiencePrompts.
172
+ audienceNames: z.record(z.string(), z.string().max(60)).default({}),
173
+ // Brand-flavored waiting phrases, shown (with "…" appended) while the
174
+ // assistant streams no text yet. Empty = the widget's "Thinking…" default.
175
+ thinkingPhrases: z.array(z.string().max(80)).max(30).default([]),
167
176
  systemPrompt: z.string().max(8_000).default(""),
168
177
  // Audience names the widget is shown to — strict opt-in: [] = no viewer
169
178
  // sees it (editors always can via /edit). Gates: [...slug].astro + /api/chat.
@@ -225,10 +234,26 @@ export const SiteConfigSchema = z.object({
225
234
  .nullable()
226
235
  .default(null),
227
236
  media: MediaConfigSchema.default(MediaConfigSchema.parse({})),
237
+ // Composed social-preview raster, stored as a media-library reference so it
238
+ // participates in usage tracking + GC protection. Composed client-side at
239
+ // save time (Phase 3). `width`/`height` are the ACTUAL served variant size
240
+ // (the /api/media route only serves exact generated-variant widths), so the
241
+ // og:image URL + dimensions are correct. Defaults null so pre-existing
242
+ // configs keep parsing and sites without a preview emit no og:image tags.
243
+ previewImage: z
244
+ .object({
245
+ imageId: z.string(),
246
+ width: z.number().int().positive(),
247
+ height: z.number().int().positive(),
248
+ })
249
+ .nullable()
250
+ .default(null),
251
+ // Optional site description → og:description + <meta name="description">.
252
+ description: z.string().max(300).nullable().default(null),
228
253
  // Brand chatbot config (editor-owned; the platform kill switch lives in the
229
254
  // sites table, not here). Every field defaults so pre-existing
230
255
  // site-config.json files keep parsing.
231
- chatbot: ChatbotConfigSchema.default({ enabled: false, systemPrompt: "", audiences: [], tasks: [], audiencePrompts: {} }),
256
+ chatbot: ChatbotConfigSchema.default({ enabled: false, name: "Portal Assistant", audienceNames: {}, thinkingPhrases: [], systemPrompt: "", audiences: [], tasks: [], audiencePrompts: {} }),
232
257
  });
233
258
 
234
259
  export type SiteConfig = z.infer<typeof SiteConfigSchema>;