@drawnagency/primitives 0.1.84 → 0.1.86

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.
@@ -15,7 +15,7 @@ import {
15
15
  buildBlockDropData,
16
16
  canDropBlock,
17
17
  isBlockDragData
18
- } from "./chunk-Q6LZOH5M.js";
18
+ } from "./chunk-YAPG44NS.js";
19
19
  import {
20
20
  EditableRichText,
21
21
  defineHeadingSection
@@ -544,7 +544,7 @@ var ChildBlockWrapper = forwardRef(
544
544
  {
545
545
  ref,
546
546
  className: cn(
547
- "group/childblock relative h-full rounded-sm",
547
+ "group/childblock relative h-full rounded-inner",
548
548
  isDragging && "opacity-50",
549
549
  "hover:outline hover:outline-2 hover:outline-primary/30"
550
550
  ),
@@ -376,7 +376,7 @@ function Tabs({ tabs, defaultTabId, activeTabId, onTabChange, fullBleedTabBar })
376
376
  },
377
377
  tab.id
378
378
  )) }) }),
379
- /* @__PURE__ */ jsx8("div", { role: "tabpanel", className: cn("px-px", fullBleedTabBar && "pt-4"), children: activeTab?.content })
379
+ /* @__PURE__ */ jsx8("div", { role: "tabpanel", className: cn("flex min-h-0 flex-1 flex-col px-px", fullBleedTabBar && "pt-4"), children: activeTab?.content })
380
380
  ] });
381
381
  }
382
382
 
@@ -33,5 +33,28 @@ export interface RebaseResult {
33
33
  * structure in both branches), and the result partitions: every section in
34
34
  * `sections` is reachable from `index`; a dirty section deleted remotely is
35
35
  * kept, re-linked into the index, and reported in `conflictIds`. */
36
+ /** Fold a peer's live (pre-save) index broadcast under our current index — the
37
+ * live-collab `onRemoteIndexUpdate` path, applied ONLY when we hold no unsaved
38
+ * structural work (so our index equals the last-saved branch: every id in it is
39
+ * a real, branch-backed section).
40
+ *
41
+ * Keep every peer entry whose id our current index already knows; withhold the
42
+ * rest, which is one of:
43
+ * (a) the peer's unsaved ADD — its section file is not on the branch yet, so
44
+ * letting it into our index would poison our next save with a dangling
45
+ * ref the server rejects (its live appearance is deferred to the peer's
46
+ * save event, which arrives via the saved-event sync); or
47
+ * (b) a section WE deleted unsaved — absent from our index; our deletion
48
+ * stands until save-time arbitration.
49
+ *
50
+ * The keep-set is our INDEX's section ids, NOT the section bodies we managed to
51
+ * load. A live, branch-backed section whose body was skipped by the client
52
+ * loader (schema/registry miss, missing file, depth trip) is still in our index
53
+ * and MUST survive — otherwise our next save posts an index missing it and the
54
+ * server rejects with "section … not found in site index" (or the write drops
55
+ * the live section). Taking the whole `localIndex` here, rather than a
56
+ * caller-computed id set, is deliberate: it makes passing the loaded-bodies set
57
+ * (the original bug) unrepresentable. Neither input is mutated. */
58
+ export declare function reconcileRemoteIndex(remoteIndex: SiteIndex, localIndex: SiteIndex): SiteIndex;
36
59
  export declare function rebaseContent(args: RebaseArgs): RebaseResult;
37
60
  //# sourceMappingURL=rebase.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rebase.d.ts","sourceRoot":"","sources":["../../src/collab/rebase.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAExD,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,aAAa,EAAE,CAAC;IAC/B,UAAU,EAAE,SAAS,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B;;;;;+EAK2E;IAC3E,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,WAAW,EAAE,SAAS,CAAC;IAKvB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,KAAK,EAAE,SAAS,CAAC;IAIjB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;;;;qEAUqE;AACrE,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,YAAY,CA2F5D"}
1
+ {"version":3,"file":"rebase.d.ts","sourceRoot":"","sources":["../../src/collab/rebase.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAExD,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,aAAa,EAAE,CAAC;IAC/B,UAAU,EAAE,SAAS,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B;;;;;+EAK2E;IAC3E,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,WAAW,EAAE,SAAS,CAAC;IAKvB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,KAAK,EAAE,SAAS,CAAC;IAIjB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;;;;qEAUqE;AACrE;;;;;;;;;;;;;;;;;;;;;oEAqBoE;AACpE,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,GAAG,SAAS,CAS7F;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,YAAY,CA2F5D"}
@@ -1,6 +1,16 @@
1
1
  import "../chunk-JSBRDJBE.js";
2
2
 
3
3
  // src/collab/rebase.ts
4
+ function reconcileRemoteIndex(remoteIndex, localIndex) {
5
+ const knownIds = new Set(Object.keys(localIndex.sections));
6
+ return {
7
+ ...remoteIndex,
8
+ pages: remoteIndex.pages.map((p) => ({ ...p, order: p.order.filter((id) => knownIds.has(id)) })),
9
+ sections: Object.fromEntries(
10
+ Object.entries(remoteIndex.sections).filter(([id]) => knownIds.has(id))
11
+ )
12
+ };
13
+ }
4
14
  function rebaseContent(args) {
5
15
  const localById = new Map(args.localSections.map((s) => [s.section.id, s]));
6
16
  const remoteIds = new Set(args.remoteSections.map((s) => s.section.id));
@@ -60,5 +70,6 @@ function rebaseContent(args) {
60
70
  return { sections: merged, index, conflictIds };
61
71
  }
62
72
  export {
63
- rebaseContent
73
+ rebaseContent,
74
+ reconcileRemoteIndex
64
75
  };
@@ -11,7 +11,7 @@ import {
11
11
  buildBlockDropData,
12
12
  canDropBlock,
13
13
  isBlockDragData
14
- } from "../../chunk-Q6LZOH5M.js";
14
+ } from "../../chunk-YAPG44NS.js";
15
15
  import "../../chunk-5THS4K5D.js";
16
16
  import "../../chunk-ZSPCE6MZ.js";
17
17
  import {
@@ -569,9 +569,9 @@ function SectionWrapper({
569
569
  "relative",
570
570
  isDragging && "opacity-50",
571
571
  isLocked && "opacity-60",
572
- isActive && "outline outline-2 outline-primary/50 rounded-sm",
573
- !isActive && showAllChrome && "outline outline-2 outline-primary/20 rounded-sm",
574
- !alwaysShow && "group-hover:outline group-hover:outline-2 group-hover:outline-primary/20 group-hover:rounded-sm"
572
+ isActive && "outline outline-2 outline-primary/50 rounded-outer",
573
+ !isActive && showAllChrome && "outline outline-2 outline-primary/20 rounded-outer",
574
+ !alwaysShow && "group-hover:outline group-hover:outline-2 group-hover:outline-primary/20 group-hover:rounded-outer"
575
575
  ),
576
576
  "data-section-id": sectionId,
577
577
  "data-section-type": sectionType,
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-56J4QMF4.js";
3
+ } from "../../chunk-2CVP2AGK.js";
4
4
  import "../../chunk-665DW3HR.js";
5
- import "../../chunk-Q6LZOH5M.js";
5
+ import "../../chunk-YAPG44NS.js";
6
6
  import "../../chunk-5THS4K5D.js";
7
7
  import "../../chunk-FDBZZ4AT.js";
8
8
  import "../../chunk-DKXISNJ2.js";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-56J4QMF4.js";
3
+ } from "../../chunk-2CVP2AGK.js";
4
4
  import "../../chunk-665DW3HR.js";
5
- import "../../chunk-Q6LZOH5M.js";
5
+ import "../../chunk-YAPG44NS.js";
6
6
  import "../../chunk-5THS4K5D.js";
7
7
  import "../../chunk-FDBZZ4AT.js";
8
8
  import "../../chunk-DKXISNJ2.js";
@@ -1 +1 @@
1
- {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AASjD,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAwDtF,OAAO,KAAK,EAAE,eAAe,EAA2D,MAAM,oBAAoB,CAAC;AAmCnH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAoBxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAKjC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC1C;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,EAC9B,eAAe,GAChB,EAAE,KAAK,2CAsiDP;AAuED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,YAAY,CAAC,EACvC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK/B"}
1
+ {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AASjD,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAuDtF,OAAO,KAAK,EAAE,eAAe,EAA2D,MAAM,oBAAoB,CAAC;AAmCnH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAoBxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAKjC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC1C;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,EAC9B,eAAe,GAChB,EAAE,KAAK,2CAshDP;AAuED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,YAAY,CAAC,EACvC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK/B"}
@@ -1,13 +1,11 @@
1
1
  import type { SiteConfig } from "../../schemas/site-config";
2
2
  import type { Audience } from "../../auth/types";
3
3
  interface Props {
4
- isOpen: boolean;
5
- onClose: () => void;
6
4
  siteConfig: SiteConfig;
7
5
  onSiteConfigChange: (config: SiteConfig) => void;
8
6
  audiences: Audience[];
9
7
  platformEnabled: boolean;
10
8
  }
11
- export declare function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props): import("react/jsx-runtime").JSX.Element;
9
+ export declare function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props): import("react/jsx-runtime").JSX.Element;
12
10
  export {};
13
- //# sourceMappingURL=ChatbotConfigModal.d.ts.map
11
+ //# sourceMappingURL=SiteSettingsChatbot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SiteSettingsChatbot.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsChatbot.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,2BAA2B,CAAC;AACxF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAGjD,UAAU,KAAK;IACb,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,mBAAmB,CAAC,EAAE,UAAU,EAAE,kBAAkB,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,KAAK,2CAwJxG"}
@@ -6,6 +6,8 @@ interface Props {
6
6
  siteConfig: SiteConfig;
7
7
  onSiteConfigChange: (config: SiteConfig) => void;
8
8
  onAudiencesChange: (audiences: Audience[]) => void;
9
+ audiences: Audience[];
10
+ chatbotPlatformEnabled: boolean;
9
11
  capabilities: {
10
12
  oauth: boolean;
11
13
  emailPassword: boolean;
@@ -19,6 +21,6 @@ interface Props {
19
21
  role: "owner" | "editor";
20
22
  } | null;
21
23
  }
22
- export declare function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, capabilities, currentUser }: Props): import("react/jsx-runtime").JSX.Element;
24
+ export declare function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, audiences, chatbotPlatformEnabled, capabilities, currentUser }: Props): import("react/jsx-runtime").JSX.Element;
23
25
  export {};
24
26
  //# sourceMappingURL=SiteSettingsModal.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SiteSettingsModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsModal.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,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,iBAAiB,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACnD,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;CACjE;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,YAAY,EAAE,WAAW,EAAE,EAAE,KAAK,2CA2FzI"}
1
+ {"version":3,"file":"SiteSettingsModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsModal.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,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,iBAAiB,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACnD,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,sBAAsB,EAAE,OAAO,CAAC;IAChC,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;CACjE;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,SAAS,EAAE,sBAAsB,EAAE,YAAY,EAAE,WAAW,EAAE,EAAE,KAAK,2CA0G5K"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -46,6 +46,39 @@ export interface RebaseResult {
46
46
  * structure in both branches), and the result partitions: every section in
47
47
  * `sections` is reachable from `index`; a dirty section deleted remotely is
48
48
  * kept, re-linked into the index, and reported in `conflictIds`. */
49
+ /** Fold a peer's live (pre-save) index broadcast under our current index — the
50
+ * live-collab `onRemoteIndexUpdate` path, applied ONLY when we hold no unsaved
51
+ * structural work (so our index equals the last-saved branch: every id in it is
52
+ * a real, branch-backed section).
53
+ *
54
+ * Keep every peer entry whose id our current index already knows; withhold the
55
+ * rest, which is one of:
56
+ * (a) the peer's unsaved ADD — its section file is not on the branch yet, so
57
+ * letting it into our index would poison our next save with a dangling
58
+ * ref the server rejects (its live appearance is deferred to the peer's
59
+ * save event, which arrives via the saved-event sync); or
60
+ * (b) a section WE deleted unsaved — absent from our index; our deletion
61
+ * stands until save-time arbitration.
62
+ *
63
+ * The keep-set is our INDEX's section ids, NOT the section bodies we managed to
64
+ * load. A live, branch-backed section whose body was skipped by the client
65
+ * loader (schema/registry miss, missing file, depth trip) is still in our index
66
+ * and MUST survive — otherwise our next save posts an index missing it and the
67
+ * server rejects with "section … not found in site index" (or the write drops
68
+ * the live section). Taking the whole `localIndex` here, rather than a
69
+ * caller-computed id set, is deliberate: it makes passing the loaded-bodies set
70
+ * (the original bug) unrepresentable. Neither input is mutated. */
71
+ export function reconcileRemoteIndex(remoteIndex: SiteIndex, localIndex: SiteIndex): SiteIndex {
72
+ const knownIds = new Set(Object.keys(localIndex.sections));
73
+ return {
74
+ ...remoteIndex,
75
+ pages: remoteIndex.pages.map((p) => ({ ...p, order: p.order.filter((id) => knownIds.has(id)) })),
76
+ sections: Object.fromEntries(
77
+ Object.entries(remoteIndex.sections).filter(([id]) => knownIds.has(id)),
78
+ ),
79
+ };
80
+ }
81
+
49
82
  export function rebaseContent(args: RebaseArgs): RebaseResult {
50
83
  const localById = new Map(args.localSections.map((s) => [s.section.id, s]));
51
84
  const remoteIds = new Set(args.remoteSections.map((s) => s.section.id));
@@ -53,7 +53,7 @@ export const ChildBlockWrapper = forwardRef<HTMLDivElement, ChildBlockWrapperPro
53
53
  <div
54
54
  ref={ref}
55
55
  className={cn(
56
- "group/childblock relative h-full rounded-sm",
56
+ "group/childblock relative h-full rounded-inner",
57
57
  isDragging && "opacity-50",
58
58
  "hover:outline hover:outline-2 hover:outline-primary/30",
59
59
  )}
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect } from "react";
2
- import { Check } from "lucide-react";
2
+ import { Check, ChevronDown, Home } from "lucide-react";
3
3
  import type { SiteIndex, Page } from "../../schemas/site-config";
4
4
  import type { Audience } from "../../auth/types";
5
5
  // Sanctioned outside->inside import: document/ is a sealed subtree, but the
@@ -8,7 +8,7 @@ import type { Audience } from "../../auth/types";
8
8
  import type { DocumentOptions } from "../../document/engine/settings";
9
9
  import { Button } from "../shared/Button";
10
10
  import { Input } from "../shared/Input";
11
- import { Checkbox } from "../shared/Checkbox";
11
+ import { Toggle } from "../shared/Toggle";
12
12
  import { AudienceIndicator } from "./AudienceIndicator";
13
13
  import { DragHandle } from "./DragHandle";
14
14
  import { useSortableRow } from "../../hooks/useSortableRow";
@@ -208,30 +208,35 @@ function PageRow({
208
208
  className={cn("relative rounded-md border border-base-200", isDragging && "opacity-50")}
209
209
  >
210
210
  <DropEdgeIndicator edge={closestEdge} />
211
- <div className="flex items-center gap-2 px-3 py-2">
211
+ <div className="flex items-stretch gap-2 pl-3">
212
212
  {draggable && (
213
- <div className="shrink-0 [&_[role=tooltip]]:hidden">
213
+ <div className="flex shrink-0 items-center py-2 [&_[role=tooltip]]:hidden">
214
214
  <DragHandle ref={handleRef} />
215
215
  </div>
216
216
  )}
217
- <div className="min-w-0 flex-1">
218
- <div className="flex items-center gap-2">
219
- <span className={cn("truncate text-sm font-medium text-base-contrast", !page.title && "italic text-base-contrast/60")}>
220
- {pageDisplayTitle(page.title)}
221
- </span>
222
- {page.isHome && <span className="rounded bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-contrast">Home page</span>}
223
- {!page.showInNav && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Hidden</span>}
224
- {page.status === "archived" && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Archived</span>}
225
- </div>
226
- <div className="text-xs text-base-contrast/70">{page.isHome ? "/" : `/${page.slug}`}</div>
227
- </div>
228
217
  <button
229
218
  type="button"
230
219
  aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
220
+ aria-expanded={expanded}
231
221
  onClick={handleHeaderClick}
232
- className="cursor-pointer rounded px-2 py-1 text-sm text-base-contrast/70 hover:text-primary"
222
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded py-2 pr-3 pl-4 text-left hover:bg-base-accent"
233
223
  >
234
- {expanded ? "Done" : "Edit"}
224
+ <div className="min-w-0 flex-1">
225
+ <div className="flex items-center gap-2">
226
+ <span className={cn("truncate text-sm font-medium text-base-contrast", !page.title && "italic text-base-contrast/60")}>
227
+ {pageDisplayTitle(page.title)}
228
+ </span>
229
+ {page.isHome && <Home size={14} aria-label="Home page" className="shrink-0 text-base-contrast/60" />}
230
+ {!page.showInNav && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Hidden</span>}
231
+ {page.status === "archived" && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Archived</span>}
232
+ </div>
233
+ <div className="text-xs text-base-contrast/70">{page.isHome ? "/" : `/${page.slug}`}</div>
234
+ </div>
235
+ <ChevronDown
236
+ size={16}
237
+ aria-hidden="true"
238
+ className={cn("shrink-0 text-base-contrast/50 transition-transform", expanded && "rotate-180")}
239
+ />
235
240
  </button>
236
241
  </div>
237
242
 
@@ -254,7 +259,7 @@ function PageRow({
254
259
  onChange={handleSlugChange}
255
260
  />
256
261
  )}
257
- <div className="flex items-start justify-between gap-4">
262
+ <div className="flex flex-col gap-3">
258
263
  <div>
259
264
  <label className="mb-1 block text-sm text-base-contrast">Audience</label>
260
265
  <AudienceIndicator access={page.access} audiences={audiences} onChange={onSetAudience} />
@@ -264,20 +269,23 @@ function PageRow({
264
269
  </p>
265
270
  )}
266
271
  </div>
267
- <Checkbox
268
- label="Hide from Menu"
269
- align="center"
270
- checked={!page.showInNav}
271
- onChange={(hidden) => onSetFields({ showInNav: !hidden })}
272
- className="shrink-0"
273
- />
272
+ <div className="flex flex-col divide-y divide-base-200 rounded-inner border border-base-200">
273
+ <div className="flex items-center justify-between gap-4 px-3 py-2.5">
274
+ <div className="min-w-0">
275
+ <div className="text-sm text-base-contrast">Show in menu</div>
276
+ <div className="text-xs text-base-contrast/70">List this page in the site navigation.</div>
277
+ </div>
278
+ <Toggle label="Show in menu" checked={page.showInNav} onChange={(v) => onSetFields({ showInNav: v })} />
279
+ </div>
280
+ <div className="flex items-center justify-between gap-4 px-3 py-2.5">
281
+ <div className="min-w-0">
282
+ <div className="text-sm text-base-contrast">Document mode</div>
283
+ <div className="text-xs text-base-contrast/70">Adds a presentation view of this page.</div>
284
+ </div>
285
+ <Toggle label="Document mode" checked={page.document?.enabled ?? false} onChange={(enabled) => onSetDocument({ enabled })} />
286
+ </div>
287
+ </div>
274
288
  </div>
275
- <Checkbox
276
- label="Document mode"
277
- description="Adds a presentation view of this page."
278
- checked={page.document?.enabled ?? false}
279
- onChange={(enabled) => onSetDocument({ enabled })}
280
- />
281
289
  <div className="flex items-center gap-2">
282
290
  <Button
283
291
  variant="secondary"
@@ -246,12 +246,12 @@ export function SectionWrapper({
246
246
  "relative",
247
247
  isDragging && "opacity-50",
248
248
  isLocked && "opacity-60",
249
- isActive && "outline outline-2 outline-primary/50 rounded-sm",
249
+ isActive && "outline outline-2 outline-primary/50 rounded-outer",
250
250
  !isActive &&
251
251
  showAllChrome &&
252
- "outline outline-2 outline-primary/20 rounded-sm",
252
+ "outline outline-2 outline-primary/20 rounded-outer",
253
253
  !alwaysShow &&
254
- "group-hover:outline group-hover:outline-2 group-hover:outline-primary/20 group-hover:rounded-sm",
254
+ "group-hover:outline group-hover:outline-2 group-hover:outline-primary/20 group-hover:rounded-outer",
255
255
  )}
256
256
  data-section-id={sectionId}
257
257
  data-section-type={sectionType}
@@ -60,7 +60,7 @@ export function Tabs({ tabs, defaultTabId, activeTabId, onTabChange, fullBleedTa
60
60
  {/* px-px: the root's overflow-hidden would clip the 1px focus ring of
61
61
  inputs that span the full panel width (rings are box-shadows drawn
62
62
  outside the border box) — give them 1px to paint into. */}
63
- <div role="tabpanel" className={cn("px-px", fullBleedTabBar && "pt-4")}>{activeTab?.content}</div>
63
+ <div role="tabpanel" className={cn("flex min-h-0 flex-1 flex-col px-px", fullBleedTabBar && "pt-4")}>{activeTab?.content}</div>
64
64
  </div>
65
65
  );
66
66
  }
@@ -23,7 +23,6 @@ import type { BlockMove } from "../../lib/block-move";
23
23
  import { EditorModalProvider, useEditorModal } from "./EditorModalContext";
24
24
  import { EditorModal } from "./EditorModal";
25
25
  import { SiteSettingsModal } from "./SiteSettingsModal";
26
- import { ChatbotConfigModal } from "./ChatbotConfigModal";
27
26
  import ChatWidget from "../chat/ChatWidget";
28
27
  import { MediaLibraryModal } from "./MediaLibraryModal";
29
28
  import { MediaLibraryContext } from "./MediaLibraryContext";
@@ -69,7 +68,7 @@ import {
69
68
  deleteSectionRows,
70
69
  preserveEdit,
71
70
  } from "../../lib/dexie";
72
- import { rebaseContent } from "../../collab/rebase";
71
+ import { rebaseContent, reconcileRemoteIndex } from "../../collab/rebase";
73
72
  import { deriveRecoveryInputs, applyConflictChoices } from "../../collab/recovery";
74
73
  import { getCollabProvider } from "../../collab/provider-registry";
75
74
  import type { CollabBootstrap, CollabPeer, CollabStatus, SavedMessage, TakeoverMessage } from "../../collab/types";
@@ -100,7 +99,7 @@ import { IconButton } from "../shared/IconButton";
100
99
  import { SegmentedControl } from "../shared/SegmentedControl";
101
100
  import { computeDisplaySections } from "./view-branch";
102
101
  import { SettingsIcon } from "../shared/icons";
103
- import { ImageIcon, ListOrderedIcon, FilesIcon, MessageCircleIcon, X } from "lucide-react";
102
+ import { ImageIcon, ListOrderedIcon, FilesIcon, JoystickIcon, X } from "lucide-react";
104
103
  import { ErrorBoundary } from "../shared/ErrorBoundary";
105
104
  import { HistoryToolbar } from "./HistoryToolbar";
106
105
  import { RestoreModal } from "./RestoreModal";
@@ -201,7 +200,6 @@ export default function EditorShell({
201
200
  const [showRestoreModal, setShowRestoreModal] = useState(false);
202
201
  const [showOrderingModal, setShowOrderingModal] = useState(false);
203
202
  const [showPagesModal, setShowPagesModal] = useState(false);
204
- const [showChatbotConfig, setShowChatbotConfig] = useState(false);
205
203
  const [showDocumentEditor, setShowDocumentEditor] = useState(false);
206
204
  const [isRestoring, setIsRestoring] = useState(false);
207
205
  const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
@@ -634,22 +632,16 @@ export default function EditorShell({
634
632
  if (persistence.hasUnsavedIndex()) return;
635
633
  const parsed = IndexSchema.safeParse(msg.index);
636
634
  if (!parsed.success) return;
637
- // PRUNE ids we hold no content for before applying. An id in the peer's
638
- // index with no counterpart in our local sections state is either (a) the
639
- // peer's unsaved ADD — its section file is not on the branch yet, so
640
- // letting it into siteIndexRef would poison OUR next save with a dangling
641
- // index ref the server rejects (live new-section appearance is deliberately
642
- // deferred to the peer's save, which arrives via the saved-event sync); or
643
- // (b) a section WE deleted unsaved — our deletion stands until save-time
644
- // arbitration.
645
- const localIds = new Set(sectionsRef.current.map((s) => s.section.id));
646
- const pruned: SiteIndex = {
647
- ...parsed.data,
648
- pages: parsed.data.pages.map((p) => ({ ...p, order: p.order.filter((id) => localIds.has(id)) })),
649
- sections: Object.fromEntries(
650
- Object.entries(parsed.data.sections).filter(([id]) => localIds.has(id)),
651
- ),
652
- };
635
+ // Withhold ids our current index doesn't know before applying the peer's:
636
+ // (a) the peer's unsaved ADD — its file isn't on the branch yet, so letting
637
+ // it into siteIndexRef would poison OUR next save with a dangling index ref
638
+ // the server rejects (its live appearance is deferred to the peer's save,
639
+ // via the saved-event sync); or (b) a section WE deleted unsaved — our
640
+ // deletion stands until save-time arbitration. Keyed on our INDEX, not the
641
+ // loaded section bodies: a live section whose body the loader skipped
642
+ // (schema/registry miss, depth trip) is still indexed and must survive —
643
+ // guaranteed because we only reach here with no unsaved index work.
644
+ const pruned = reconcileRemoteIndex(parsed.data, siteIndexRef.current);
653
645
  setSiteIndex(pruned);
654
646
  siteIndexRef.current = pruned;
655
647
  },
@@ -1445,7 +1437,6 @@ export default function EditorShell({
1445
1437
  onRestoreClick={() => setShowRestoreModal(true)}
1446
1438
  onOrderingClick={() => setShowOrderingModal(true)}
1447
1439
  onPagesClick={() => setShowPagesModal(true)}
1448
- onChatbotClick={() => setShowChatbotConfig(true)}
1449
1440
  collabPeers={collab.peers}
1450
1441
  collabSelfId={collabBootstrap?.userId ?? null}
1451
1442
  collabStatus={collab.status}
@@ -1577,19 +1568,11 @@ export default function EditorShell({
1577
1568
  siteConfig={siteConfig!}
1578
1569
  onSiteConfigChange={handleSiteConfigChange}
1579
1570
  onAudiencesChange={setAudiences}
1571
+ audiences={audiences}
1572
+ chatbotPlatformEnabled={chatbotPlatformEnabled}
1580
1573
  capabilities={capabilities}
1581
1574
  currentUser={currentUser}
1582
1575
  />
1583
- {siteConfig && (
1584
- <ChatbotConfigModal
1585
- isOpen={showChatbotConfig}
1586
- onClose={() => setShowChatbotConfig(false)}
1587
- siteConfig={siteConfig}
1588
- onSiteConfigChange={handleSiteConfigChange}
1589
- audiences={audiences}
1590
- platformEnabled={chatbotPlatformEnabled}
1591
- />
1592
- )}
1593
1576
  <EditorModal
1594
1577
  isOpen={pendingDeleteSectionId !== null}
1595
1578
  onClose={() => setPendingDeleteSectionId(null)}
@@ -2280,7 +2263,6 @@ function EditorToolbar({
2280
2263
  onRestoreClick,
2281
2264
  onOrderingClick,
2282
2265
  onPagesClick,
2283
- onChatbotClick,
2284
2266
  collabPeers,
2285
2267
  collabSelfId,
2286
2268
  collabStatus,
@@ -2303,7 +2285,6 @@ function EditorToolbar({
2303
2285
  onRestoreClick: () => void;
2304
2286
  onOrderingClick: () => void;
2305
2287
  onPagesClick: () => void;
2306
- onChatbotClick: () => void;
2307
2288
  collabPeers: CollabPeer[];
2308
2289
  collabSelfId: string | null;
2309
2290
  collabStatus: CollabStatus;
@@ -2377,42 +2358,11 @@ function EditorToolbar({
2377
2358
  </div>
2378
2359
  <div className="flex items-center justify-end gap-2">
2379
2360
  <ProcessingIndicator items={processingItems} />
2380
- <IconButton
2381
- icon={<ListOrderedIcon size={16} />}
2382
- label="Reorder sections"
2383
- size="md"
2384
- onClick={onOrderingClick}
2385
- className="border border-base-200 bg-base-accent"
2386
- />
2387
- <IconButton
2388
- icon={<ImageIcon size={16} />}
2389
- label="Media library"
2390
- size="md"
2391
- onClick={onMediaClick}
2392
- className="border border-base-200 bg-base-accent"
2393
- />
2394
- <IconButton
2395
- icon={<FilesIcon size={16} />}
2396
- label="Pages"
2397
- size="md"
2398
- onClick={onPagesClick}
2399
- className="border border-base-200 bg-base-accent"
2400
- />
2401
- <IconButton
2402
- icon={<MessageCircleIcon size={16} />}
2403
- label="Chatbot"
2404
- size="md"
2405
- onClick={onChatbotClick}
2406
- className="border border-base-200 bg-base-accent"
2407
- />
2408
- <IconButton
2409
- icon={<SettingsIcon size={16} />}
2410
- label="Site settings"
2411
- size="md"
2412
- onClick={onSettingsClick}
2413
- className="border border-base-200 bg-base-accent"
2414
- />
2361
+ <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
2362
+ <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
2363
+ <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
2415
2364
  <ShowAllChromeToggle />
2365
+ <IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
2416
2366
  </div>
2417
2367
  </div>
2418
2368
  </div>
@@ -2457,14 +2407,13 @@ function ShowAllChromeToggle() {
2457
2407
  const { showAllChrome, toggleShowAllChrome } = useEditorContext();
2458
2408
 
2459
2409
  return (
2460
- <Button
2461
- variant="secondary"
2462
- size="sm"
2410
+ <IconButton
2411
+ icon={<JoystickIcon size={16} />}
2412
+ label="Toggle editor chrome visibility"
2413
+ size="md"
2463
2414
  onClick={toggleShowAllChrome}
2464
- className="bg-base-accent"
2465
- aria-label="Toggle editor chrome visibility"
2466
- >
2467
- {showAllChrome ? "Hide Controls" : "Show Controls"}
2468
- </Button>
2415
+ aria-pressed={showAllChrome}
2416
+ className="border border-base-200 bg-base-accent"
2417
+ />
2469
2418
  );
2470
2419
  }
@@ -0,0 +1,188 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Button } from "../shared/Button";
3
+ import { cn } from "../../lib/cn";
4
+ import ChatPanel from "../chat/ChatPanel";
5
+ import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
6
+ import type { Audience } from "../../auth/types";
7
+ import { audiencePasses } from "../../lib/pages";
8
+
9
+ interface Props {
10
+ siteConfig: SiteConfig;
11
+ onSiteConfigChange: (config: SiteConfig) => void;
12
+ audiences: Audience[];
13
+ platformEnabled: boolean;
14
+ }
15
+
16
+ function slugify(label: string): string {
17
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
18
+ }
19
+
20
+ function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[]; selected: string[]; onToggle: (name: string) => void }) {
21
+ return (
22
+ <div className="flex flex-wrap gap-1">
23
+ {audiences.map((a) => (
24
+ <button key={a.name} type="button" onClick={() => onToggle(a.name)}
25
+ className={cn("rounded-full border px-2 py-0.5 text-xs",
26
+ 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}>
28
+ {a.displayName}
29
+ </button>
30
+ ))}
31
+ {audiences.length === 0 && <span className="text-xs text-base-contrast-light">No audiences configured</span>}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props) {
37
+ const chatbot = siteConfig.chatbot;
38
+ const [newTaskLabel, setNewTaskLabel] = useState("");
39
+ const [showTest, setShowTest] = useState(false);
40
+ const [previewAudience, setPreviewAudience] = useState("");
41
+
42
+ // A deselected audience can no longer chat — never keep simulating it.
43
+ useEffect(() => {
44
+ if (previewAudience && !chatbot.audiences.includes(previewAudience)) setPreviewAudience("");
45
+ }, [previewAudience, chatbot.audiences]);
46
+
47
+ const patch = (p: Partial<ChatbotConfig>) =>
48
+ onSiteConfigChange({ ...siteConfig, chatbot: { ...chatbot, ...p } });
49
+
50
+ const patchTask = (i: number, p: Partial<ChatbotTask>) =>
51
+ patch({ tasks: chatbot.tasks.map((t, idx) => (idx === i ? { ...t, ...p } : t)) });
52
+
53
+ function addTask() {
54
+ const label = newTaskLabel.trim();
55
+ if (!label || chatbot.tasks.length >= 20) return;
56
+ let id = slugify(label);
57
+ if (chatbot.tasks.some((t) => t.id === id)) {
58
+ for (let n = 2; ; n++) {
59
+ const suffix = `-${n}`;
60
+ const candidate = `${id.slice(0, 32 - suffix.length)}${suffix}`;
61
+ if (!chatbot.tasks.some((t) => t.id === candidate)) {
62
+ id = candidate;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ patch({ tasks: [...chatbot.tasks, { id, label, prompt: "", access: [] }] });
68
+ setNewTaskLabel("");
69
+ }
70
+
71
+ const toggleName = (list: string[], name: string) =>
72
+ list.includes(name) ? list.filter((n) => n !== name) : [...list, name];
73
+
74
+ // Record lookups can hit inherited Object.prototype members ("constructor"
75
+ // is a legal audience name) — treat anything non-string as absent.
76
+ const overlayValue = (name: string) => {
77
+ const v = chatbot.audiencePrompts[name];
78
+ return typeof v === "string" ? v : "";
79
+ };
80
+
81
+ return (
82
+ <div className="flex flex-col gap-4">
83
+ {!platformEnabled && (
84
+ <div className="rounded border border-base-200 bg-base-accent px-3 py-2 text-sm text-base-contrast">
85
+ Chat is not enabled for this site — contact Drawn Agency. You can still edit the
86
+ configuration below; it activates when chat is switched on.
87
+ </div>
88
+ )}
89
+
90
+ <label className="flex items-center gap-2 text-sm text-base-contrast">
91
+ <input type="checkbox" aria-label="Enable chatbot" checked={chatbot.enabled}
92
+ className="h-5 w-5 cursor-pointer accent-primary"
93
+ onChange={(e) => patch({ enabled: e.target.checked })} />
94
+ Show the chat widget to viewers
95
+ </label>
96
+
97
+ <div>
98
+ <p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
99
+ <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
+ <AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
101
+ {chatbot.audiences.map((name) => (
102
+ <div key={name} className="mt-2">
103
+ <label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
104
+ Additional guidance for {name}
105
+ </label>
106
+ <textarea id={`audience-prompt-${name}`} aria-label={`Guidance for ${name}`} rows={2} maxLength={8000}
107
+ value={overlayValue(name)}
108
+ onChange={(e) => patch({ audiencePrompts: { ...chatbot.audiencePrompts, [name]: e.target.value } })}
109
+ placeholder={`Only ${name} sees answers shaped by this — safe for ${name}-level info.`}
110
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
111
+ </div>
112
+ ))}
113
+ </div>
114
+
115
+ <div>
116
+ <label htmlFor="chatbot-system-prompt" className="mb-1 block text-sm font-medium text-base-contrast">System prompt</label>
117
+ <textarea id="chatbot-system-prompt" aria-label="System prompt" rows={5} maxLength={8000}
118
+ value={chatbot.systemPrompt}
119
+ onChange={(e) => patch({ systemPrompt: e.target.value })}
120
+ placeholder="Describe the brand's voice and how the assistant should behave — e.g. “Speak warmly and concisely; always reference our color names, never hex codes.”"
121
+ className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
122
+ <p className="text-right text-xs text-base-contrast-light">{chatbot.systemPrompt.length}/8000</p>
123
+ </div>
124
+
125
+ <div>
126
+ <p className="mb-1 text-sm font-medium text-base-contrast">Tasks</p>
127
+ <p className="mb-2 text-xs text-base-contrast-light">Goal presets shown as a dropdown in the widget. Each task layers its own instructions onto the system prompt.</p>
128
+ <div className="space-y-3">
129
+ {chatbot.tasks.map((t, i) => (
130
+ <div key={t.id} className="rounded border border-base-200 p-2">
131
+ <div className="mb-1 flex items-center justify-between gap-2">
132
+ <input aria-label={`Task ${t.id} label`} value={t.label} maxLength={60}
133
+ onChange={(e) => patchTask(i, { label: e.target.value })}
134
+ className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
135
+ <Button type="button" variant="destructive"
136
+ onClick={() => patch({ tasks: chatbot.tasks.filter((_, idx) => idx !== i) })}>
137
+ Remove
138
+ </Button>
139
+ </div>
140
+ <textarea aria-label={`Task ${t.id} prompt`} rows={2} maxLength={4000} value={t.prompt}
141
+ onChange={(e) => patchTask(i, { prompt: e.target.value })}
142
+ placeholder="Instructions for this task…"
143
+ className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm" />
144
+ <AudienceChips audiences={audiences} selected={t.access} onToggle={(n) => patchTask(i, { access: toggleName(t.access, n) })} />
145
+ </div>
146
+ ))}
147
+ </div>
148
+ <div className="mt-2 flex gap-2">
149
+ <input aria-label="New task label" value={newTaskLabel} maxLength={60}
150
+ onChange={(e) => setNewTaskLabel(e.target.value)}
151
+ placeholder="e.g. Draft a social post"
152
+ className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
153
+ <Button type="button" onClick={addTask}>Add task</Button>
154
+ </div>
155
+ </div>
156
+
157
+ <div>
158
+ <Button type="button" onClick={() => setShowTest((s) => !s)} disabled={!platformEnabled}>
159
+ {showTest ? "Hide test chat" : "Test with current draft"}
160
+ </Button>
161
+ <p className="mt-1 text-xs text-base-contrast-light">
162
+ Tests your unsaved prompt and tasks. Answers ground on published content.
163
+ </p>
164
+ {showTest && platformEnabled && (
165
+ <label className="mt-2 flex items-center gap-2 text-xs text-base-contrast">
166
+ Preview as
167
+ <select aria-label="Preview as" value={previewAudience} onChange={(e) => setPreviewAudience(e.target.value)}
168
+ className="rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
169
+ <option value="">Editor (base prompt only)</option>
170
+ {chatbot.audiences.map((n) => <option key={n} value={n}>{n}</option>)}
171
+ </select>
172
+ <span className="text-base-contrast-light">Prompt and task preview only — grounding stays editor-wide.</span>
173
+ </label>
174
+ )}
175
+ {showTest && platformEnabled && (
176
+ <ChatPanel
177
+ open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
178
+ tasks={chatbot.tasks
179
+ .filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
180
+ .map((t) => ({ id: t.id, label: t.label }))}
181
+ isEditor draftChatbot={chatbot}
182
+ simulateAudience={previewAudience || undefined}
183
+ />
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ }
@@ -5,6 +5,7 @@ import { Tabs } from "../shared/Tabs";
5
5
  import { SiteSettingsViewerAccess } from "./SiteSettingsViewerAccess";
6
6
  import { SiteSettingsDisplay } from "./SiteSettingsDisplay";
7
7
  import { SiteSettingsUsers } from "./SiteSettingsUsers";
8
+ import { SiteSettingsChatbot } from "./SiteSettingsChatbot";
8
9
  import type { SiteConfig } from "../../schemas/site-config";
9
10
  import type { Audience } from "../../auth/types";
10
11
 
@@ -14,6 +15,8 @@ interface Props {
14
15
  siteConfig: SiteConfig;
15
16
  onSiteConfigChange: (config: SiteConfig) => void;
16
17
  onAudiencesChange: (audiences: Audience[]) => void;
18
+ audiences: Audience[];
19
+ chatbotPlatformEnabled: boolean;
17
20
  capabilities: {
18
21
  oauth: boolean;
19
22
  emailPassword: boolean;
@@ -25,7 +28,7 @@ interface Props {
25
28
  currentUser: { email: string; role: "owner" | "editor" } | null;
26
29
  }
27
30
 
28
- export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, capabilities, currentUser }: Props) {
31
+ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, audiences, chatbotPlatformEnabled, capabilities, currentUser }: Props) {
29
32
  const [signOutError, setSignOutError] = useState<string | null>(null);
30
33
 
31
34
  async function handleSignOut() {
@@ -50,25 +53,30 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
50
53
 
51
54
  const allTabs = [
52
55
  {
53
- id: "users",
54
- label: "Users",
55
- show: capabilities.userManagement && currentUser?.role === "owner",
56
+ id: "viewer-access",
57
+ label: "Audiences",
58
+ show: true,
56
59
  content: (
57
60
  <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
58
- <SiteSettingsUsers currentUser={currentUser} />
61
+ <SiteSettingsViewerAccess
62
+ audienceManagement={capabilities.audienceManagement}
63
+ passwordToggle={capabilities.passwordToggle}
64
+ onAudiencesChange={onAudiencesChange}
65
+ />
59
66
  </div>
60
67
  ),
61
68
  },
62
69
  {
63
- id: "viewer-access",
64
- label: "Viewer Access",
70
+ id: "chatbot",
71
+ label: "Chatbot",
65
72
  show: true,
66
73
  content: (
67
74
  <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
68
- <SiteSettingsViewerAccess
69
- audienceManagement={capabilities.audienceManagement}
70
- passwordToggle={capabilities.passwordToggle}
71
- onAudiencesChange={onAudiencesChange}
75
+ <SiteSettingsChatbot
76
+ siteConfig={siteConfig}
77
+ onSiteConfigChange={onSiteConfigChange}
78
+ audiences={audiences}
79
+ platformEnabled={chatbotPlatformEnabled}
72
80
  />
73
81
  </div>
74
82
  ),
@@ -83,6 +91,16 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
83
91
  </div>
84
92
  ),
85
93
  },
94
+ {
95
+ id: "users",
96
+ label: "Users",
97
+ show: capabilities.userManagement && currentUser?.role === "owner",
98
+ content: (
99
+ <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
100
+ <SiteSettingsUsers currentUser={currentUser} />
101
+ </div>
102
+ ),
103
+ },
86
104
  ];
87
105
 
88
106
  const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
@@ -1 +0,0 @@
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"}
@@ -1,193 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { EditorModal } from "./EditorModal";
3
- import { Button } from "../shared/Button";
4
- import { cn } from "../../lib/cn";
5
- import ChatPanel from "../chat/ChatPanel";
6
- import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
7
- import type { Audience } from "../../auth/types";
8
- import { audiencePasses } from "../../lib/pages";
9
-
10
- interface Props {
11
- isOpen: boolean;
12
- onClose: () => void;
13
- siteConfig: SiteConfig;
14
- onSiteConfigChange: (config: SiteConfig) => void;
15
- audiences: Audience[];
16
- platformEnabled: boolean;
17
- }
18
-
19
- function slugify(label: string): string {
20
- return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
21
- }
22
-
23
- function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[]; selected: string[]; onToggle: (name: string) => void }) {
24
- return (
25
- <div className="flex flex-wrap gap-1">
26
- {audiences.map((a) => (
27
- <button key={a.name} type="button" onClick={() => onToggle(a.name)}
28
- className={cn("rounded-full border px-2 py-0.5 text-xs",
29
- selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
30
- style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color } : undefined}>
31
- {a.displayName}
32
- </button>
33
- ))}
34
- {audiences.length === 0 && <span className="text-xs text-base-contrast-light">No audiences configured</span>}
35
- </div>
36
- );
37
- }
38
-
39
- export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props) {
40
- const chatbot = siteConfig.chatbot;
41
- const [newTaskLabel, setNewTaskLabel] = useState("");
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]);
49
-
50
- const patch = (p: Partial<ChatbotConfig>) =>
51
- onSiteConfigChange({ ...siteConfig, chatbot: { ...chatbot, ...p } });
52
-
53
- const patchTask = (i: number, p: Partial<ChatbotTask>) =>
54
- patch({ tasks: chatbot.tasks.map((t, idx) => (idx === i ? { ...t, ...p } : t)) });
55
-
56
- function addTask() {
57
- const label = newTaskLabel.trim();
58
- if (!label || chatbot.tasks.length >= 20) return;
59
- let id = slugify(label);
60
- if (chatbot.tasks.some((t) => t.id === id)) {
61
- for (let n = 2; ; n++) {
62
- const suffix = `-${n}`;
63
- const candidate = `${id.slice(0, 32 - suffix.length)}${suffix}`;
64
- if (!chatbot.tasks.some((t) => t.id === candidate)) {
65
- id = candidate;
66
- break;
67
- }
68
- }
69
- }
70
- patch({ tasks: [...chatbot.tasks, { id, label, prompt: "", access: [] }] });
71
- setNewTaskLabel("");
72
- }
73
-
74
- const toggleName = (list: string[], name: string) =>
75
- list.includes(name) ? list.filter((n) => n !== name) : [...list, name];
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
-
84
- return (
85
- <EditorModal isOpen={isOpen} onClose={onClose} title="Chatbot" size="settings" noPadding>
86
- <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4">
87
- {!platformEnabled && (
88
- <div className="rounded border border-base-200 bg-base-accent px-3 py-2 text-sm text-base-contrast">
89
- Chat is not enabled for this site — contact Drawn Agency. You can still edit the
90
- configuration below; it activates when chat is switched on.
91
- </div>
92
- )}
93
-
94
- <label className="flex items-center gap-2 text-sm text-base-contrast">
95
- <input type="checkbox" aria-label="Enable chatbot" checked={chatbot.enabled}
96
- className="h-5 w-5 cursor-pointer accent-primary"
97
- onChange={(e) => patch({ enabled: e.target.checked })} />
98
- Show the chat widget to viewers
99
- </label>
100
-
101
- <div>
102
- <p className="mb-1 text-sm font-medium text-base-contrast">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>
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
- ))}
117
- </div>
118
-
119
- <div>
120
- <label htmlFor="chatbot-system-prompt" className="mb-1 block text-sm font-medium text-base-contrast">System prompt</label>
121
- <textarea id="chatbot-system-prompt" aria-label="System prompt" rows={5} maxLength={8000}
122
- value={chatbot.systemPrompt}
123
- onChange={(e) => patch({ systemPrompt: e.target.value })}
124
- placeholder="Describe the brand's voice and how the assistant should behave — e.g. “Speak warmly and concisely; always reference our color names, never hex codes.”"
125
- className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
126
- <p className="text-right text-xs text-base-contrast-light">{chatbot.systemPrompt.length}/8000</p>
127
- </div>
128
-
129
- <div>
130
- <p className="mb-1 text-sm font-medium text-base-contrast">Tasks</p>
131
- <p className="mb-2 text-xs text-base-contrast-light">Goal presets shown as a dropdown in the widget. Each task layers its own instructions onto the system prompt.</p>
132
- <div className="space-y-3">
133
- {chatbot.tasks.map((t, i) => (
134
- <div key={t.id} className="rounded border border-base-200 p-2">
135
- <div className="mb-1 flex items-center justify-between gap-2">
136
- <input aria-label={`Task ${t.id} label`} value={t.label} maxLength={60}
137
- onChange={(e) => patchTask(i, { label: e.target.value })}
138
- className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
139
- <Button type="button" variant="destructive"
140
- onClick={() => patch({ tasks: chatbot.tasks.filter((_, idx) => idx !== i) })}>
141
- Remove
142
- </Button>
143
- </div>
144
- <textarea aria-label={`Task ${t.id} prompt`} rows={2} maxLength={4000} value={t.prompt}
145
- onChange={(e) => patchTask(i, { prompt: e.target.value })}
146
- placeholder="Instructions for this task…"
147
- className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm" />
148
- <AudienceChips audiences={audiences} selected={t.access} onToggle={(n) => patchTask(i, { access: toggleName(t.access, n) })} />
149
- </div>
150
- ))}
151
- </div>
152
- <div className="mt-2 flex gap-2">
153
- <input aria-label="New task label" value={newTaskLabel} maxLength={60}
154
- onChange={(e) => setNewTaskLabel(e.target.value)}
155
- placeholder="e.g. Draft a social post"
156
- className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
157
- <Button type="button" onClick={addTask}>Add task</Button>
158
- </div>
159
- </div>
160
-
161
- <div>
162
- <Button type="button" onClick={() => setShowTest((s) => !s)} disabled={!platformEnabled}>
163
- {showTest ? "Hide test chat" : "Test with current draft"}
164
- </Button>
165
- <p className="mt-1 text-xs text-base-contrast-light">
166
- Tests your unsaved prompt and tasks. Answers ground on published content.
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
- )}
179
- {showTest && platformEnabled && (
180
- <ChatPanel
181
- open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
182
- tasks={chatbot.tasks
183
- .filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
184
- .map((t) => ({ id: t.id, label: t.label }))}
185
- isEditor draftChatbot={chatbot}
186
- simulateAudience={previewAudience || undefined}
187
- />
188
- )}
189
- </div>
190
- </div>
191
- </EditorModal>
192
- );
193
- }