@drawnagency/primitives 0.1.118 → 0.1.120

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 (279) hide show
  1. package/dist/{chunk-O37AV27P.js → chunk-5JYHTHVR.js} +28 -0
  2. package/dist/{chunk-W72BPGR3.js → chunk-BZKHHUIN.js} +44 -8
  3. package/dist/{chunk-4NSOKEA7.js → chunk-HFHM4MNK.js} +12 -7
  4. package/dist/{chunk-CEFFP3CH.js → chunk-HZTPF6OK.js} +304 -83
  5. package/dist/{chunk-RIITGTV3.js → chunk-LQHBC7C6.js} +37 -2
  6. package/dist/{chunk-VY3IFGE3.js → chunk-QAMAIDXZ.js} +1 -1
  7. package/dist/{chunk-HRX3XJBU.js → chunk-RIN2QCYJ.js} +9 -11
  8. package/dist/{chunk-ZOTRHHUB.js → chunk-UNFPATDE.js} +8 -10
  9. package/dist/{chunk-ERLIL4RU.js → chunk-UZBFVVTC.js} +35 -37
  10. package/dist/{chunk-67P34WNW.js → chunk-ZKVQMIMJ.js} +3 -20
  11. package/dist/collab/rebase.js +4 -5
  12. package/dist/components/chat/ChatComposer.d.ts +0 -4
  13. package/dist/components/chat/ChatComposer.d.ts.map +1 -1
  14. package/dist/components/chat/ChatPanel.d.ts +2 -3
  15. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  16. package/dist/components/chat/markdown.d.ts +2 -3
  17. package/dist/components/chat/markdown.d.ts.map +1 -1
  18. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  19. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  20. package/dist/components/editor/IndicatorPill.d.ts +3 -1
  21. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  22. package/dist/components/editor/PagesModal.d.ts +0 -2
  23. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  24. package/dist/components/editor/SectionBulkEditModal.d.ts +25 -0
  25. package/dist/components/editor/SectionBulkEditModal.d.ts.map +1 -0
  26. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  27. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  28. package/dist/components/editor/index.js +28 -26
  29. package/dist/components/primitives/index.js +11 -15
  30. package/dist/components/sections/brand-guide.js +10 -13
  31. package/dist/components/sections/register-schemas.js +16 -21
  32. package/dist/components/sections/register.js +16 -21
  33. package/dist/components/shared/Popover.d.ts +1 -1
  34. package/dist/components/shared/Popover.d.ts.map +1 -1
  35. package/dist/components/shared/SectionSummaryRow.d.ts +1 -1
  36. package/dist/components/shell/AudienceChips.d.ts +1 -3
  37. package/dist/components/shell/AudienceChips.d.ts.map +1 -1
  38. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  39. package/dist/components/shell/EditorShell.d.ts +0 -34
  40. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  41. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  42. package/dist/hooks/useSortableNavRow.d.ts +1 -1
  43. package/dist/hooks/useSortableRow.d.ts +1 -1
  44. package/dist/index.js +83 -90
  45. package/dist/lib/index-ops.js +5 -6
  46. package/dist/lib/index.js +27 -32
  47. package/dist/lib/pages.d.ts +0 -2
  48. package/dist/lib/pages.d.ts.map +1 -1
  49. package/dist/lib/sidecars.js +2 -2
  50. package/dist/lib/site-validation.js +13 -14
  51. package/dist/lib/storage-tier.js +4 -4
  52. package/dist/schemas/index.js +42 -45
  53. package/dist/schemas/site-config.d.ts +1 -277
  54. package/dist/schemas/site-config.d.ts.map +1 -1
  55. package/package.json +2 -42
  56. package/src/components/chat/ChatComposer.tsx +0 -4
  57. package/src/components/chat/ChatPanel.tsx +2 -3
  58. package/src/components/chat/markdown.ts +2 -3
  59. package/src/components/editor/AudienceIndicator.tsx +8 -4
  60. package/src/components/editor/IndicatorPill.tsx +4 -0
  61. package/src/components/editor/PagesModal.tsx +1 -15
  62. package/src/components/editor/SectionBulkEditModal.tsx +328 -0
  63. package/src/components/editor/StatusIndicator.tsx +7 -6
  64. package/src/components/shared/Popover.tsx +17 -4
  65. package/src/components/shared/SectionSummaryRow.tsx +1 -1
  66. package/src/components/shell/AudienceChips.tsx +1 -3
  67. package/src/components/shell/EditorModal.tsx +5 -2
  68. package/src/components/shell/EditorShell.tsx +15 -119
  69. package/src/components/shell/SiteSettingsModal.tsx +0 -11
  70. package/src/lib/pages.ts +0 -22
  71. package/src/schemas/index.ts +0 -4
  72. package/src/schemas/site-config.ts +4 -32
  73. package/dist/chunk-4TZJZU2T.js +0 -92
  74. package/dist/chunk-DIMDRBFK.js +0 -11
  75. package/dist/chunk-FV7JQRCN.js +0 -94
  76. package/dist/chunk-IIDKW7AV.js +0 -41
  77. package/dist/chunk-JCDZAFX2.js +0 -224
  78. package/dist/chunk-MM7KZS5C.js +0 -30
  79. package/dist/chunk-QQ3S3P5P.js +0 -38
  80. package/dist/chunk-TEOAKGZA.js +0 -784
  81. package/dist/chunk-VAYZJKVR.js +0 -209
  82. package/dist/components/editor/SectionOrderingModal.d.ts +0 -10
  83. package/dist/components/editor/SectionOrderingModal.d.ts.map +0 -1
  84. package/dist/components/shell/DocInspector.d.ts +0 -18
  85. package/dist/components/shell/DocInspector.d.ts.map +0 -1
  86. package/dist/components/shell/DocumentEditorOverlay.d.ts +0 -29
  87. package/dist/components/shell/DocumentEditorOverlay.d.ts.map +0 -1
  88. package/dist/components/shell/DocumentPresentOverlay.d.ts +0 -30
  89. package/dist/components/shell/DocumentPresentOverlay.d.ts.map +0 -1
  90. package/dist/components/shell/SiteSettingsDecks.d.ts +0 -13
  91. package/dist/components/shell/SiteSettingsDecks.d.ts.map +0 -1
  92. package/dist/deck/DeckComponentView.d.ts +0 -13
  93. package/dist/deck/DeckComponentView.d.ts.map +0 -1
  94. package/dist/deck/DeckPlayer.d.ts +0 -26
  95. package/dist/deck/DeckPlayer.d.ts.map +0 -1
  96. package/dist/deck/DeckSlideView.d.ts +0 -17
  97. package/dist/deck/DeckSlideView.d.ts.map +0 -1
  98. package/dist/deck/DeckStage.d.ts +0 -16
  99. package/dist/deck/DeckStage.d.ts.map +0 -1
  100. package/dist/deck/components/DeckImage.d.ts +0 -14
  101. package/dist/deck/components/DeckImage.d.ts.map +0 -1
  102. package/dist/deck/components/DeckImageGrid.d.ts +0 -27
  103. package/dist/deck/components/DeckImageGrid.d.ts.map +0 -1
  104. package/dist/deck/components/DeckRichText.d.ts +0 -22
  105. package/dist/deck/components/DeckRichText.d.ts.map +0 -1
  106. package/dist/deck/components/DeckVideo.d.ts +0 -15
  107. package/dist/deck/components/DeckVideo.d.ts.map +0 -1
  108. package/dist/deck/editing.d.ts +0 -50
  109. package/dist/deck/editing.d.ts.map +0 -1
  110. package/dist/deck/flip.d.ts +0 -11
  111. package/dist/deck/flip.d.ts.map +0 -1
  112. package/dist/deck/index.d.ts +0 -24
  113. package/dist/deck/index.d.ts.map +0 -1
  114. package/dist/deck/index.js +0 -51
  115. package/dist/deck/layouts/CenteredTextLayout.d.ts +0 -12
  116. package/dist/deck/layouts/CenteredTextLayout.d.ts.map +0 -1
  117. package/dist/deck/layouts/ColumnSplitLayout.d.ts +0 -14
  118. package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +0 -1
  119. package/dist/deck/layouts/FullBleedImageLayout.d.ts +0 -14
  120. package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +0 -1
  121. package/dist/deck/media.d.ts +0 -33
  122. package/dist/deck/media.d.ts.map +0 -1
  123. package/dist/deck/tokens.d.ts +0 -22
  124. package/dist/deck/tokens.d.ts.map +0 -1
  125. package/dist/deck/useStageScale.d.ts +0 -10
  126. package/dist/deck/useStageScale.d.ts.map +0 -1
  127. package/dist/deck-builder/DeckBuilderApp.d.ts +0 -30
  128. package/dist/deck-builder/DeckBuilderApp.d.ts.map +0 -1
  129. package/dist/deck-builder/DeckBuilderTopBar.d.ts +0 -24
  130. package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +0 -1
  131. package/dist/deck-builder/DeckChatPanel.d.ts +0 -45
  132. package/dist/deck-builder/DeckChatPanel.d.ts.map +0 -1
  133. package/dist/deck-builder/DeckEditor.d.ts +0 -37
  134. package/dist/deck-builder/DeckEditor.d.ts.map +0 -1
  135. package/dist/deck-builder/DeckLibrary.d.ts +0 -22
  136. package/dist/deck-builder/DeckLibrary.d.ts.map +0 -1
  137. package/dist/deck-builder/DeckSlideSurface.d.ts +0 -31
  138. package/dist/deck-builder/DeckSlideSurface.d.ts.map +0 -1
  139. package/dist/deck-builder/deck-api.d.ts +0 -76
  140. package/dist/deck-builder/deck-api.d.ts.map +0 -1
  141. package/dist/deck-builder/deck-chat-client.d.ts +0 -44
  142. package/dist/deck-builder/deck-chat-client.d.ts.map +0 -1
  143. package/dist/deck-builder/deck-chat-history.d.ts +0 -10
  144. package/dist/deck-builder/deck-chat-history.d.ts.map +0 -1
  145. package/dist/deck-builder/index.d.ts +0 -13
  146. package/dist/deck-builder/index.d.ts.map +0 -1
  147. package/dist/deck-builder/index.js +0 -1498
  148. package/dist/deck-builder/starter-deck.d.ts +0 -23
  149. package/dist/deck-builder/starter-deck.d.ts.map +0 -1
  150. package/dist/deck-builder/types.d.ts +0 -29
  151. package/dist/deck-builder/types.d.ts.map +0 -1
  152. package/dist/deck-builder/useSortableSlide.d.ts +0 -29
  153. package/dist/deck-builder/useSortableSlide.d.ts.map +0 -1
  154. package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  155. package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  156. package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  157. package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  158. package/dist/document/components/DocCoverPage.d.ts +0 -11
  159. package/dist/document/components/DocCoverPage.d.ts.map +0 -1
  160. package/dist/document/components/DocPage.d.ts +0 -10
  161. package/dist/document/components/DocPage.d.ts.map +0 -1
  162. package/dist/document/components/DocRail.d.ts +0 -28
  163. package/dist/document/components/DocRail.d.ts.map +0 -1
  164. package/dist/document/components/DocSectionPage.d.ts +0 -12
  165. package/dist/document/components/DocSectionPage.d.ts.map +0 -1
  166. package/dist/document/components/DocViewer.d.ts +0 -21
  167. package/dist/document/components/DocViewer.d.ts.map +0 -1
  168. package/dist/document/components/Wordmark.d.ts +0 -4
  169. package/dist/document/components/Wordmark.d.ts.map +0 -1
  170. package/dist/document/components/measure.d.ts +0 -7
  171. package/dist/document/components/measure.d.ts.map +0 -1
  172. package/dist/document/components/useDocPages.d.ts +0 -12
  173. package/dist/document/components/useDocPages.d.ts.map +0 -1
  174. package/dist/document/engine/blocks.d.ts +0 -2
  175. package/dist/document/engine/blocks.d.ts.map +0 -1
  176. package/dist/document/engine/harvest.d.ts +0 -3
  177. package/dist/document/engine/harvest.d.ts.map +0 -1
  178. package/dist/document/engine/masonry.d.ts +0 -26
  179. package/dist/document/engine/masonry.d.ts.map +0 -1
  180. package/dist/document/engine/paginate.d.ts +0 -43
  181. package/dist/document/engine/paginate.d.ts.map +0 -1
  182. package/dist/document/engine/settings.d.ts +0 -91
  183. package/dist/document/engine/settings.d.ts.map +0 -1
  184. package/dist/document/engine/types.d.ts +0 -44
  185. package/dist/document/engine/types.d.ts.map +0 -1
  186. package/dist/document/index.d.ts +0 -13
  187. package/dist/document/index.d.ts.map +0 -1
  188. package/dist/document/index.js +0 -1030
  189. package/dist/document/styles/document.css +0 -217
  190. package/dist/lib/deck-access.d.ts +0 -86
  191. package/dist/lib/deck-access.d.ts.map +0 -1
  192. package/dist/lib/deck-access.js +0 -58
  193. package/dist/lib/deck-dexie.d.ts +0 -88
  194. package/dist/lib/deck-dexie.d.ts.map +0 -1
  195. package/dist/lib/deck-dexie.js +0 -22
  196. package/dist/lib/deck-edit.d.ts +0 -67
  197. package/dist/lib/deck-edit.d.ts.map +0 -1
  198. package/dist/lib/deck-media-assets.d.ts +0 -27
  199. package/dist/lib/deck-media-assets.d.ts.map +0 -1
  200. package/dist/lib/deck-media-assets.js +0 -25
  201. package/dist/lib/deck-media-refs.d.ts +0 -35
  202. package/dist/lib/deck-media-refs.d.ts.map +0 -1
  203. package/dist/lib/deck-media-refs.js +0 -36
  204. package/dist/lib/deck-tool.d.ts +0 -25
  205. package/dist/lib/deck-tool.d.ts.map +0 -1
  206. package/dist/lib/deck-tool.js +0 -58
  207. package/dist/lib/document-input.d.ts +0 -25
  208. package/dist/lib/document-input.d.ts.map +0 -1
  209. package/dist/schemas/deck-meta.d.ts +0 -85
  210. package/dist/schemas/deck-meta.d.ts.map +0 -1
  211. package/dist/schemas/deck-meta.js +0 -64
  212. package/dist/schemas/deck.d.ts +0 -954
  213. package/dist/schemas/deck.d.ts.map +0 -1
  214. package/dist/schemas/deck.js +0 -65
  215. package/src/components/editor/SectionOrderingModal.tsx +0 -80
  216. package/src/components/shell/DocInspector.tsx +0 -314
  217. package/src/components/shell/DocumentEditorOverlay.tsx +0 -179
  218. package/src/components/shell/DocumentPresentOverlay.tsx +0 -87
  219. package/src/components/shell/SiteSettingsDecks.tsx +0 -41
  220. package/src/deck/DeckComponentView.tsx +0 -28
  221. package/src/deck/DeckPlayer.tsx +0 -166
  222. package/src/deck/DeckSlideView.tsx +0 -38
  223. package/src/deck/DeckStage.tsx +0 -39
  224. package/src/deck/components/DeckImage.tsx +0 -38
  225. package/src/deck/components/DeckImageGrid.tsx +0 -254
  226. package/src/deck/components/DeckRichText.tsx +0 -111
  227. package/src/deck/components/DeckVideo.tsx +0 -81
  228. package/src/deck/editing.tsx +0 -201
  229. package/src/deck/flip.ts +0 -70
  230. package/src/deck/index.ts +0 -30
  231. package/src/deck/layouts/CenteredTextLayout.tsx +0 -29
  232. package/src/deck/layouts/ColumnSplitLayout.tsx +0 -69
  233. package/src/deck/layouts/FullBleedImageLayout.tsx +0 -58
  234. package/src/deck/media.tsx +0 -46
  235. package/src/deck/tokens.ts +0 -57
  236. package/src/deck/useStageScale.ts +0 -44
  237. package/src/deck-builder/DeckBuilderApp.tsx +0 -572
  238. package/src/deck-builder/DeckBuilderTopBar.tsx +0 -62
  239. package/src/deck-builder/DeckChatPanel.tsx +0 -334
  240. package/src/deck-builder/DeckEditor.tsx +0 -258
  241. package/src/deck-builder/DeckLibrary.tsx +0 -195
  242. package/src/deck-builder/DeckSlideSurface.tsx +0 -55
  243. package/src/deck-builder/deck-api.ts +0 -177
  244. package/src/deck-builder/deck-chat-client.ts +0 -129
  245. package/src/deck-builder/deck-chat-history.ts +0 -62
  246. package/src/deck-builder/index.ts +0 -12
  247. package/src/deck-builder/starter-deck.ts +0 -86
  248. package/src/deck-builder/types.ts +0 -24
  249. package/src/deck-builder/useSortableSlide.ts +0 -102
  250. package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  251. package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  252. package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  253. package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  254. package/src/document/components/DocCoverPage.tsx +0 -45
  255. package/src/document/components/DocPage.tsx +0 -40
  256. package/src/document/components/DocRail.tsx +0 -189
  257. package/src/document/components/DocSectionPage.tsx +0 -48
  258. package/src/document/components/DocViewer.tsx +0 -313
  259. package/src/document/components/Wordmark.tsx +0 -18
  260. package/src/document/components/measure.tsx +0 -133
  261. package/src/document/components/useDocPages.ts +0 -95
  262. package/src/document/engine/blocks.ts +0 -116
  263. package/src/document/engine/harvest.ts +0 -95
  264. package/src/document/engine/masonry.ts +0 -158
  265. package/src/document/engine/paginate.ts +0 -232
  266. package/src/document/engine/settings.ts +0 -125
  267. package/src/document/engine/types.ts +0 -64
  268. package/src/document/index.ts +0 -18
  269. package/src/document/styles/document.css +0 -217
  270. package/src/lib/deck-access.ts +0 -132
  271. package/src/lib/deck-dexie.ts +0 -190
  272. package/src/lib/deck-edit.ts +0 -196
  273. package/src/lib/deck-media-assets.ts +0 -57
  274. package/src/lib/deck-media-refs.ts +0 -67
  275. package/src/lib/deck-tool.ts +0 -95
  276. package/src/lib/document-input.ts +0 -57
  277. package/src/schemas/deck-meta.ts +0 -88
  278. package/src/schemas/deck.ts +0 -478
  279. package/dist/{chunk-DJABADOO.js → chunk-AJ2QOAZI.js} +3 -3
@@ -0,0 +1,328 @@
1
+ import { useRef, useState } from "react";
2
+ import { Check, ChevronDown, Lock, Minus, X } from "lucide-react";
3
+ import type { LoadedSection } from "../../lib/loader";
4
+ import { getSection } from "../../lib/registry";
5
+ import type { MediaManifest } from "../../media/types";
6
+ import type { Audience } from "../../auth/types";
7
+ import { DragHandle } from "./DragHandle";
8
+ import { useSortableRow } from "../../hooks/useSortableRow";
9
+ import { DropEdgeIndicator } from "./DropEdgeIndicator";
10
+ import { IndicatorPill } from "./IndicatorPill";
11
+ import { StatusRadioList, dotColorClasses } from "./StatusIndicator";
12
+ import { AudienceChecklist } from "./AudienceIndicator";
13
+ import { SectionSummaryRow } from "../shared/SectionSummaryRow";
14
+ import { Popover } from "../shared/Popover";
15
+ import { PopoverItem } from "../shared/PopoverItem";
16
+ import { toThumbViews } from "../../lib/thumb-views";
17
+ import { cn } from "../../lib/cn";
18
+
19
+ type Status = "draft" | "live" | "archived";
20
+ const STATUSES: Status[] = ["draft", "live", "archived"];
21
+ type Coverage = "all" | "some" | "none";
22
+
23
+ interface SectionBulkEditModalProps {
24
+ sections: LoadedSection[];
25
+ mediaManifest: MediaManifest;
26
+ audiences: Audience[];
27
+ /** Sections another editor holds. They stay draggable but can't be selected,
28
+ * matching the canvas, where a held section's status and audience are locked. */
29
+ locks: ReadonlyMap<string, { email: string }>;
30
+ onReorder: (fromIndex: number, toIndex: number) => void;
31
+ onStatusChange: (sectionId: string, status: Status) => void;
32
+ onAccessChange: (sectionId: string, access: string[]) => void;
33
+ }
34
+
35
+ function audienceLabel(selected: Audience[]): string {
36
+ if (selected.length === 0) return "No audience";
37
+ if (selected.length === 1) return selected[0].displayName;
38
+ return `${selected.length} audiences`;
39
+ }
40
+
41
+ function coverage(sections: LoadedSection[], name: string): Coverage {
42
+ const n = sections.filter((s) => s.meta.access.includes(name)).length;
43
+ if (n === 0) return "none";
44
+ return n === sections.length ? "all" : "some";
45
+ }
46
+
47
+ function CheckBox({ state }: { state: Coverage }) {
48
+ return (
49
+ <span
50
+ aria-hidden="true"
51
+ className={cn(
52
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
53
+ state === "none" ? "border-base-300 bg-base" : "border-primary bg-primary text-primary-contrast",
54
+ )}
55
+ >
56
+ {state === "all" && <Check size={12} strokeWidth={3} />}
57
+ {state === "some" && <Minus size={12} strokeWidth={3} />}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ function SectionRow({
63
+ section,
64
+ index,
65
+ mediaManifest,
66
+ audiences,
67
+ selected,
68
+ lockedBy,
69
+ onToggle,
70
+ onReorder,
71
+ }: {
72
+ section: LoadedSection;
73
+ index: number;
74
+ mediaManifest: MediaManifest;
75
+ audiences: Audience[];
76
+ selected: boolean;
77
+ lockedBy: string | null;
78
+ onToggle: (range: boolean) => void;
79
+ onReorder: (fromIndex: number, toIndex: number) => void;
80
+ }) {
81
+ const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
82
+ index,
83
+ dragType: "ordering-row",
84
+ onReorder,
85
+ });
86
+
87
+ const definition = getSection(section.section.type);
88
+ const label = definition?.getLabel?.(section.section) ?? definition?.label ?? section.section.type;
89
+ const typeLabel = definition?.label ?? section.section.type;
90
+ const status = section.meta.status as Status;
91
+ const access = audiences.filter((a) => section.meta.access.includes(a.name));
92
+
93
+ return (
94
+ <div ref={rowRef} className={cn("relative flex items-center gap-1", isDragging && "opacity-50")}>
95
+ <DropEdgeIndicator edge={closestEdge} />
96
+
97
+ {lockedBy ? (
98
+ <span title={`Being edited by ${lockedBy}`} className="flex h-8 w-8 shrink-0 items-center justify-center text-base-contrast-light">
99
+ <Lock size={14} aria-label={`${label} is being edited by ${lockedBy}`} />
100
+ </span>
101
+ ) : (
102
+ <button
103
+ type="button"
104
+ role="checkbox"
105
+ aria-checked={selected}
106
+ aria-label={`Select ${label}`}
107
+ onClick={(e) => onToggle(e.shiftKey)}
108
+ className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center"
109
+ >
110
+ <CheckBox state={selected ? "all" : "none"} />
111
+ </button>
112
+ )}
113
+
114
+ <div className="shrink-0 [&_[role=tooltip]]:hidden">
115
+ <DragHandle ref={handleRef} />
116
+ </div>
117
+
118
+ <SectionSummaryRow
119
+ label={label}
120
+ typeLabel={typeLabel}
121
+ thumbnails={toThumbViews(definition?.getThumbnails?.(section.section) ?? [], mediaManifest)}
122
+ className={cn(selected && "border-primary/60 bg-primary/5", isDragging && "outline outline-2 outline-primary/50")}
123
+ trailing={
124
+ <div className="flex shrink-0 flex-col items-end gap-0.5 text-xs text-base-contrast-light">
125
+ <span className="inline-flex items-center gap-1.5 capitalize">
126
+ <span aria-hidden="true" className={cn("h-2.5 w-2.5 rounded-full border border-base-200", dotColorClasses[status])} />
127
+ {status}
128
+ </span>
129
+ <span className="inline-flex items-center gap-1.5">
130
+ {access.length > 0 && (
131
+ <span aria-hidden="true" className="flex -space-x-1">
132
+ {access.slice(0, 3).map((a) => (
133
+ <span key={a.name} className="h-2.5 w-2.5 rounded-full border border-base" style={{ backgroundColor: a.color ?? "#9ca3af" }} />
134
+ ))}
135
+ </span>
136
+ )}
137
+ {audienceLabel(access)}
138
+ </span>
139
+ </div>
140
+ }
141
+ />
142
+ </div>
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The toolbar's "Edit sections" panel: drag rows to reorder, or tick several and
148
+ * set their status and audience at once from the bar that appears. Every change
149
+ * applies to the working copy immediately, like the per-section pills it reuses.
150
+ */
151
+ export function SectionBulkEditModal({
152
+ sections,
153
+ mediaManifest,
154
+ audiences,
155
+ locks,
156
+ onReorder,
157
+ onStatusChange,
158
+ onAccessChange,
159
+ }: SectionBulkEditModalProps) {
160
+ const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
161
+ const [anchorId, setAnchorId] = useState<string | null>(null);
162
+ const [selectMenuOpen, setSelectMenuOpen] = useState(false);
163
+ const selectMenuRef = useRef<HTMLButtonElement>(null);
164
+
165
+ // Derived, so a section deleted or newly locked by a peer drops out of the selection.
166
+ const selectable = sections.filter((s) => !locks.has(s.section.id));
167
+ const selected = selectable.filter((s) => selectedIds.has(s.section.id));
168
+ const allSelected = selectable.length > 0 && selected.length === selectable.length;
169
+
170
+ function select(ids: string[]) {
171
+ setSelectedIds(new Set(ids));
172
+ }
173
+
174
+ function toggle(id: string, range: boolean) {
175
+ const ids = selectable.map((s) => s.section.id);
176
+ const next = new Set(selectedIds);
177
+ if (range && anchorId && ids.includes(anchorId)) {
178
+ const [a, b] = [ids.indexOf(anchorId), ids.indexOf(id)].sort((x, y) => x - y);
179
+ for (const rangeId of ids.slice(a, b + 1)) next.add(rangeId);
180
+ } else {
181
+ if (next.has(id)) next.delete(id);
182
+ else next.add(id);
183
+ setAnchorId(id);
184
+ }
185
+ setSelectedIds(next);
186
+ }
187
+
188
+ const statuses = STATUSES.filter((s) => selected.some((x) => x.meta.status === s));
189
+ const commonStatus = statuses.length === 1 ? statuses[0] : null;
190
+ const coverageByName = new Map(audiences.map((a) => [a.name, coverage(selected, a.name)]));
191
+ const fullAudiences = audiences.filter((a) => coverageByName.get(a.name) === "all");
192
+ const partialAudiences = audiences.filter((a) => coverageByName.get(a.name) === "some");
193
+
194
+ function toggleAudience(name: string) {
195
+ // A partly-covered audience is added to every selected section; a fully
196
+ // covered one is removed from all of them.
197
+ const add = coverageByName.get(name) !== "all";
198
+ for (const s of selected) {
199
+ const has = s.meta.access.includes(name);
200
+ if (add && !has) onAccessChange(s.section.id, [...s.meta.access, name]);
201
+ if (!add && has) onAccessChange(s.section.id, s.meta.access.filter((n) => n !== name));
202
+ }
203
+ }
204
+
205
+ const selectMenu: { label: string; ids: () => string[]; status?: Status }[] = [
206
+ { label: "All", ids: () => selectable.map((s) => s.section.id) },
207
+ { label: "None", ids: () => [] },
208
+ ...STATUSES.map((status) => ({
209
+ label: status,
210
+ status,
211
+ ids: () => selectable.filter((s) => s.meta.status === status).map((s) => s.section.id),
212
+ })),
213
+ ];
214
+
215
+ return (
216
+ <div className="flex min-h-0 flex-1 flex-col">
217
+ <div className="-mx-6 mb-3 flex items-center border-b border-base-200 px-6 pb-2 text-xs text-base-contrast-light">
218
+ <div className="relative flex items-center rounded hover:bg-base-accent">
219
+ <button
220
+ type="button"
221
+ role="checkbox"
222
+ aria-checked={allSelected ? true : selected.length > 0 ? "mixed" : false}
223
+ aria-label={allSelected ? "Deselect all" : "Select all"}
224
+ onClick={() => select(allSelected ? [] : selectable.map((s) => s.section.id))}
225
+ className="flex cursor-pointer items-center py-1.5 pr-1 pl-2"
226
+ >
227
+ <CheckBox state={allSelected ? "all" : selected.length > 0 ? "some" : "none"} />
228
+ </button>
229
+ <button
230
+ ref={selectMenuRef}
231
+ type="button"
232
+ aria-haspopup="dialog"
233
+ aria-expanded={selectMenuOpen}
234
+ onClick={() => setSelectMenuOpen((open) => !open)}
235
+ className="flex cursor-pointer items-center gap-1 py-1.5 pr-2 pl-1 hover:text-base-contrast"
236
+ >
237
+ Select
238
+ <ChevronDown size={12} aria-hidden="true" />
239
+ </button>
240
+ <Popover isOpen={selectMenuOpen} onClose={() => setSelectMenuOpen(false)} anchorRef={selectMenuRef} align="start" className="min-w-40 py-1">
241
+ {selectMenu.map(({ label, ids, status }) => (
242
+ <PopoverItem
243
+ key={label}
244
+ className={cn(status === "draft" && "mt-1 border-t border-base-200 pt-2")}
245
+ onClick={() => {
246
+ select(ids());
247
+ setSelectMenuOpen(false);
248
+ }}
249
+ >
250
+ {status && <span aria-hidden="true" className={cn("h-2.5 w-2.5 rounded-full border border-base-200", dotColorClasses[status])} />}
251
+ <span className="flex-1 capitalize">{label}</span>
252
+ {status && <span className="tabular-nums text-base-contrast-light">{ids().length}</span>}
253
+ </PopoverItem>
254
+ ))}
255
+ </Popover>
256
+ </div>
257
+ {selected.length > 0 && <span className="ml-2">{selected.length} of {sections.length} selected</span>}
258
+ </div>
259
+
260
+ <div className="flex flex-col gap-2 pb-2">
261
+ {sections.map((loadedSection, index) => {
262
+ const id = loadedSection.section.id;
263
+ return (
264
+ <SectionRow
265
+ key={id}
266
+ section={loadedSection}
267
+ index={index}
268
+ mediaManifest={mediaManifest}
269
+ audiences={audiences}
270
+ selected={selectedIds.has(id)}
271
+ lockedBy={locks.get(id)?.email ?? null}
272
+ onToggle={(range) => toggle(id, range)}
273
+ onReorder={onReorder}
274
+ />
275
+ );
276
+ })}
277
+ </div>
278
+
279
+ {selected.length > 0 && (
280
+ <div className="sticky bottom-0 -mx-6 -mb-4 mt-auto flex items-center gap-2 border-t border-base-200 bg-base px-6 py-3 text-sm shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
281
+ <span className="font-medium text-base-contrast">{selected.length} selected</span>
282
+ <button
283
+ type="button"
284
+ onClick={() => select([])}
285
+ aria-label="Clear selection"
286
+ className="cursor-pointer rounded p-0.5 text-base-contrast-light hover:bg-base-accent"
287
+ >
288
+ <X size={14} />
289
+ </button>
290
+ <div className="ml-auto flex items-center gap-2">
291
+ <IndicatorPill
292
+ placement="top"
293
+ dots={statuses.map((s) => ({ className: dotColorClasses[s] }))}
294
+ label={commonStatus ? commonStatus.charAt(0).toUpperCase() + commonStatus.slice(1) : "Mixed status"}
295
+ ariaLabel="Status of selected sections"
296
+ buttonClassName="text-base-contrast-light"
297
+ clickContent={(onClose) => (
298
+ <StatusRadioList
299
+ status={commonStatus}
300
+ ariaLabel="Status for selected sections"
301
+ onClose={onClose}
302
+ onChange={(status) => {
303
+ for (const s of selected) if (s.meta.status !== status) onStatusChange(s.section.id, status);
304
+ }}
305
+ />
306
+ )}
307
+ />
308
+ <IndicatorPill
309
+ placement="top"
310
+ dots={[...fullAudiences, ...partialAudiences].map((a) => ({ color: a.color ?? "#9ca3af" }))}
311
+ label={partialAudiences.length > 0 ? "Mixed audiences" : audienceLabel(fullAudiences)}
312
+ ariaLabel="Audience of selected sections"
313
+ buttonClassName={fullAudiences.length + partialAudiences.length === 0 ? "text-base-contrast-light" : undefined}
314
+ clickContent={
315
+ <AudienceChecklist
316
+ audiences={audiences}
317
+ access={fullAudiences.map((a) => a.name)}
318
+ partial={partialAudiences.map((a) => a.name)}
319
+ onToggle={toggleAudience}
320
+ />
321
+ }
322
+ />
323
+ </div>
324
+ </div>
325
+ )}
326
+ </div>
327
+ );
328
+ }
@@ -73,18 +73,19 @@ export function deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, i
73
73
  };
74
74
  }
75
75
 
76
- const dotColorClasses: Record<StatusColor, string> = {
77
- live: "bg-status-live-text",
78
- draft: "bg-status-draft-text",
79
- archived: "bg-white border-status-draft-text",
80
- modified: "bg-status-modified-text",
76
+ export const dotColorClasses: Record<StatusColor, string> = {
77
+ live: "bg-status-live-dot",
78
+ draft: "bg-status-draft-dot",
79
+ archived: "bg-white border-status-archived-dot-border",
80
+ modified: "bg-status-modified-dot",
81
81
  };
82
82
 
83
83
 
84
84
  const STATUSES: Status[] = ["draft", "live", "archived"];
85
85
 
86
86
  interface StatusRadioListProps {
87
- status: Status;
87
+ /** null when several sections with different statuses are being set at once. */
88
+ status: Status | null;
88
89
  onChange: (status: Status) => void;
89
90
  onClose?: () => void;
90
91
  ariaLabel?: string;
@@ -18,7 +18,7 @@ interface PopoverProps {
18
18
  * panel wider than the sidebar is clipped and adds a horizontal scrollbar.
19
19
  */
20
20
  portal?: boolean;
21
- /** Which side of the anchor to open toward. Only used with `portal`. */
21
+ /** Which side of the anchor to open toward. */
22
22
  placement?: "bottom" | "top";
23
23
  }
24
24
 
@@ -111,7 +111,14 @@ export function Popover({
111
111
  // this the one keypress dismisses both, losing the navigator's zoom stack
112
112
  // — inconsistent with the X button, and with the mousedown exemption below
113
113
  // that exists for exactly this stacking.
114
- useStackedEscape(onClose, { enabled: isOpen });
114
+ const escapeLayerRef = useStackedEscape<HTMLElement>(onClose, { enabled: isOpen });
115
+ // A popover living INSIDE an EditorModal treats that modal's layer as its
116
+ // own, so only a modal stacked above it (one opened from this popover) takes
117
+ // Escape away. The modal, for its part, yields Escape while one of its
118
+ // popovers is open (EditorModal's `before`).
119
+ useLayoutEffect(() => {
120
+ escapeLayerRef.current = isOpen ? anchorRef.current?.closest<HTMLElement>("[data-modal-layer]") ?? null : null;
121
+ }, [isOpen, anchorRef, escapeLayerRef]);
115
122
 
116
123
  useEffect(() => {
117
124
  if (!isOpen) return;
@@ -122,7 +129,10 @@ export function Popover({
122
129
  // A modal opened from inside this popover portals to <body>, so it is
123
130
  // "outside" by DOM containment. Closing on its mousedowns would unmount
124
131
  // the modal along with the popover, and would break text selection in it.
125
- if (target instanceof Element && target.closest("[data-modal-layer]")) return;
132
+ // The modal this popover itself sits in doesn't count: a click elsewhere
133
+ // in it is an ordinary outside click.
134
+ const layer = target instanceof Element ? target.closest("[data-modal-layer]") : null;
135
+ if (layer && !layer.contains(anchorRef.current)) return;
126
136
  onClose();
127
137
  }
128
138
  document.addEventListener("mousedown", onMouseDown);
@@ -145,6 +155,7 @@ export function Popover({
145
155
  ref={panelRef}
146
156
  role="dialog"
147
157
  aria-modal="false"
158
+ data-popover=""
148
159
  style={{
149
160
  position: "fixed",
150
161
  left: `${fixedPos.left}px`,
@@ -164,8 +175,10 @@ export function Popover({
164
175
  ref={panelRef}
165
176
  role="dialog"
166
177
  aria-modal="false"
178
+ data-popover=""
167
179
  className={cn(
168
- "absolute top-full z-50 mt-1 rounded-md border border-base-200 bg-base shadow-lg",
180
+ "absolute z-50 rounded-md border border-base-200 bg-base shadow-lg",
181
+ placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
169
182
  effectiveAlign === "end" ? "right-0" : "left-0",
170
183
  className,
171
184
  )}
@@ -20,7 +20,7 @@ const MAX_THUMBS = 3;
20
20
 
21
21
  /**
22
22
  * One section as a compact card: type over label, with a thumbnail cluster.
23
- * Shared by the reorder modal (which wraps it in a drag handle + drop
23
+ * Shared by the Edit sections modal (which wraps it in a drag handle + drop
24
24
  * indicator) and the Audience Details page (which dims unavailable rows), so
25
25
  * both read as the same list of the same site.
26
26
  */
@@ -2,9 +2,7 @@ import { cn } from "../../lib/cn";
2
2
  import { contrastColor } from "../../lib/contrast";
3
3
  import type { Audience } from "../../auth/types";
4
4
 
5
- /** Multi-select audience chips — the site-settings idiom for "who sees this".
6
- * Shared by the chatbot and deck-builder opt-ins so the two gates, which have
7
- * identical strict-opt-in semantics, cannot come to look like different rules. */
5
+ /** Multi-select audience chips — the site-settings idiom for "who sees this". */
8
6
  export function AudienceChips({
9
7
  audiences,
10
8
  selected,
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useRef, type ReactNode } from "react";
1
+ import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { X } from "lucide-react";
4
4
  import { IconButton } from "../shared/IconButton";
@@ -31,7 +31,10 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
31
31
  // Another EditorModal opened on top of this one (a ConfirmDialog, say) owns
32
32
  // Escape — attaching layerRef to our own data-modal-layer element below is
33
33
  // what lets the hook tell "another layer" from "just me".
34
- const layerRef = useStackedEscape<HTMLDivElement>(onClose, { enabled: isOpen && !blocking });
34
+ // An open Popover inside this modal owns Escape: it closes first, the modal on
35
+ // the next press. Stable, so the keydown listener isn't re-bound every render.
36
+ const popoverOpenInside = useCallback(() => Boolean(panelRef.current?.querySelector("[data-popover]")), []);
37
+ const layerRef = useStackedEscape<HTMLDivElement>(onClose, { enabled: isOpen && !blocking, before: popoverOpenInside });
35
38
 
36
39
  useEffect(() => {
37
40
  if (!isOpen) return;