@drawnagency/primitives 0.1.84 → 0.1.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/{chunk-665DW3HR.js → chunk-6VOIZP2O.js} +3 -3
  2. package/dist/{chunk-YKOLOZB2.js → chunk-DRCPDOW3.js} +14 -0
  3. package/dist/{chunk-MBJUWOM2.js → chunk-EO7K45GV.js} +1 -1
  4. package/dist/{chunk-DKXISNJ2.js → chunk-NEDOJHHR.js} +2 -2
  5. package/dist/{chunk-DO3ZENLN.js → chunk-OEL57NYV.js} +1 -1
  6. package/dist/{chunk-FDBZZ4AT.js → chunk-PTVY3Q7P.js} +1 -1
  7. package/dist/{chunk-56J4QMF4.js → chunk-VY5V4HBV.js} +7 -7
  8. package/dist/{chunk-EP3LVOXZ.js → chunk-WA3HFN54.js} +1 -1
  9. package/dist/{chunk-Q6LZOH5M.js → chunk-XDG2K6UT.js} +2 -2
  10. package/dist/{chunk-E3MTIMOG.js → chunk-XPUBUR64.js} +10 -0
  11. package/dist/collab/rebase.d.ts +23 -0
  12. package/dist/collab/rebase.d.ts.map +1 -1
  13. package/dist/collab/rebase.js +12 -1
  14. package/dist/components/editor/index.js +7 -7
  15. package/dist/components/primitives/index.js +5 -5
  16. package/dist/components/sections/brand-guide.js +5 -5
  17. package/dist/components/sections/register-schemas.js +8 -8
  18. package/dist/components/sections/register.js +8 -8
  19. package/dist/components/shared/Navigation.d.ts +6 -1
  20. package/dist/components/shared/Navigation.d.ts.map +1 -1
  21. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  22. package/dist/components/shell/{ChatbotConfigModal.d.ts → SiteSettingsChatbot.d.ts} +2 -4
  23. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -0
  24. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  25. package/dist/components/shell/SiteSettingsModal.d.ts +3 -1
  26. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  27. package/dist/hooks/useMediaPipeline.d.ts +2 -1
  28. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  29. package/dist/index.js +6 -4
  30. package/dist/lib/image-refs.d.ts +9 -0
  31. package/dist/lib/image-refs.d.ts.map +1 -1
  32. package/dist/lib/index.js +2 -2
  33. package/dist/media/index.js +4 -2
  34. package/dist/media/resolve.d.ts +25 -0
  35. package/dist/media/resolve.d.ts.map +1 -1
  36. package/dist/schemas/index.js +2 -2
  37. package/dist/schemas/site-config.d.ts +10 -0
  38. package/dist/schemas/site-config.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/collab/rebase.ts +33 -0
  41. package/src/components/editor/ChildBlockWrapper.tsx +1 -1
  42. package/src/components/editor/PagesModal.tsx +39 -31
  43. package/src/components/editor/SectionWrapper.tsx +3 -3
  44. package/src/components/shared/Navigation.tsx +20 -2
  45. package/src/components/shared/Tabs.tsx +1 -1
  46. package/src/components/shell/EditorShell.tsx +29 -76
  47. package/src/components/shell/SiteSettingsChatbot.tsx +188 -0
  48. package/src/components/shell/SiteSettingsDisplay.tsx +106 -0
  49. package/src/components/shell/SiteSettingsModal.tsx +29 -11
  50. package/src/hooks/useMediaPipeline.ts +16 -3
  51. package/src/lib/image-refs.ts +19 -0
  52. package/src/media/resolve.ts +34 -0
  53. package/src/schemas/site-config.ts +13 -0
  54. package/dist/components/shell/ChatbotConfigModal.d.ts.map +0 -1
  55. package/src/components/shell/ChatbotConfigModal.tsx +0 -193
@@ -12,11 +12,12 @@ import {
12
12
  hashFileBuffer,
13
13
  mimeToExt,
14
14
  rebindImageRef,
15
+ resolveLogo,
15
16
  resolveManifestReferences,
16
17
  resolveMedia,
17
18
  sanitizeMediaName,
18
19
  setMediaProvider
19
- } from "../chunk-YKOLOZB2.js";
20
+ } from "../chunk-DRCPDOW3.js";
20
21
  import "../chunk-YBVK254P.js";
21
22
  import {
22
23
  ImageManifestSchema,
@@ -24,7 +25,7 @@ import {
24
25
  MediaFolderSchema,
25
26
  MediaItemSchema,
26
27
  VariantSchema
27
- } from "../chunk-E3MTIMOG.js";
28
+ } from "../chunk-XPUBUR64.js";
28
29
  import "../chunk-FM4M4Q2W.js";
29
30
  import "../chunk-JSBRDJBE.js";
30
31
  export {
@@ -46,6 +47,7 @@ export {
46
47
  hashFileBuffer,
47
48
  mimeToExt,
48
49
  rebindImageRef,
50
+ resolveLogo,
49
51
  resolveManifestReferences,
50
52
  resolveMedia,
51
53
  sanitizeMediaName,
@@ -26,4 +26,29 @@ export declare const BAKED_MEDIA_KEYS: readonly ["src", "srcset", "alt", "width"
26
26
  export declare function rebindImageRef<T extends {
27
27
  imageId?: string;
28
28
  }>(ref: T, imageId: string): T;
29
+ export interface ResolvedLogo {
30
+ lightUrl: string;
31
+ darkUrl: string | null;
32
+ invertInDark: boolean;
33
+ showInNav: boolean;
34
+ }
35
+ /**
36
+ * Resolves a SiteConfig["logo"] entry into concrete URLs. Returns null when
37
+ * there is no logo configured, when the light variant's imageId can't be
38
+ * resolved against the manifest, or when the light variant resolves to a
39
+ * video (an `<img>` can't render mp4/webm) — callers should fall back to
40
+ * rendering the site name as text rather than a broken image. Still-image and
41
+ * animated (gif) picks are both fine — only a video pick is rejected, since
42
+ * both those tags resolve to `tag: "img"`.
43
+ */
44
+ export declare function resolveLogo(logo: {
45
+ light: {
46
+ imageId: string;
47
+ };
48
+ dark: {
49
+ imageId: string;
50
+ } | null;
51
+ invertInDark: boolean;
52
+ showInNav: boolean;
53
+ } | null, manifest: MediaManifest, provider: MediaProvider): ResolvedLogo | null;
29
54
  //# sourceMappingURL=resolve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/media/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,YAAY,CAC1B,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,aAAa,GAAG,IAAI,CAKtB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,OAAO,CA0CT;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,gEAAiE,CAAC;AAE/F;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAIzF"}
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/media/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,YAAY,CAC1B,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,aAAa,GAAG,IAAI,CAKtB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,OAAO,CA0CT;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,gEAAiE,CAAC;AAE/F;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAIzF;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;IACtB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE;IAAE,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAAC,YAAY,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,EACxH,QAAQ,EAAE,aAAa,EACvB,QAAQ,EAAE,aAAa,GACtB,YAAY,GAAG,IAAI,CAYrB"}
@@ -3,7 +3,7 @@ import {
3
3
  AudienceNameSchema,
4
4
  MediaGridOptionsSchema,
5
5
  slugifyAudienceName
6
- } from "../chunk-EP3LVOXZ.js";
6
+ } from "../chunk-WA3HFN54.js";
7
7
  import {
8
8
  AudienceSchema,
9
9
  RoleSchema,
@@ -40,7 +40,7 @@ import {
40
40
  TextLineSchema,
41
41
  VariantSchema,
42
42
  normalizeSiteIndex
43
- } from "../chunk-E3MTIMOG.js";
43
+ } from "../chunk-XPUBUR64.js";
44
44
  import "../chunk-VKAGMEKE.js";
45
45
  import "../chunk-FM4M4Q2W.js";
46
46
  import "../chunk-NSCT3AMV.js";
@@ -456,6 +456,16 @@ export declare const SiteConfigSchema: z.ZodObject<{
456
456
  uppercaseNavHeadings: z.ZodDefault<z.ZodBoolean>;
457
457
  googleFontsUrl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
458
458
  favicon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
459
+ logo: z.ZodDefault<z.ZodNullable<z.ZodObject<{
460
+ light: z.ZodObject<{
461
+ imageId: z.ZodString;
462
+ }, z.core.$strip>;
463
+ dark: z.ZodDefault<z.ZodNullable<z.ZodObject<{
464
+ imageId: z.ZodString;
465
+ }, z.core.$strip>>>;
466
+ invertInDark: z.ZodDefault<z.ZodBoolean>;
467
+ showInNav: z.ZodDefault<z.ZodBoolean>;
468
+ }, z.core.$strip>>>;
459
469
  media: z.ZodDefault<z.ZodObject<{
460
470
  sizes: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
461
471
  maxFileSize: z.ZodDefault<z.ZodNumber>;
@@ -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;AAaxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAQ5D,eAAO,MAAM,cAAc,8EAA+E,CAAC;AAW3G,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAUrB,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA4B3B,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;AAaxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAQ5D,eAAO,MAAM,cAAc,8EAA+E,CAAC;AAW3G,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAUrB,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyC3B,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.84",
3
+ "version": "0.1.87",
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}
@@ -14,12 +14,13 @@ interface Props {
14
14
  siteName: string;
15
15
  darkMode: "light" | "dark" | "optional";
16
16
  lastUpdated: string | null;
17
+ logo?: { lightUrl: string; darkUrl: string | null; invertInDark: boolean } | null;
17
18
  // Optional same-island handler (tests). In production the editor nav is a
18
19
  // separate island from EditorShell, so a click dispatches pageSelectEvent.
19
20
  onPageSelect?: (pageId: string) => void;
20
21
  }
21
22
 
22
- export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, onPageSelect }: Props) {
23
+ export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect }: Props) {
23
24
  const [isOpen, setIsOpen] = useState(false);
24
25
  const [isEditMode, setIsEditMode] = useState(false);
25
26
  const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
@@ -135,6 +136,19 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
135
136
  </ul>
136
137
  );
137
138
 
139
+ const logoMark = logo ? (
140
+ <>
141
+ <img
142
+ src={logo.lightUrl}
143
+ alt={currentSiteName}
144
+ className={logo.darkUrl ? "block h-8 w-auto object-contain dark:hidden" : logo.invertInDark ? "h-8 w-auto object-contain dark:invert" : "h-8 w-auto object-contain"}
145
+ />
146
+ {logo.darkUrl && (
147
+ <img src={logo.darkUrl} alt={currentSiteName} className="hidden h-8 w-auto object-contain dark:block" />
148
+ )}
149
+ </>
150
+ ) : null;
151
+
138
152
  const handlePageClick = useCallback((id: string) => {
139
153
  if (onPageSelect) onPageSelect(id);
140
154
  else pageSelectEvent.dispatch(id);
@@ -193,7 +207,10 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
193
207
  <>
194
208
  <header className="fixed top-0 left-0 right-0 z-50 bg-base lg:hidden">
195
209
  <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
196
- <span className="text-lg font-bold text-primary">{currentSiteName}</span>
210
+ <div className="flex flex-col items-start gap-1">
211
+ {logoMark}
212
+ <span className="text-lg font-bold text-primary">{currentSiteName}</span>
213
+ </div>
197
214
  <button onClick={() => setIsOpen(!isOpen)} className="cursor-pointer p-2 text-base-contrast" aria-label="Toggle navigation">
198
215
  <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
199
216
  {isOpen
@@ -209,6 +226,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
209
226
  <nav className={cn("fixed top-0 left-0 lg:left-auto z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform lg:translate-x-0 nav-sidebar",
210
227
  isOpen ? "translate-x-0" : "-translate-x-full")}>
211
228
  <div className="hidden px-4 py-4 lg:block">
229
+ {logoMark}
212
230
  <span className="text-lg font-bold text-primary">{currentSiteName}</span>
213
231
  </div>
214
232
 
@@ -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" }[]>([]);
@@ -365,6 +363,10 @@ export default function EditorShell({
365
363
  markSectionDirty: persistence.markSectionDirty,
366
364
  onUploadError: (message) => setUploadErrorMessage(message),
367
365
  onUploadNotice: setUploadNotice,
366
+ onSiteConfigChange: (updater) => {
367
+ if (!siteConfig) return;
368
+ handleSiteConfigChange(updater(siteConfig));
369
+ },
368
370
  });
369
371
 
370
372
  const buildStatus = useBuildStatus();
@@ -634,22 +636,16 @@ export default function EditorShell({
634
636
  if (persistence.hasUnsavedIndex()) return;
635
637
  const parsed = IndexSchema.safeParse(msg.index);
636
638
  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
- };
639
+ // Withhold ids our current index doesn't know before applying the peer's:
640
+ // (a) the peer's unsaved ADD — its file isn't on the branch yet, so letting
641
+ // it into siteIndexRef would poison OUR next save with a dangling index ref
642
+ // the server rejects (its live appearance is deferred to the peer's save,
643
+ // via the saved-event sync); or (b) a section WE deleted unsaved — our
644
+ // deletion stands until save-time arbitration. Keyed on our INDEX, not the
645
+ // loaded section bodies: a live section whose body the loader skipped
646
+ // (schema/registry miss, depth trip) is still indexed and must survive —
647
+ // guaranteed because we only reach here with no unsaved index work.
648
+ const pruned = reconcileRemoteIndex(parsed.data, siteIndexRef.current);
653
649
  setSiteIndex(pruned);
654
650
  siteIndexRef.current = pruned;
655
651
  },
@@ -1445,7 +1441,6 @@ export default function EditorShell({
1445
1441
  onRestoreClick={() => setShowRestoreModal(true)}
1446
1442
  onOrderingClick={() => setShowOrderingModal(true)}
1447
1443
  onPagesClick={() => setShowPagesModal(true)}
1448
- onChatbotClick={() => setShowChatbotConfig(true)}
1449
1444
  collabPeers={collab.peers}
1450
1445
  collabSelfId={collabBootstrap?.userId ?? null}
1451
1446
  collabStatus={collab.status}
@@ -1577,19 +1572,11 @@ export default function EditorShell({
1577
1572
  siteConfig={siteConfig!}
1578
1573
  onSiteConfigChange={handleSiteConfigChange}
1579
1574
  onAudiencesChange={setAudiences}
1575
+ audiences={audiences}
1576
+ chatbotPlatformEnabled={chatbotPlatformEnabled}
1580
1577
  capabilities={capabilities}
1581
1578
  currentUser={currentUser}
1582
1579
  />
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
1580
  <EditorModal
1594
1581
  isOpen={pendingDeleteSectionId !== null}
1595
1582
  onClose={() => setPendingDeleteSectionId(null)}
@@ -2280,7 +2267,6 @@ function EditorToolbar({
2280
2267
  onRestoreClick,
2281
2268
  onOrderingClick,
2282
2269
  onPagesClick,
2283
- onChatbotClick,
2284
2270
  collabPeers,
2285
2271
  collabSelfId,
2286
2272
  collabStatus,
@@ -2303,7 +2289,6 @@ function EditorToolbar({
2303
2289
  onRestoreClick: () => void;
2304
2290
  onOrderingClick: () => void;
2305
2291
  onPagesClick: () => void;
2306
- onChatbotClick: () => void;
2307
2292
  collabPeers: CollabPeer[];
2308
2293
  collabSelfId: string | null;
2309
2294
  collabStatus: CollabStatus;
@@ -2377,42 +2362,11 @@ function EditorToolbar({
2377
2362
  </div>
2378
2363
  <div className="flex items-center justify-end gap-2">
2379
2364
  <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
- />
2365
+ <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
2366
+ <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
2367
+ <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
2415
2368
  <ShowAllChromeToggle />
2369
+ <IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
2416
2370
  </div>
2417
2371
  </div>
2418
2372
  </div>
@@ -2457,14 +2411,13 @@ function ShowAllChromeToggle() {
2457
2411
  const { showAllChrome, toggleShowAllChrome } = useEditorContext();
2458
2412
 
2459
2413
  return (
2460
- <Button
2461
- variant="secondary"
2462
- size="sm"
2414
+ <IconButton
2415
+ icon={<JoystickIcon size={16} />}
2416
+ label="Toggle editor chrome visibility"
2417
+ size="md"
2463
2418
  onClick={toggleShowAllChrome}
2464
- className="bg-base-accent"
2465
- aria-label="Toggle editor chrome visibility"
2466
- >
2467
- {showAllChrome ? "Hide Controls" : "Show Controls"}
2468
- </Button>
2419
+ aria-pressed={showAllChrome}
2420
+ className="border border-base-200 bg-base-accent"
2421
+ />
2469
2422
  );
2470
2423
  }