@drawnagency/primitives 0.1.87 → 0.1.89
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.
- package/dist/{chunk-PTVY3Q7P.js → chunk-2QXJ2KGB.js} +1 -1
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-NEDOJHHR.js → chunk-J75XGXYD.js} +2 -2
- package/dist/{chunk-6VOIZP2O.js → chunk-L2MAJKKC.js} +220 -207
- package/dist/{chunk-VY5V4HBV.js → chunk-MVX4ULYE.js} +6 -6
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-QFV5TSPO.js} +1 -1
- package/dist/{chunk-OEL57NYV.js → chunk-VK5KNH73.js} +25 -1
- package/dist/{chunk-XPUBUR64.js → chunk-WCBYXNAG.js} +27 -2
- package/dist/{chunk-WA3HFN54.js → chunk-Y24T75HT.js} +1 -1
- package/dist/components/brandguide/Colors.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsEditable.d.ts +20 -0
- package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -0
- package/dist/components/brandguide/ColorsView.d.ts +9 -0
- package/dist/components/brandguide/ColorsView.d.ts.map +1 -0
- package/dist/components/brandguide/colors-shared.d.ts +4 -0
- package/dist/components/brandguide/colors-shared.d.ts.map +1 -0
- package/dist/components/chat/ChatPanel.d.ts +20 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts +7 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/chat/chat-history.d.ts +24 -0
- package/dist/components/chat/chat-history.d.ts.map +1 -0
- package/dist/components/editor/index.js +4 -4
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/SectionIsland.d.ts +8 -0
- package/dist/components/sections/SectionIsland.d.ts.map +1 -0
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/hooks/useCollabSession.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +5 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +14 -4
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- package/dist/lib/contrast.d.ts +5 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +10 -2
- package/dist/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/compose-preview.d.ts +23 -0
- package/dist/media/compose-preview.d.ts.map +1 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +4 -2
- package/dist/media/preview-inputs.d.ts +31 -0
- package/dist/media/preview-inputs.d.ts.map +1 -0
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/media.d.ts +2 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +12 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/brandguide/Colors.tsx +2 -275
- package/src/components/brandguide/ColorsEditable.tsx +155 -0
- package/src/components/brandguide/ColorsView.tsx +100 -0
- package/src/components/brandguide/colors-shared.ts +17 -0
- package/src/components/chat/ChatPanel.tsx +437 -65
- package/src/components/chat/ChatWidget.tsx +90 -32
- package/src/components/chat/chat-history.ts +105 -0
- package/src/components/sections/Colors/index.tsx +1 -0
- package/src/components/sections/SectionIsland.tsx +21 -0
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shell/EditorShell.tsx +71 -1
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +47 -0
- package/src/hooks/useCollabSession.ts +27 -0
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/registry.ts +7 -0
- package/src/media/compose-preview.ts +90 -0
- package/src/media/index.ts +1 -0
- package/src/media/preview-inputs.ts +77 -0
- package/src/schemas/media.ts +3 -0
- package/src/schemas/site-config.ts +26 -1
|
@@ -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
|
+
}
|
package/src/lib/contrast.ts
CHANGED
|
@@ -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";
|
package/src/lib/registry.ts
CHANGED
|
@@ -176,6 +176,12 @@ export interface SectionDefinition<T = unknown> {
|
|
|
176
176
|
* top-level `description`. Presentation/metadata only — not validated.
|
|
177
177
|
*/
|
|
178
178
|
authoringNote?: string;
|
|
179
|
+
/**
|
|
180
|
+
* When true, this section must run client-side on the viewer page: the viewer
|
|
181
|
+
* renderer hydrates it as an Astro island (`client:visible`) instead of
|
|
182
|
+
* emitting static HTML. Opt-in per type; default (absent/false) stays zero-JS.
|
|
183
|
+
*/
|
|
184
|
+
interactive?: boolean;
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
// --- defineSection ---
|
|
@@ -204,6 +210,7 @@ type DefineSectionInput<S extends ZodType> = {
|
|
|
204
210
|
settingsTabs?: { label: string; fields: string[] }[];
|
|
205
211
|
presets?: SectionPreset[];
|
|
206
212
|
authoringNote?: string;
|
|
213
|
+
interactive?: boolean;
|
|
207
214
|
};
|
|
208
215
|
|
|
209
216
|
export function defineSection<S extends ZodType>(
|
|
@@ -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
|
+
}
|
package/src/media/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/schemas/media.ts
CHANGED
|
@@ -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>;
|
|
@@ -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>;
|