@drawnagency/primitives 0.1.88 → 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.
Files changed (66) hide show
  1. package/dist/{chunk-PTVY3Q7P.js → chunk-2QXJ2KGB.js} +1 -1
  2. package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
  3. package/dist/{chunk-NEDOJHHR.js → chunk-J75XGXYD.js} +2 -2
  4. package/dist/{chunk-SK6GGTTO.js → chunk-L2MAJKKC.js} +3 -3
  5. package/dist/{chunk-H2FENAQH.js → chunk-MVX4ULYE.js} +6 -6
  6. package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
  7. package/dist/{chunk-XDG2K6UT.js → chunk-QFV5TSPO.js} +1 -1
  8. package/dist/{chunk-OEL57NYV.js → chunk-VK5KNH73.js} +25 -1
  9. package/dist/{chunk-XPUBUR64.js → chunk-WCBYXNAG.js} +27 -2
  10. package/dist/{chunk-WA3HFN54.js → chunk-Y24T75HT.js} +1 -1
  11. package/dist/components/chat/ChatPanel.d.ts +20 -3
  12. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  13. package/dist/components/chat/ChatWidget.d.ts +7 -1
  14. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  15. package/dist/components/chat/chat-history.d.ts +24 -0
  16. package/dist/components/chat/chat-history.d.ts.map +1 -0
  17. package/dist/components/editor/index.js +4 -4
  18. package/dist/components/primitives/index.js +5 -5
  19. package/dist/components/sections/brand-guide.js +5 -5
  20. package/dist/components/sections/register-schemas.js +8 -8
  21. package/dist/components/sections/register.js +8 -8
  22. package/dist/components/shared/Navigation.d.ts +6 -1
  23. package/dist/components/shared/Navigation.d.ts.map +1 -1
  24. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  25. package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
  26. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  27. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  28. package/dist/hooks/useMediaPipeline.d.ts +5 -1
  29. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  30. package/dist/index.js +14 -4
  31. package/dist/lib/chatbot.d.ts +10 -0
  32. package/dist/lib/chatbot.d.ts.map +1 -0
  33. package/dist/lib/contrast.d.ts +5 -0
  34. package/dist/lib/contrast.d.ts.map +1 -1
  35. package/dist/lib/index.d.ts +2 -1
  36. package/dist/lib/index.d.ts.map +1 -1
  37. package/dist/lib/index.js +10 -2
  38. package/dist/media/compose-preview.d.ts +23 -0
  39. package/dist/media/compose-preview.d.ts.map +1 -0
  40. package/dist/media/index.d.ts +1 -0
  41. package/dist/media/index.d.ts.map +1 -1
  42. package/dist/media/index.js +4 -2
  43. package/dist/media/preview-inputs.d.ts +31 -0
  44. package/dist/media/preview-inputs.d.ts.map +1 -0
  45. package/dist/schemas/index.js +2 -2
  46. package/dist/schemas/media.d.ts +2 -0
  47. package/dist/schemas/media.d.ts.map +1 -1
  48. package/dist/schemas/site-config.d.ts +12 -0
  49. package/dist/schemas/site-config.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/components/chat/ChatPanel.tsx +437 -65
  52. package/src/components/chat/ChatWidget.tsx +90 -32
  53. package/src/components/chat/chat-history.ts +105 -0
  54. package/src/components/shared/Navigation.tsx +18 -5
  55. package/src/components/shell/EditorShell.tsx +71 -1
  56. package/src/components/shell/MediaLibraryContext.tsx +1 -1
  57. package/src/components/shell/SiteSettingsChatbot.tsx +47 -0
  58. package/src/hooks/useMediaPipeline.ts +7 -3
  59. package/src/lib/chatbot.ts +30 -0
  60. package/src/lib/contrast.ts +12 -0
  61. package/src/lib/index.ts +2 -1
  62. package/src/media/compose-preview.ts +90 -0
  63. package/src/media/index.ts +1 -0
  64. package/src/media/preview-inputs.ts +77 -0
  65. package/src/schemas/media.ts +3 -0
  66. package/src/schemas/site-config.ts +26 -1
@@ -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>;
@@ -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>;