@drawnagency/primitives 0.1.69 → 0.1.71

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.
@@ -9,7 +9,7 @@ import {
9
9
  } from "./chunk-TZQPOR5A.js";
10
10
  import {
11
11
  IndexSchema
12
- } from "./chunk-QKOIEWZ4.js";
12
+ } from "./chunk-YMVYTYGQ.js";
13
13
  import {
14
14
  getAllSchemas
15
15
  } from "./chunk-VKAGMEKE.js";
@@ -3,7 +3,7 @@ import {
3
3
  pluralize,
4
4
  stripHtmlToPlainText,
5
5
  truncate
6
- } from "./chunk-VVK6ZRCN.js";
6
+ } from "./chunk-RTYJZAKR.js";
7
7
  import {
8
8
  DeleteButton,
9
9
  DragHandle,
@@ -43,7 +43,7 @@ import {
43
43
  } from "./chunk-TZQPOR5A.js";
44
44
  import {
45
45
  SingleMediaReferenceSchema
46
- } from "./chunk-QKOIEWZ4.js";
46
+ } from "./chunk-YMVYTYGQ.js";
47
47
  import {
48
48
  defineSection,
49
49
  getSection
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  HexColorSchema
3
- } from "./chunk-QKOIEWZ4.js";
3
+ } from "./chunk-YMVYTYGQ.js";
4
4
 
5
5
  // src/schemas/audience.ts
6
6
  import { z } from "zod";
@@ -30,7 +30,7 @@ import {
30
30
  import {
31
31
  HexColorSchema,
32
32
  SingleMediaReferenceSchema
33
- } from "./chunk-QKOIEWZ4.js";
33
+ } from "./chunk-YMVYTYGQ.js";
34
34
  import {
35
35
  defineSection
36
36
  } from "./chunk-VKAGMEKE.js";
@@ -208,8 +208,16 @@ var ChatbotTaskSchema = z3.object({
208
208
  var ChatbotConfigSchema = z3.object({
209
209
  enabled: z3.boolean().default(false),
210
210
  systemPrompt: z3.string().max(8e3).default(""),
211
- // Audience names the widget is shown to; [] = all audiences.
211
+ // Audience names the widget is shown to — strict opt-in: [] = no viewer
212
+ // sees it (editors always can via /edit). Gates: [...slug].astro + /api/chat.
212
213
  audiences: z3.array(z3.string()).default([]),
214
+ // Per-audience prompt overlays, keyed by audience name. Appended AFTER the
215
+ // base systemPrompt, and ONLY for requests whose session audience matches
216
+ // (or an editor's explicit test-panel simulation) — enforced in
217
+ // assembleBrandChat. Keys are not validated against the audience list:
218
+ // entries for deselected/renamed audiences are inert (strict opt-in means
219
+ // those audiences cannot chat at all).
220
+ audiencePrompts: z3.record(z3.string(), z3.string().max(8e3)).default({}),
213
221
  tasks: z3.array(ChatbotTaskSchema).max(20).default([])
214
222
  }).superRefine((cfg, ctx) => {
215
223
  const seen = /* @__PURE__ */ new Set();
@@ -238,7 +246,7 @@ var SiteConfigSchema = z3.object({
238
246
  // Brand chatbot config (editor-owned; the platform kill switch lives in the
239
247
  // sites table, not here). Every field defaults so pre-existing
240
248
  // site-config.json files keep parsing.
241
- chatbot: ChatbotConfigSchema.default({ enabled: false, systemPrompt: "", audiences: [], tasks: [] })
249
+ chatbot: ChatbotConfigSchema.default({ enabled: false, systemPrompt: "", audiences: [], tasks: [], audiencePrompts: {} })
242
250
  });
243
251
 
244
252
  export {
@@ -2,12 +2,17 @@ export interface ChatMessage {
2
2
  role: "user" | "assistant";
3
3
  content: string;
4
4
  }
5
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
6
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
7
+ * through, and model output is untrusted. Exported for tests. */
8
+ export declare function assistantHtml(md: string): string;
5
9
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
6
10
  export declare function streamChat(body: {
7
11
  messages: ChatMessage[];
8
12
  sessionId: string;
9
13
  taskId?: string;
10
14
  draftChatbot?: unknown;
15
+ simulateAudience?: string;
11
16
  }, onText: (text: string) => void): Promise<{
12
17
  ok: true;
13
18
  truncated: boolean;
@@ -16,7 +21,7 @@ export declare function streamChat(body: {
16
21
  error: string;
17
22
  retryAfterMs?: number;
18
23
  }>;
19
- export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, variant }: {
24
+ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, simulateAudience, variant }: {
20
25
  open: boolean;
21
26
  onClose: () => void;
22
27
  siteName: string;
@@ -27,6 +32,8 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
27
32
  isEditor?: boolean;
28
33
  /** Set by the configurator's test panel and the /edit chat FAB. */
29
34
  draftChatbot?: unknown;
35
+ /** Configurator test panel only: preview this audience's prompt overlay. */
36
+ simulateAudience?: string;
30
37
  /** Anchors the panel above its launcher: viewer corner FAB vs /edit FAB column. */
31
38
  variant?: "viewer" | "editor";
32
39
  }): import("react/jsx-runtime").JSX.Element | null;
@@ -1 +1 @@
1
- {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAoB5E,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE;IAChH,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B,kDA6EA"}
1
+ {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAQA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAoB5E;;kEAEkE;AAClE,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhD;AAcD,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,EACxH,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAkB,EAAE,EAAE;IAClI,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mFAAmF;IACnF,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B,kDA8JA"}
@@ -1 +1 @@
1
- {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,2CAoClH"}
1
+ {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,2CA2ClH"}
@@ -23,7 +23,7 @@ import "../../chunk-4YEOTFVU.js";
23
23
  import {
24
24
  cn
25
25
  } from "../../chunk-7EOSDFS3.js";
26
- import "../../chunk-QKOIEWZ4.js";
26
+ import "../../chunk-YMVYTYGQ.js";
27
27
  import "../../chunk-VKAGMEKE.js";
28
28
  import "../../chunk-JSBRDJBE.js";
29
29
 
@@ -2,7 +2,7 @@ import {
2
2
  ColorItemSchema,
3
3
  ColorSpaceSchema,
4
4
  brandGuideSectionDefs
5
- } from "../../chunk-VVK6ZRCN.js";
5
+ } from "../../chunk-RTYJZAKR.js";
6
6
  import "../../chunk-5THS4K5D.js";
7
7
  import "../../chunk-KWBM27UR.js";
8
8
  import "../../chunk-I5737FJY.js";
@@ -11,7 +11,7 @@ import "../../chunk-3DCG4DNU.js";
11
11
  import "../../chunk-WDSVHKAH.js";
12
12
  import "../../chunk-4YEOTFVU.js";
13
13
  import "../../chunk-7EOSDFS3.js";
14
- import "../../chunk-QKOIEWZ4.js";
14
+ import "../../chunk-YMVYTYGQ.js";
15
15
  import "../../chunk-VKAGMEKE.js";
16
16
  import "../../chunk-JSBRDJBE.js";
17
17
  export {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-TYYCKVAW.js";
4
- import "../../chunk-VVK6ZRCN.js";
3
+ } from "../../chunk-MU3CDERQ.js";
4
+ import "../../chunk-RTYJZAKR.js";
5
5
  import "../../chunk-2BHPWO7M.js";
6
6
  import "../../chunk-5THS4K5D.js";
7
7
  import "../../chunk-WXVKX6PY.js";
@@ -15,7 +15,7 @@ import "../../chunk-53DKVAB6.js";
15
15
  import "../../chunk-4YEOTFVU.js";
16
16
  import "../../chunk-7EOSDFS3.js";
17
17
  import "../../chunk-TZQPOR5A.js";
18
- import "../../chunk-QKOIEWZ4.js";
18
+ import "../../chunk-YMVYTYGQ.js";
19
19
  import {
20
20
  registerRichText,
21
21
  registerSchema
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-TYYCKVAW.js";
4
- import "../../chunk-VVK6ZRCN.js";
3
+ } from "../../chunk-MU3CDERQ.js";
4
+ import "../../chunk-RTYJZAKR.js";
5
5
  import "../../chunk-2BHPWO7M.js";
6
6
  import "../../chunk-5THS4K5D.js";
7
7
  import "../../chunk-WXVKX6PY.js";
@@ -15,7 +15,7 @@ import "../../chunk-53DKVAB6.js";
15
15
  import "../../chunk-4YEOTFVU.js";
16
16
  import "../../chunk-7EOSDFS3.js";
17
17
  import "../../chunk-TZQPOR5A.js";
18
- import "../../chunk-QKOIEWZ4.js";
18
+ import "../../chunk-YMVYTYGQ.js";
19
19
  import {
20
20
  registerSection
21
21
  } from "../../chunk-VKAGMEKE.js";
@@ -1 +1 @@
1
- {"version":3,"file":"ChatbotConfigModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/ChatbotConfigModal.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,2BAA2B,CAAC;AACxF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,UAAU,KAAK;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,UAAU,EAAE,UAAU,CAAC;IACvB,kBAAkB,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACjD,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAsBD,wBAAgB,kBAAkB,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,KAAK,2CAkHxH"}
1
+ {"version":3,"file":"ChatbotConfigModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/ChatbotConfigModal.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,2BAA2B,CAAC;AACxF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAGjD,UAAU,KAAK;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,UAAU,EAAE,UAAU,CAAC;IACvB,kBAAkB,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACjD,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAsBD,wBAAgB,kBAAkB,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,KAAK,2CA0JxH"}
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  AudienceNameSchema,
34
34
  MediaGridOptionsSchema,
35
35
  slugifyAudienceName
36
- } from "./chunk-EWKCEPMW.js";
36
+ } from "./chunk-NR7UB6DZ.js";
37
37
  import {
38
38
  AudienceSchema,
39
39
  RoleSchema,
@@ -55,7 +55,7 @@ import {
55
55
  loadStaticSiteContent,
56
56
  mergeSiteContent,
57
57
  safeRedirect
58
- } from "./chunk-WNPMBFD6.js";
58
+ } from "./chunk-GKV77AA6.js";
59
59
  import {
60
60
  isSameOriginRequest,
61
61
  requireSessionSecret,
@@ -107,7 +107,7 @@ import {
107
107
  TextLineSchema,
108
108
  VariantSchema,
109
109
  normalizeSiteIndex
110
- } from "./chunk-QKOIEWZ4.js";
110
+ } from "./chunk-YMVYTYGQ.js";
111
111
  import {
112
112
  clearRegistry,
113
113
  createRegistry,
package/dist/lib/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  loadStaticSiteContent,
8
8
  mergeSiteContent,
9
9
  safeRedirect
10
- } from "../chunk-WNPMBFD6.js";
10
+ } from "../chunk-GKV77AA6.js";
11
11
  import "../chunk-S2L3BPLS.js";
12
12
  import "../chunk-75P6HWPN.js";
13
13
  import {
@@ -33,7 +33,7 @@ import {
33
33
  cn
34
34
  } from "../chunk-7EOSDFS3.js";
35
35
  import "../chunk-TZQPOR5A.js";
36
- import "../chunk-QKOIEWZ4.js";
36
+ import "../chunk-YMVYTYGQ.js";
37
37
  import {
38
38
  clearRegistry,
39
39
  createRegistry,
@@ -20,7 +20,7 @@ import {
20
20
  MediaConfigSchema,
21
21
  MediaItemSchema,
22
22
  VariantSchema
23
- } from "../chunk-QKOIEWZ4.js";
23
+ } from "../chunk-YMVYTYGQ.js";
24
24
  import "../chunk-JSBRDJBE.js";
25
25
  export {
26
26
  EXT_TO_MIME,
@@ -3,7 +3,7 @@ import {
3
3
  AudienceNameSchema,
4
4
  MediaGridOptionsSchema,
5
5
  slugifyAudienceName
6
- } from "../chunk-EWKCEPMW.js";
6
+ } from "../chunk-NR7UB6DZ.js";
7
7
  import {
8
8
  AudienceSchema,
9
9
  RoleSchema,
@@ -39,7 +39,7 @@ import {
39
39
  TextLineSchema,
40
40
  VariantSchema,
41
41
  normalizeSiteIndex
42
- } from "../chunk-QKOIEWZ4.js";
42
+ } from "../chunk-YMVYTYGQ.js";
43
43
  import "../chunk-VKAGMEKE.js";
44
44
  import "../chunk-NSCT3AMV.js";
45
45
  import "../chunk-JSBRDJBE.js";
@@ -161,6 +161,7 @@ export declare const ChatbotConfigSchema: z.ZodObject<{
161
161
  enabled: z.ZodDefault<z.ZodBoolean>;
162
162
  systemPrompt: z.ZodDefault<z.ZodString>;
163
163
  audiences: z.ZodDefault<z.ZodArray<z.ZodString>>;
164
+ audiencePrompts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
164
165
  tasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
165
166
  id: z.ZodString;
166
167
  label: z.ZodString;
@@ -194,6 +195,7 @@ export declare const SiteConfigSchema: z.ZodObject<{
194
195
  enabled: z.ZodDefault<z.ZodBoolean>;
195
196
  systemPrompt: z.ZodDefault<z.ZodString>;
196
197
  audiences: z.ZodDefault<z.ZodArray<z.ZodString>>;
198
+ audiencePrompts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
197
199
  tasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
198
200
  id: z.ZodString;
199
201
  label: z.ZodString;
@@ -1 +1 @@
1
- {"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAMD,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,mBAAmB;;;;;;;;;;iBAgB5B,CAAC;AACL,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyB3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
1
+ {"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAMD,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,mBAAmB;;;;;;;;;;;iBAwB5B,CAAC;AACL,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyB3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -106,6 +106,7 @@
106
106
  "dompurify": "^3.2.6",
107
107
  "jose": "^6.2.2",
108
108
  "lucide-react": "^1.7.0",
109
+ "marked": "^18.0.5",
109
110
  "tailwind-merge": "^3.5.0",
110
111
  "zod": "^4.3.6"
111
112
  },
@@ -1,5 +1,10 @@
1
- import { useRef, useState } from "react";
1
+ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
2
+ import { marked } from "marked";
2
3
  import { cn } from "../../lib/cn";
4
+ import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
5
+ import { Select } from "../shared/Select";
6
+ import { IconButton } from "../shared/IconButton";
7
+ import { RemoveIcon } from "../shared/icons";
3
8
 
4
9
  export interface ChatMessage { role: "user" | "assistant"; content: string }
5
10
 
@@ -21,9 +26,28 @@ const ERROR_COPY: Record<string, string> = {
21
26
  upstream_error: "Something went wrong — please try again.",
22
27
  };
23
28
 
29
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
30
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
31
+ * through, and model output is untrusted. Exported for tests. */
32
+ export function assistantHtml(md: string): string {
33
+ return sanitizeHtml(marked.parse(md, { async: false, gfm: true, breaks: true }) as string);
34
+ }
35
+
36
+ // Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
37
+ // weight + spacing to clamp to the bubble's text scale.
38
+ const MD_STYLES =
39
+ "[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
40
+ "[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
41
+ "[&_a]:underline [&_a]:underline-offset-2 [&_a]:text-primary " +
42
+ "[&_code]:rounded [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
43
+ "[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-base-200 [&_pre]:p-2 " +
44
+ "[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
45
+ "[&_h3]:mt-2 [&_h3]:font-semibold [&_h4]:mt-2 [&_h4]:font-semibold " +
46
+ "[&_blockquote]:mt-2 [&_blockquote]:border-l-2 [&_blockquote]:border-base-200 [&_blockquote]:pl-2";
47
+
24
48
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
25
49
  export async function streamChat(
26
- body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown },
50
+ body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown; simulateAudience?: string },
27
51
  onText: (text: string) => void,
28
52
  ): Promise<{ ok: true; truncated: boolean } | { ok: false; error: string; retryAfterMs?: number }> {
29
53
  try {
@@ -68,7 +92,7 @@ export async function streamChat(
68
92
  }
69
93
  }
70
94
 
71
- export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, variant = "viewer" }: {
95
+ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer" }: {
72
96
  open: boolean;
73
97
  onClose: () => void;
74
98
  siteName: string;
@@ -76,6 +100,8 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
76
100
  isEditor?: boolean;
77
101
  /** Set by the configurator's test panel and the /edit chat FAB. */
78
102
  draftChatbot?: unknown;
103
+ /** Configurator test panel only: preview this audience's prompt overlay. */
104
+ simulateAudience?: string;
79
105
  /** Anchors the panel above its launcher: viewer corner FAB vs /edit FAB column. */
80
106
  variant?: "viewer" | "editor";
81
107
  }) {
@@ -85,6 +111,72 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
85
111
  const [busy, setBusy] = useState(false);
86
112
  const [notice, setNotice] = useState<string | null>(null);
87
113
  const streamingRef = useRef("");
114
+ const composerRef = useRef<HTMLTextAreaElement>(null);
115
+
116
+ // Autosize the composer: height tracks content up to the className's
117
+ // 4-line max-h cap, then it scrolls internally. Keyed on `input` so the
118
+ // post-send clear snaps it back to a single row. (field-sizing: content
119
+ // would do this in CSS alone, but it's still Chromium-only.)
120
+ useEffect(() => {
121
+ const el = composerRef.current;
122
+ if (!el) return;
123
+ el.style.height = "auto";
124
+ el.style.height = `${el.scrollHeight}px`;
125
+ }, [input]);
126
+
127
+ function onComposerKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
128
+ // Chat convention: Enter sends, Shift+Enter inserts a newline.
129
+ if (e.key === "Enter" && !e.shiftKey) {
130
+ e.preventDefault();
131
+ void send();
132
+ }
133
+ }
134
+
135
+ // The tasks prop can shrink live (the configurator's Preview-as filters it):
136
+ // never keep a selected task that's no longer offered, or the next send would
137
+ // assemble a task the previewed audience can't access.
138
+ useEffect(() => {
139
+ if (taskId && !tasks.some((t) => t.id === taskId)) setTaskId("");
140
+ }, [taskId, tasks]);
141
+
142
+ const [mdReady, setMdReady] = useState(false);
143
+ useEffect(() => {
144
+ let alive = true;
145
+ // Failure just leaves mdReady false forever (plain-text messages) — never
146
+ // let it surface as an unhandled rejection.
147
+ void ensureSanitizer().then(() => { if (alive) setMdReady(true); }).catch(() => {});
148
+ return () => { alive = false; };
149
+ }, []);
150
+
151
+ function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
152
+ const link = (e.target as HTMLElement).closest("a");
153
+ if (!link || !link.getAttribute("href")) return;
154
+ e.preventDefault();
155
+ const url = new URL(link.href, window.location.href);
156
+ // Trust the hash, not the path: the model reliably preserves a section's
157
+ // #anchor but improvises the path around it (a bare "#anchor", or a path
158
+ // copied from elsewhere in the prompt). If the cited anchor exists in the
159
+ // CURRENT document — viewer page or /edit editor, both render heading
160
+ // ids — scroll to it in place; nothing navigates and the panel survives.
161
+ let anchorId = "";
162
+ if (url.hash.length > 1 && url.origin === window.location.origin) {
163
+ try {
164
+ anchorId = decodeURIComponent(url.hash.slice(1));
165
+ } catch {
166
+ anchorId = url.hash.slice(1); // malformed percent-encoding in model output
167
+ }
168
+ }
169
+ const target = anchorId ? document.getElementById(anchorId) : null;
170
+ if (target) {
171
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
172
+ window.history.replaceState(null, "", url.hash);
173
+ return;
174
+ }
175
+ // Anchor not in this document (cross-page citation on a multi-page
176
+ // portal, external URL, hashless link, or hallucinated anchor): new tab
177
+ // so chat history and any unsaved editor state survive.
178
+ window.open(url.href, "_blank", "noopener");
179
+ }
88
180
 
89
181
  async function send() {
90
182
  const text = input.trim();
@@ -96,7 +188,7 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
96
188
  setMessages([...history, { role: "assistant", content: "" }]);
97
189
  streamingRef.current = "";
98
190
  const result = await streamChat(
99
- { messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}) },
191
+ { messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}), ...(simulateAudience ? { simulateAudience } : {}) },
100
192
  (delta) => {
101
193
  streamingRef.current += delta;
102
194
  const streamed = streamingRef.current;
@@ -118,36 +210,51 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
118
210
  <div role="dialog" aria-label={`Chat with ${siteName}`}
119
211
  className={cn(
120
212
  "fixed z-40 flex h-[28rem] w-80 flex-col rounded-lg border border-base-200 bg-base shadow-xl",
121
- variant === "editor" ? "bottom-40 right-4 lg:right-auto fab-container-right" : "bottom-20 right-4",
213
+ variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
122
214
  )}>
123
215
 
124
216
  <div className="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">
125
- <span className="text-sm font-medium text-base-contrast">{siteName} assistant</span>
126
- {tasks.length > 0 && (
127
- <select aria-label="Task" value={taskId} onChange={(e) => setTaskId(e.target.value)}
128
- className="max-w-36 rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
129
- <option value="">General</option>
130
- {tasks.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
131
- </select>
132
- )}
133
- <button type="button" aria-label="Close chat" onClick={onClose} className="text-base-contrast-light hover:text-base-contrast">×</button>
217
+ <span className="text-sm font-medium text-base-contrast">Portal Assistant</span>
218
+ <IconButton icon={<RemoveIcon size={18} />} label="Close chat" onClick={onClose} className="rounded-md hover:bg-base-accent" />
134
219
  </div>
135
- <div className="flex-1 space-y-2 overflow-y-auto p-3">
136
- {messages.map((m, i) => (
137
- <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
138
- m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
139
- {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
140
- </div>
141
- ))}
220
+ {tasks.length > 0 && (
221
+ <div className="border-b border-base-200 px-3 py-2">
222
+ <Select
223
+ label="Task"
224
+ value={taskId}
225
+ onChange={setTaskId}
226
+ options={[{ value: "", label: "General" }, ...tasks.map((t) => ({ value: t.id, label: t.label }))]}
227
+ selectClassName="py-1"
228
+ />
229
+ </div>
230
+ )}
231
+ <div className="flex-1 space-y-2 overflow-y-auto p-3" onClick={onMessagesClick}>
232
+ {messages.map((m, i) =>
233
+ m.role === "assistant" && mdReady && m.content ? (
234
+ <div
235
+ key={i}
236
+ className={cn("max-w-[85%] rounded-lg bg-base-accent px-3 py-2 text-sm text-base-contrast", MD_STYLES)}
237
+ dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
238
+ />
239
+ ) : (
240
+ <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
241
+ m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
242
+ {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
243
+ </div>
244
+ ),
245
+ )}
142
246
  {notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
143
247
  </div>
144
- <form className="flex gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
145
- <input
248
+ <form className="flex items-end gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
249
+ <textarea
250
+ ref={composerRef}
251
+ rows={1}
146
252
  value={input}
147
253
  onChange={(e) => setInput(e.target.value)}
254
+ onKeyDown={onComposerKeyDown}
148
255
  placeholder={`Ask about ${siteName}…`}
149
256
  maxLength={4000}
150
- className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast"
257
+ className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
151
258
  />
152
259
  <button type="submit" aria-label="Send" disabled={busy}
153
260
  className="rounded bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
@@ -4,8 +4,8 @@
4
4
  //
5
5
  // Two placements:
6
6
  // - "viewer" (default): bottom-right corner of viewer pages.
7
- // - "editor": stacked above the /edit route's existing FAB column (edit-mode
8
- // toggle at bottom-4, bug report at bottom-16), aligned to the content edge
7
+ // - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
8
+ // bottom-16, bug report at bottom-28 above it), aligned to the content edge
9
9
  // via fab-container-right like its neighbors.
10
10
  import { lazy, Suspense, useState } from "react";
11
11
  import { cn } from "../../lib/cn";
@@ -30,18 +30,25 @@ export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, va
30
30
  <>
31
31
  <button
32
32
  type="button"
33
- aria-label={`Chat with ${siteName}`}
33
+ aria-label={open ? "Collapse chat" : `Chat with ${siteName}`}
34
+ aria-expanded={open}
34
35
  onClick={() => { setOpen((o) => !o); setEverOpened(true); }}
35
36
  className={cn(
36
37
  "fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
37
38
  variant === "editor"
38
- ? "bottom-28 right-4 lg:right-auto h-10 w-10 fab-container-right"
39
+ ? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
39
40
  : "bottom-4 right-4 h-12 w-12",
40
41
  )}
41
42
  >
42
- <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
43
- <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
44
- </svg>
43
+ {open ? (
44
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
45
+ <path d="m6 9 6 6 6-6" />
46
+ </svg>
47
+ ) : (
48
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
49
+ <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
50
+ </svg>
51
+ )}
45
52
  </button>
46
53
  {everOpened && (
47
54
  <Suspense fallback={null}>
@@ -190,7 +190,7 @@ export function BugReportFAB() {
190
190
  type="button"
191
191
  onClick={handleOpen}
192
192
  aria-label="Report a bug"
193
- className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
193
+ className="cursor-pointer fixed bottom-28 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
194
194
  >
195
195
  {/* Bug / beetle SVG icon */}
196
196
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -1,10 +1,11 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { EditorModal } from "./EditorModal";
3
3
  import { Button } from "../shared/Button";
4
4
  import { cn } from "../../lib/cn";
5
5
  import ChatPanel from "../chat/ChatPanel";
6
6
  import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
7
7
  import type { Audience } from "../../auth/types";
8
+ import { audiencePasses } from "../../lib/pages";
8
9
 
9
10
  interface Props {
10
11
  isOpen: boolean;
@@ -39,6 +40,12 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
39
40
  const chatbot = siteConfig.chatbot;
40
41
  const [newTaskLabel, setNewTaskLabel] = useState("");
41
42
  const [showTest, setShowTest] = useState(false);
43
+ const [previewAudience, setPreviewAudience] = useState("");
44
+
45
+ // A deselected audience can no longer chat — never keep simulating it.
46
+ useEffect(() => {
47
+ if (previewAudience && !chatbot.audiences.includes(previewAudience)) setPreviewAudience("");
48
+ }, [previewAudience, chatbot.audiences]);
42
49
 
43
50
  const patch = (p: Partial<ChatbotConfig>) =>
44
51
  onSiteConfigChange({ ...siteConfig, chatbot: { ...chatbot, ...p } });
@@ -67,6 +74,13 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
67
74
  const toggleName = (list: string[], name: string) =>
68
75
  list.includes(name) ? list.filter((n) => n !== name) : [...list, name];
69
76
 
77
+ // Record lookups can hit inherited Object.prototype members ("constructor"
78
+ // is a legal audience name) — treat anything non-string as absent.
79
+ const overlayValue = (name: string) => {
80
+ const v = chatbot.audiencePrompts[name];
81
+ return typeof v === "string" ? v : "";
82
+ };
83
+
70
84
  return (
71
85
  <EditorModal isOpen={isOpen} onClose={onClose} title="Chatbot" size="settings" noPadding>
72
86
  <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4">
@@ -79,14 +93,27 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
79
93
 
80
94
  <label className="flex items-center gap-2 text-sm text-base-contrast">
81
95
  <input type="checkbox" aria-label="Enable chatbot" checked={chatbot.enabled}
96
+ className="h-5 w-5 cursor-pointer accent-primary"
82
97
  onChange={(e) => patch({ enabled: e.target.checked })} />
83
98
  Show the chat widget to viewers
84
99
  </label>
85
100
 
86
101
  <div>
87
102
  <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>
103
+ <p className="mb-2 text-xs text-base-contrast-light">Who sees the widget — only the selected audiences; none selected means no one.</p>
89
104
  <AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
105
+ {chatbot.audiences.map((name) => (
106
+ <div key={name} className="mt-2">
107
+ <label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
108
+ Additional guidance for {name}
109
+ </label>
110
+ <textarea id={`audience-prompt-${name}`} aria-label={`Guidance for ${name}`} rows={2} maxLength={8000}
111
+ value={overlayValue(name)}
112
+ onChange={(e) => patch({ audiencePrompts: { ...chatbot.audiencePrompts, [name]: e.target.value } })}
113
+ placeholder={`Only ${name} sees answers shaped by this — safe for ${name}-level info.`}
114
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
115
+ </div>
116
+ ))}
90
117
  </div>
91
118
 
92
119
  <div>
@@ -138,11 +165,25 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
138
165
  <p className="mt-1 text-xs text-base-contrast-light">
139
166
  Tests your unsaved prompt and tasks. Answers ground on published content.
140
167
  </p>
168
+ {showTest && platformEnabled && (
169
+ <label className="mt-2 flex items-center gap-2 text-xs text-base-contrast">
170
+ Preview as
171
+ <select aria-label="Preview as" value={previewAudience} onChange={(e) => setPreviewAudience(e.target.value)}
172
+ className="rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
173
+ <option value="">Editor (base prompt only)</option>
174
+ {chatbot.audiences.map((n) => <option key={n} value={n}>{n}</option>)}
175
+ </select>
176
+ <span className="text-base-contrast-light">Prompt and task preview only — grounding stays editor-wide.</span>
177
+ </label>
178
+ )}
141
179
  {showTest && platformEnabled && (
142
180
  <ChatPanel
143
181
  open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
144
- tasks={chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
182
+ tasks={chatbot.tasks
183
+ .filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
184
+ .map((t) => ({ id: t.id, label: t.label }))}
145
185
  isEditor draftChatbot={chatbot}
186
+ simulateAudience={previewAudience || undefined}
146
187
  />
147
188
  )}
148
189
  </div>
@@ -1696,7 +1696,7 @@ function EditorToolbar({
1696
1696
  {!historyState && (
1697
1697
  <button
1698
1698
  onClick={toggleEditMode}
1699
- className="cursor-pointer fixed bottom-4 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1699
+ className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1700
1700
  aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
1701
1701
  >
1702
1702
  {isEditMode ? (
@@ -149,8 +149,16 @@ export const ChatbotConfigSchema = z
149
149
  .object({
150
150
  enabled: z.boolean().default(false),
151
151
  systemPrompt: z.string().max(8_000).default(""),
152
- // Audience names the widget is shown to; [] = all audiences.
152
+ // Audience names the widget is shown to — strict opt-in: [] = no viewer
153
+ // sees it (editors always can via /edit). Gates: [...slug].astro + /api/chat.
153
154
  audiences: z.array(z.string()).default([]),
155
+ // Per-audience prompt overlays, keyed by audience name. Appended AFTER the
156
+ // base systemPrompt, and ONLY for requests whose session audience matches
157
+ // (or an editor's explicit test-panel simulation) — enforced in
158
+ // assembleBrandChat. Keys are not validated against the audience list:
159
+ // entries for deselected/renamed audiences are inert (strict opt-in means
160
+ // those audiences cannot chat at all).
161
+ audiencePrompts: z.record(z.string(), z.string().max(8_000)).default({}),
154
162
  tasks: z.array(ChatbotTaskSchema).max(20).default([]),
155
163
  })
156
164
  .superRefine((cfg, ctx) => {
@@ -188,7 +196,7 @@ export const SiteConfigSchema = z.object({
188
196
  // Brand chatbot config (editor-owned; the platform kill switch lives in the
189
197
  // sites table, not here). Every field defaults so pre-existing
190
198
  // site-config.json files keep parsing.
191
- chatbot: ChatbotConfigSchema.default({ enabled: false, systemPrompt: "", audiences: [], tasks: [] }),
199
+ chatbot: ChatbotConfigSchema.default({ enabled: false, systemPrompt: "", audiences: [], tasks: [], audiencePrompts: {} }),
192
200
  });
193
201
 
194
202
  export type SiteConfig = z.infer<typeof SiteConfigSchema>;