@drawnagency/primitives 0.1.111 → 0.1.113

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 (203) hide show
  1. package/dist/auth/types.d.ts +1 -0
  2. package/dist/auth/types.d.ts.map +1 -1
  3. package/dist/chunk-4I5OO5UB.js +785 -0
  4. package/dist/{chunk-7WQJ57XG.js → chunk-6BZ2KNTP.js} +68 -260
  5. package/dist/{chunk-UVIYI4J6.js → chunk-AHALSCQH.js} +19 -3
  6. package/dist/{chunk-7MI3HCOF.js → chunk-BDFTMGCL.js} +10 -6
  7. package/dist/chunk-CXZ5YJG4.js +85 -0
  8. package/dist/{chunk-RA5IOFPY.js → chunk-DNY72JZN.js} +16 -38
  9. package/dist/{chunk-3GAG6ZXB.js → chunk-DS4GCYV2.js} +8 -4
  10. package/dist/{chunk-XQMDWXD6.js → chunk-FRE2CCLV.js} +3 -2
  11. package/dist/chunk-FV7JQRCN.js +94 -0
  12. package/dist/chunk-IIDKW7AV.js +41 -0
  13. package/dist/chunk-IINIZBQB.js +204 -0
  14. package/dist/{chunk-ELQ4C677.js → chunk-JH762VUH.js} +9 -44
  15. package/dist/{chunk-OUYB2GCW.js → chunk-K7DYZXVU.js} +1 -57
  16. package/dist/{chunk-NXGIHSL5.js → chunk-KR2MHTNJ.js} +11 -91
  17. package/dist/chunk-MM7KZS5C.js +30 -0
  18. package/dist/{chunk-OTDA6KWL.js → chunk-NHN2LSIU.js} +1 -1
  19. package/dist/chunk-QQ3S3P5P.js +38 -0
  20. package/dist/chunk-VAYZJKVR.js +209 -0
  21. package/dist/{chunk-VCCNKTFD.js → chunk-VWWY5TL5.js} +3 -31
  22. package/dist/chunk-WUJ3XNFI.js +62 -0
  23. package/dist/{chunk-ALMEC3XN.js → chunk-Z7W3DQTR.js} +22 -18
  24. package/dist/collab/rebase.js +1 -1
  25. package/dist/components/chat/ChatComposer.d.ts +25 -0
  26. package/dist/components/chat/ChatComposer.d.ts.map +1 -0
  27. package/dist/components/chat/ChatPanel.d.ts +5 -4
  28. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  29. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  30. package/dist/components/chat/markdown.d.ts +14 -0
  31. package/dist/components/chat/markdown.d.ts.map +1 -0
  32. package/dist/components/editor/index.js +16 -11
  33. package/dist/components/primitives/EditablePlainText.d.ts.map +1 -1
  34. package/dist/components/primitives/index.js +15 -10
  35. package/dist/components/sections/brand-guide.js +11 -9
  36. package/dist/components/sections/register-schemas.js +19 -14
  37. package/dist/components/sections/register.js +19 -14
  38. package/dist/components/shared/Navigation.d.ts +1 -0
  39. package/dist/components/shared/Navigation.d.ts.map +1 -1
  40. package/dist/components/shell/AudienceChips.d.ts +10 -0
  41. package/dist/components/shell/AudienceChips.d.ts.map +1 -0
  42. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  43. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  44. package/dist/components/shell/SiteSettingsDecks.d.ts +13 -0
  45. package/dist/components/shell/SiteSettingsDecks.d.ts.map +1 -0
  46. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  47. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  48. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  49. package/dist/deck/DeckComponentView.d.ts +13 -0
  50. package/dist/deck/DeckComponentView.d.ts.map +1 -0
  51. package/dist/deck/DeckPlayer.d.ts +26 -0
  52. package/dist/deck/DeckPlayer.d.ts.map +1 -0
  53. package/dist/deck/DeckSlideView.d.ts +17 -0
  54. package/dist/deck/DeckSlideView.d.ts.map +1 -0
  55. package/dist/deck/DeckStage.d.ts +16 -0
  56. package/dist/deck/DeckStage.d.ts.map +1 -0
  57. package/dist/deck/components/DeckImage.d.ts +14 -0
  58. package/dist/deck/components/DeckImage.d.ts.map +1 -0
  59. package/dist/deck/components/DeckImageGrid.d.ts +27 -0
  60. package/dist/deck/components/DeckImageGrid.d.ts.map +1 -0
  61. package/dist/deck/components/DeckRichText.d.ts +22 -0
  62. package/dist/deck/components/DeckRichText.d.ts.map +1 -0
  63. package/dist/deck/components/DeckVideo.d.ts +15 -0
  64. package/dist/deck/components/DeckVideo.d.ts.map +1 -0
  65. package/dist/deck/editing.d.ts +50 -0
  66. package/dist/deck/editing.d.ts.map +1 -0
  67. package/dist/deck/flip.d.ts +11 -0
  68. package/dist/deck/flip.d.ts.map +1 -0
  69. package/dist/deck/index.d.ts +24 -0
  70. package/dist/deck/index.d.ts.map +1 -0
  71. package/dist/deck/index.js +53 -0
  72. package/dist/deck/layouts/CenteredTextLayout.d.ts +12 -0
  73. package/dist/deck/layouts/CenteredTextLayout.d.ts.map +1 -0
  74. package/dist/deck/layouts/ColumnSplitLayout.d.ts +14 -0
  75. package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +1 -0
  76. package/dist/deck/layouts/FullBleedImageLayout.d.ts +14 -0
  77. package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +1 -0
  78. package/dist/deck/media.d.ts +33 -0
  79. package/dist/deck/media.d.ts.map +1 -0
  80. package/dist/deck/tokens.d.ts +22 -0
  81. package/dist/deck/tokens.d.ts.map +1 -0
  82. package/dist/deck/useStageScale.d.ts +10 -0
  83. package/dist/deck/useStageScale.d.ts.map +1 -0
  84. package/dist/deck-builder/DeckBuilderApp.d.ts +30 -0
  85. package/dist/deck-builder/DeckBuilderApp.d.ts.map +1 -0
  86. package/dist/deck-builder/DeckBuilderTopBar.d.ts +24 -0
  87. package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +1 -0
  88. package/dist/deck-builder/DeckChatPanel.d.ts +45 -0
  89. package/dist/deck-builder/DeckChatPanel.d.ts.map +1 -0
  90. package/dist/deck-builder/DeckEditor.d.ts +37 -0
  91. package/dist/deck-builder/DeckEditor.d.ts.map +1 -0
  92. package/dist/deck-builder/DeckLibrary.d.ts +22 -0
  93. package/dist/deck-builder/DeckLibrary.d.ts.map +1 -0
  94. package/dist/deck-builder/DeckSlideSurface.d.ts +31 -0
  95. package/dist/deck-builder/DeckSlideSurface.d.ts.map +1 -0
  96. package/dist/deck-builder/deck-api.d.ts +76 -0
  97. package/dist/deck-builder/deck-api.d.ts.map +1 -0
  98. package/dist/deck-builder/deck-chat-client.d.ts +44 -0
  99. package/dist/deck-builder/deck-chat-client.d.ts.map +1 -0
  100. package/dist/deck-builder/deck-chat-history.d.ts +10 -0
  101. package/dist/deck-builder/deck-chat-history.d.ts.map +1 -0
  102. package/dist/deck-builder/index.d.ts +13 -0
  103. package/dist/deck-builder/index.d.ts.map +1 -0
  104. package/dist/deck-builder/index.js +1505 -0
  105. package/dist/deck-builder/starter-deck.d.ts +23 -0
  106. package/dist/deck-builder/starter-deck.d.ts.map +1 -0
  107. package/dist/deck-builder/types.d.ts +29 -0
  108. package/dist/deck-builder/types.d.ts.map +1 -0
  109. package/dist/deck-builder/useSortableSlide.d.ts +29 -0
  110. package/dist/deck-builder/useSortableSlide.d.ts.map +1 -0
  111. package/dist/index.js +70 -63
  112. package/dist/lib/deck-access.d.ts +86 -0
  113. package/dist/lib/deck-access.d.ts.map +1 -0
  114. package/dist/lib/deck-access.js +58 -0
  115. package/dist/lib/deck-dexie.d.ts +88 -0
  116. package/dist/lib/deck-dexie.d.ts.map +1 -0
  117. package/dist/lib/deck-dexie.js +22 -0
  118. package/dist/lib/deck-edit.d.ts +67 -0
  119. package/dist/lib/deck-edit.d.ts.map +1 -0
  120. package/dist/lib/deck-media-assets.d.ts +27 -0
  121. package/dist/lib/deck-media-assets.d.ts.map +1 -0
  122. package/dist/lib/deck-media-assets.js +25 -0
  123. package/dist/lib/deck-media-refs.d.ts +35 -0
  124. package/dist/lib/deck-media-refs.d.ts.map +1 -0
  125. package/dist/lib/deck-media-refs.js +36 -0
  126. package/dist/lib/deck-tool.d.ts +25 -0
  127. package/dist/lib/deck-tool.d.ts.map +1 -0
  128. package/dist/lib/deck-tool.js +58 -0
  129. package/dist/lib/index-ops.js +2 -2
  130. package/dist/lib/index.js +17 -12
  131. package/dist/lib/nav.d.ts.map +1 -1
  132. package/dist/media/index.js +3 -3
  133. package/dist/media/resolve.d.ts +2 -0
  134. package/dist/media/resolve.d.ts.map +1 -1
  135. package/dist/schemas/deck-meta.d.ts +85 -0
  136. package/dist/schemas/deck-meta.d.ts.map +1 -0
  137. package/dist/schemas/deck-meta.js +64 -0
  138. package/dist/schemas/deck.d.ts +954 -0
  139. package/dist/schemas/deck.d.ts.map +1 -0
  140. package/dist/schemas/deck.js +65 -0
  141. package/dist/schemas/index.js +39 -37
  142. package/dist/schemas/site-config.d.ts +14 -1
  143. package/dist/schemas/site-config.d.ts.map +1 -1
  144. package/dist/types/database.d.ts +8 -0
  145. package/dist/types/database.d.ts.map +1 -1
  146. package/package.json +37 -1
  147. package/src/auth/types.ts +1 -0
  148. package/src/components/chat/ChatComposer.tsx +100 -0
  149. package/src/components/chat/ChatPanel.tsx +20 -69
  150. package/src/components/chat/ChatWidget.tsx +5 -4
  151. package/src/components/chat/markdown.ts +32 -0
  152. package/src/components/primitives/EditablePlainText.tsx +11 -1
  153. package/src/components/shared/Navigation.tsx +26 -18
  154. package/src/components/shell/AudienceChips.tsx +30 -0
  155. package/src/components/shell/EditorShell.tsx +12 -0
  156. package/src/components/shell/SiteSettingsChatbot.tsx +1 -18
  157. package/src/components/shell/SiteSettingsDecks.tsx +41 -0
  158. package/src/components/shell/SiteSettingsDisplay.tsx +10 -1
  159. package/src/components/shell/SiteSettingsModal.tsx +11 -0
  160. package/src/components/shell/SiteSettingsUsers.tsx +49 -2
  161. package/src/deck/DeckComponentView.tsx +28 -0
  162. package/src/deck/DeckPlayer.tsx +166 -0
  163. package/src/deck/DeckSlideView.tsx +38 -0
  164. package/src/deck/DeckStage.tsx +39 -0
  165. package/src/deck/components/DeckImage.tsx +38 -0
  166. package/src/deck/components/DeckImageGrid.tsx +254 -0
  167. package/src/deck/components/DeckRichText.tsx +111 -0
  168. package/src/deck/components/DeckVideo.tsx +81 -0
  169. package/src/deck/editing.tsx +201 -0
  170. package/src/deck/flip.ts +70 -0
  171. package/src/deck/index.ts +30 -0
  172. package/src/deck/layouts/CenteredTextLayout.tsx +29 -0
  173. package/src/deck/layouts/ColumnSplitLayout.tsx +69 -0
  174. package/src/deck/layouts/FullBleedImageLayout.tsx +58 -0
  175. package/src/deck/media.tsx +46 -0
  176. package/src/deck/tokens.ts +57 -0
  177. package/src/deck/useStageScale.ts +44 -0
  178. package/src/deck-builder/DeckBuilderApp.tsx +572 -0
  179. package/src/deck-builder/DeckBuilderTopBar.tsx +62 -0
  180. package/src/deck-builder/DeckChatPanel.tsx +334 -0
  181. package/src/deck-builder/DeckEditor.tsx +258 -0
  182. package/src/deck-builder/DeckLibrary.tsx +195 -0
  183. package/src/deck-builder/DeckSlideSurface.tsx +55 -0
  184. package/src/deck-builder/deck-api.ts +177 -0
  185. package/src/deck-builder/deck-chat-client.ts +129 -0
  186. package/src/deck-builder/deck-chat-history.ts +62 -0
  187. package/src/deck-builder/index.ts +12 -0
  188. package/src/deck-builder/starter-deck.ts +86 -0
  189. package/src/deck-builder/types.ts +24 -0
  190. package/src/deck-builder/useSortableSlide.ts +102 -0
  191. package/src/lib/deck-access.ts +132 -0
  192. package/src/lib/deck-dexie.ts +190 -0
  193. package/src/lib/deck-edit.ts +196 -0
  194. package/src/lib/deck-media-assets.ts +57 -0
  195. package/src/lib/deck-media-refs.ts +67 -0
  196. package/src/lib/deck-tool.ts +95 -0
  197. package/src/lib/nav.ts +21 -1
  198. package/src/media/resolve.ts +3 -1
  199. package/src/schemas/deck-meta.ts +88 -0
  200. package/src/schemas/deck.ts +478 -0
  201. package/src/schemas/index.ts +4 -0
  202. package/src/schemas/site-config.ts +27 -1
  203. package/src/types/database.ts +4 -0
@@ -1,8 +1,9 @@
1
- import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
2
- import { marked } from "marked";
3
- import { ArrowDown, ChevronDown, ChevronRight, History, Pin, PinOff, SquarePen, Square, Trash2 } from "lucide-react";
1
+ import { useEffect, useRef, useState, type MouseEvent } from "react";
2
+ import { ArrowDown, ChevronDown, ChevronRight, History, Pin, PinOff, SquarePen, Trash2 } from "lucide-react";
4
3
  import { cn } from "../../lib/cn";
5
- import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
4
+ import { ensureSanitizer } from "../../lib/sanitize";
5
+ import { assistantHtml, MD_STYLES, USER_BUBBLE_CLASS } from "./markdown";
6
+ import { ChatComposer } from "./ChatComposer";
6
7
  import { resolveThinkingPhrases } from "../../lib/chatbot";
7
8
  import { lockBodyScroll } from "../../lib/scroll-lock";
8
9
  import { loadHistory, upsertConversation, setConversationPinned, removeConversation, type StoredConversation } from "./chat-history";
@@ -63,24 +64,10 @@ const ERROR_COPY: Record<string, string> = {
63
64
  };
64
65
  const RETRYABLE = new Set(["rate_limited", "upstream_error"]);
65
66
 
66
- /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
67
- * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
68
- * through, and model output is untrusted. Exported for tests. */
69
- export function assistantHtml(md: string): string {
70
- return sanitizeHtml(marked.parse(md, { async: false, gfm: true, breaks: true }) as string);
71
- }
72
-
73
- // Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
74
- // weight + spacing to clamp to the message's text scale.
75
- const MD_STYLES =
76
- "[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
77
- "[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
78
- "[&_a]:underline [&_a]:underline-offset-2 [&_a]:text-primary " +
79
- "[&_code]:rounded-inner [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
80
- "[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded-inner [&_pre]:bg-base-200 [&_pre]:p-2 " +
81
- "[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
82
- "[&_h3]:mt-2 [&_h3]:font-semibold [&_h4]:mt-2 [&_h4]:font-semibold " +
83
- "[&_blockquote]:mt-2 [&_blockquote]:border-l-2 [&_blockquote]:border-base-200 [&_blockquote]:pl-2";
67
+ /** Re-exported from ./markdown, where the rendering rules now live so the deck
68
+ * assistant shares them. Kept here because tests and callers import it from
69
+ * this module. */
70
+ export { assistantHtml };
84
71
 
85
72
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
86
73
  export async function streamChat(
@@ -207,25 +194,6 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
207
194
  const listRef = useRef<HTMLDivElement>(null);
208
195
  const pinnedRef = useRef(true);
209
196
 
210
- // Autosize the composer: height tracks content up to the className's
211
- // 4-line max-h cap, then it scrolls internally. Keyed on `input` so the
212
- // post-send clear snaps it back to a single row. (field-sizing: content
213
- // would do this in CSS alone, but it's still Chromium-only.)
214
- useEffect(() => {
215
- const el = composerRef.current;
216
- if (!el) return;
217
- el.style.height = "auto";
218
- el.style.height = `${el.scrollHeight}px`;
219
- }, [input]);
220
-
221
- function onComposerKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
222
- // Chat convention: Enter sends, Shift+Enter inserts a newline.
223
- if (e.key === "Enter" && !e.shiftKey) {
224
- e.preventDefault();
225
- void send();
226
- }
227
- }
228
-
229
197
  // The tasks prop can shrink live (the configurator's Preview-as filters it):
230
198
  // never keep a selected task that's no longer offered, or the next send would
231
199
  // assemble a task the previewed audience can't access.
@@ -589,7 +557,7 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
589
557
  )}
590
558
  {messages.map((m, i) =>
591
559
  m.role === "user" ? (
592
- <div key={i} className="ml-auto max-w-[85%] whitespace-pre-wrap rounded-outer bg-primary px-3 py-2 text-sm text-primary-contrast">
560
+ <div key={i} className={USER_BUBBLE_CLASS}>
593
561
  {m.content}
594
562
  </div>
595
563
  ) : m.content === "" ? (
@@ -657,33 +625,16 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
657
625
  )}
658
626
  </div>
659
627
  <p role="status" className="sr-only">{busy ? thinking : ""}</p>
660
- <form
661
- className={cn(
662
- "flex shrink-0 items-end gap-2 border-t border-base-200 p-2",
663
- mode === "sheet" && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
664
- )}
665
- onSubmit={(e) => { e.preventDefault(); void send(); }}
666
- >
667
- <textarea
668
- ref={composerRef}
669
- rows={1}
670
- value={input}
671
- onChange={(e) => setInput(e.target.value)}
672
- onKeyDown={onComposerKeyDown}
673
- placeholder={`Ask about ${siteName}…`}
674
- maxLength={4000}
675
- className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded-inner border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
676
- />
677
- {busy ? (
678
- <button type="button" aria-label="Stop" onClick={stop}
679
- className="flex cursor-pointer items-center rounded-inner bg-primary px-3 py-1.5 text-sm text-primary-contrast">
680
- <Square size={12} fill="currentColor" aria-hidden="true" />
681
- </button>
682
- ) : (
683
- <button type="submit" aria-label="Send"
684
- className="rounded-inner bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
685
- )}
686
- </form>
628
+ <ChatComposer
629
+ value={input}
630
+ onChange={setInput}
631
+ onSend={() => void send()}
632
+ onStop={stop}
633
+ busy={busy}
634
+ placeholder={`Ask about ${siteName}…`}
635
+ textareaRef={composerRef}
636
+ className={mode === "sheet" ? "pb-[max(0.5rem,env(safe-area-inset-bottom))]" : undefined}
637
+ />
687
638
  {isEditor && <p className="shrink-0 border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">Editor view — answers ground on published content.</p>}
688
639
  </div>
689
640
  </>
@@ -5,8 +5,9 @@
5
5
  // see base.css); below 768px it opens as a bottom sheet. Open/close is
6
6
  // animated (sidebar slides in as the band shifts, sheet slides up, and ALL
7
7
  // page FABs shrink/fade away); prefers-reduced-motion keeps everything
8
- // instant. FABs show only while closed. Docked-open is the default and the
9
- // preference persists per tab (sessionStorage, mirrored by Layout.astro's
8
+ // instant. FABs show only while closed. Docked-CLOSED is the default — the
9
+ // visitor opens it — and the choice then persists per tab (sessionStorage,
10
+ // so a tab that opened the chat keeps it open across pages; mirrored by Layout.astro's
10
11
  // pre-paint script so the column is reserved before hydration) — shared
11
12
  // across viewer pages and /edit.
12
13
  //
@@ -116,8 +117,8 @@ export default function ChatWidget({ siteName, botName, thinkingPhrases, tasks,
116
117
  const apply = () => setDocked(mq.matches);
117
118
  apply();
118
119
  mq.addEventListener("change", apply);
119
- let storedOpen = true; // default: sidebar open
120
- try { storedOpen = window.sessionStorage.getItem(OPEN_KEY) !== "0"; } catch {}
120
+ let storedOpen = false; // default: sidebar closed until the visitor opens it
121
+ try { storedOpen = window.sessionStorage.getItem(OPEN_KEY) === "1"; } catch {}
121
122
  if (mq.matches && storedOpen) { setOpen(true); setEverOpened(true); }
122
123
  return () => mq.removeEventListener("change", apply);
123
124
  }, []);
@@ -0,0 +1,32 @@
1
+ import { marked } from "marked";
2
+ import { sanitizeHtml } from "../../lib/sanitize";
3
+
4
+ /**
5
+ * The assistant-message treatment, shared by every chat surface (brand chat's
6
+ * ChatPanel and the Deck Builder's docked assistant). Extracted so a second
7
+ * chat pane is a second CALLER, not a second copy of the rendering rules —
8
+ * particularly the sanitizer gate below, which is a security property.
9
+ */
10
+
11
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
12
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
13
+ * through, and model output is untrusted. */
14
+ export function assistantHtml(md: string): string {
15
+ return sanitizeHtml(marked.parse(md, { async: false, gfm: true, breaks: true }) as string);
16
+ }
17
+
18
+ // Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
19
+ // weight + spacing to clamp to the message's text scale.
20
+ export const MD_STYLES =
21
+ "[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
22
+ "[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
23
+ "[&_a]:underline [&_a]:underline-offset-2 [&_a]:text-primary " +
24
+ "[&_code]:rounded-inner [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
25
+ "[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded-inner [&_pre]:bg-base-200 [&_pre]:p-2 " +
26
+ "[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
27
+ "[&_h3]:mt-2 [&_h3]:font-semibold [&_h4]:mt-2 [&_h4]:font-semibold " +
28
+ "[&_blockquote]:mt-2 [&_blockquote]:border-l-2 [&_blockquote]:border-base-200 [&_blockquote]:pl-2";
29
+
30
+ /** The user's turn: the only side of a transcript that gets a filled bubble. */
31
+ export const USER_BUBBLE_CLASS =
32
+ "ml-auto max-w-[85%] whitespace-pre-wrap rounded-outer bg-primary px-3 py-2 text-sm text-primary-contrast";
@@ -29,6 +29,16 @@ export function EditablePlainText({
29
29
  ...htmlAttrs
30
30
  }: EditablePlainTextProps) {
31
31
  const ref = useRef<HTMLElement>(null);
32
+ // React must NOT own the text node while the field is being typed into. Every
33
+ // keystroke commits (see useEditablePlainText), the parent echoes the new
34
+ // value straight back, and React's children diff then rewrites the element's
35
+ // text — which destroys the live text node and collapses the caret to offset
36
+ // 0, so a heading comes out backwards. In edit mode we therefore render the
37
+ // text React knew at the moment editing began and hand ownership to the
38
+ // effect below, which writes only when the DOM has genuinely drifted.
39
+ const uncontrolled = useRef(value);
40
+ if (!isEditMode) uncontrolled.current = value;
41
+
32
42
  const {
33
43
  value: localValue,
34
44
  handleInput,
@@ -94,6 +104,6 @@ export function EditablePlainText({
94
104
  "data-placeholder": isEditMode && placeholder ? placeholder : undefined,
95
105
  ...htmlAttrs,
96
106
  },
97
- value,
107
+ isEditMode ? uncontrolled.current : value,
98
108
  );
99
109
  }
@@ -16,7 +16,7 @@ interface Props {
16
16
  siteName: string;
17
17
  darkMode: "light" | "dark" | "optional";
18
18
  lastUpdated: string | null;
19
- logo?: { lightUrl: string; darkUrl: string | null; invertInDark: boolean } | null;
19
+ logo?: { lightUrl: string; darkUrl: string | null; invertInDark: boolean; hideTitle?: boolean } | null;
20
20
  // Optional same-island handler (tests). In production the editor nav is a
21
21
  // separate island from EditorShell, so a click dispatches pageSelectEvent.
22
22
  onPageSelect?: (pageId: string) => void;
@@ -217,18 +217,24 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
217
217
  </ul>
218
218
  );
219
219
 
220
- const logoMark = logo ? (
221
- <>
222
- <img
223
- src={logo.lightUrl}
224
- alt={currentSiteName}
225
- 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"}
226
- />
227
- {logo.darkUrl && (
228
- <img src={logo.darkUrl} alt={currentSiteName} className="hidden h-8 w-auto object-contain dark:block" />
229
- )}
230
- </>
231
- ) : null;
220
+ // `size` differs by surface: the mobile header is a 16-unit bar so the logo is
221
+ // height-capped there, while the sidebar sizes it to 80% of the nav's width.
222
+ const renderLogo = (size: string) =>
223
+ logo ? (
224
+ <>
225
+ <img
226
+ src={logo.lightUrl}
227
+ alt={currentSiteName}
228
+ className={cn("object-contain", size, logo.darkUrl ? "block dark:hidden" : logo.invertInDark && "dark:invert")}
229
+ />
230
+ {logo.darkUrl && (
231
+ <img src={logo.darkUrl} alt={currentSiteName} className={cn("hidden object-contain dark:block", size)} />
232
+ )}
233
+ </>
234
+ ) : null;
235
+
236
+ // A logo stands in for the site name unless the editor has opted to keep both.
237
+ const showTitle = !logo || !logo.hideTitle;
232
238
 
233
239
  const handlePageClick = useCallback((id: string) => {
234
240
  setEditorAudienceOpen(false);
@@ -368,8 +374,8 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
368
374
  <header className={cn("fixed top-0 left-0 right-0 z-50 bg-base", bp.hidden)}>
369
375
  <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
370
376
  <div className="flex flex-col items-start gap-1">
371
- {logoMark}
372
- <span className="text-lg font-bold text-primary">{currentSiteName}</span>
377
+ {renderLogo("h-8 w-auto")}
378
+ {showTitle && <span className="text-lg font-bold text-primary">{currentSiteName}</span>}
373
379
  </div>
374
380
  <button onClick={() => setIsOpen(!isOpen)} className="cursor-pointer p-2 text-base-contrast" aria-label="Toggle navigation">
375
381
  <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
@@ -386,9 +392,11 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
386
392
  <nav className={cn("fixed top-0 left-0 z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform nav-sidebar",
387
393
  bp.leftAuto, bp.translate,
388
394
  isOpen ? "translate-x-0" : "-translate-x-full")}>
389
- <div className={cn("hidden px-4 py-4", bp.block)}>
390
- {logoMark}
391
- <span className="text-lg font-bold text-primary">{currentSiteName}</span>
395
+ {/* No horizontal padding on the wrapper: the logo's 80% is of the nav
396
+ column itself, not of a padded box inside it. */}
397
+ <div className={cn("hidden py-4", bp.block)}>
398
+ {renderLogo("mx-auto h-auto w-4/5")}
399
+ {showTitle && <span className="block px-4 text-lg font-bold text-primary">{currentSiteName}</span>}
392
400
  </div>
393
401
 
394
402
  <ul className="space-y-1 px-4 py-2">
@@ -0,0 +1,30 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { contrastColor } from "../../lib/contrast";
3
+ import type { Audience } from "../../auth/types";
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. */
8
+ export function AudienceChips({
9
+ audiences,
10
+ selected,
11
+ onToggle,
12
+ }: {
13
+ audiences: Audience[];
14
+ selected: string[];
15
+ onToggle: (name: string) => void;
16
+ }) {
17
+ return (
18
+ <div className="flex flex-wrap gap-1">
19
+ {audiences.map((a) => (
20
+ <button key={a.name} type="button" onClick={() => onToggle(a.name)}
21
+ className={cn("rounded-full border px-2 py-0.5 text-xs",
22
+ selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
23
+ style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color, color: contrastColor(a.color) } : undefined}>
24
+ {a.displayName}
25
+ </button>
26
+ ))}
27
+ {audiences.length === 0 && <span className="text-xs text-base-contrast-light">No audiences configured</span>}
28
+ </div>
29
+ );
30
+ }
@@ -2025,6 +2025,18 @@ export default function EditorShell({
2025
2025
  </svg>
2026
2026
  </Fab>
2027
2027
  )}
2028
+ {/* Deck Builder (#34). Editors are super-admins over decks and are
2029
+ never gated by deckBuilder.audiences, so this is unconditional.
2030
+ An <a>, not a click handler: builder mode is its own route.
2031
+ bottom-52 clears the bottom-40 document FAB + gap. Taking over the
2032
+ editor top bar in place is #38 — this just goes there. */}
2033
+ <Fab href="/decks?next=%2Fedit" label="Build a deck" column="editor" className="bottom-52">
2034
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
2035
+ <rect x="3" y="4" width="18" height="12" rx="1" />
2036
+ <path d="M12 16v4" />
2037
+ <path d="M9 20h6" />
2038
+ </svg>
2039
+ </Fab>
2028
2040
  {showDocumentEditor && resolvedActivePage?.document?.enabled && (
2029
2041
  <Suspense fallback={<DocumentOverlayFallback />}>
2030
2042
  {/* Branch/history view (viewSections !== null → previewing the
@@ -1,8 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { Button } from "../shared/Button";
3
3
  import { SubTabs } from "../shared/SubTabs";
4
- import { cn } from "../../lib/cn";
5
- import { contrastColor } from "../../lib/contrast";
4
+ import { AudienceChips } from "./AudienceChips";
6
5
  import ChatPanel from "../chat/ChatPanel";
7
6
  import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
8
7
  import type { Audience } from "../../auth/types";
@@ -30,22 +29,6 @@ function slugify(label: string): string {
30
29
  return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
31
30
  }
32
31
 
33
- function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[]; selected: string[]; onToggle: (name: string) => void }) {
34
- return (
35
- <div className="flex flex-wrap gap-1">
36
- {audiences.map((a) => (
37
- <button key={a.name} type="button" onClick={() => onToggle(a.name)}
38
- className={cn("rounded-full border px-2 py-0.5 text-xs",
39
- selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
40
- style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color, color: contrastColor(a.color) } : undefined}>
41
- {a.displayName}
42
- </button>
43
- ))}
44
- {audiences.length === 0 && <span className="text-xs text-base-contrast-light">No audiences configured</span>}
45
- </div>
46
- );
47
- }
48
-
49
32
  export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props) {
50
33
  const chatbot = siteConfig.chatbot;
51
34
  const [newTaskLabel, setNewTaskLabel] = useState("");
@@ -0,0 +1,41 @@
1
+ import { AudienceChips } from "./AudienceChips";
2
+ import type { SiteConfig } from "../../schemas/site-config";
3
+ import type { Audience } from "../../auth/types";
4
+
5
+ interface Props {
6
+ siteConfig: SiteConfig;
7
+ onSiteConfigChange: (config: SiteConfig) => void;
8
+ audiences: Audience[];
9
+ }
10
+
11
+ /** Deck Builder settings. One control today — the creation opt-in — because
12
+ * that is the only `deckBuilder` key that exists; public sharing (#37) is the
13
+ * next one and belongs on this tab. */
14
+ export function SiteSettingsDecks({ siteConfig, onSiteConfigChange, audiences }: Props) {
15
+ const selected = siteConfig.deckBuilder.audiences;
16
+
17
+ function toggle(name: string) {
18
+ const next = selected.includes(name) ? selected.filter((n) => n !== name) : [...selected, name];
19
+ onSiteConfigChange({ ...siteConfig, deckBuilder: { ...siteConfig.deckBuilder, audiences: next } });
20
+ }
21
+
22
+ return (
23
+ <div className="space-y-3">
24
+ <div>
25
+ <h3 className="mb-1 text-sm font-medium text-base-contrast">Who can build decks</h3>
26
+ <p className="mb-2 text-xs text-base-contrast-light">
27
+ Members of the selected audiences get the Decks button and can create and publish their own presentations.
28
+ None selected means no viewer can start a new one. Editors always can.
29
+ </p>
30
+ <AudienceChips audiences={audiences} selected={selected} onToggle={toggle} />
31
+ </div>
32
+ <p className="text-xs text-base-contrast-light">
33
+ Removing an audience here stops its members starting new decks. Decks they already own stay theirs — they can
34
+ still open, edit and delete them — so remove the deck itself if that is what you want gone.
35
+ </p>
36
+ <p className="text-xs text-base-contrast-light">
37
+ A deck is private to the person who made it. Sharing it with an audience, or by public link, comes later.
38
+ </p>
39
+ </div>
40
+ );
41
+ }
@@ -88,7 +88,7 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
88
88
 
89
89
  function pickLogo(which: "light" | "dark") {
90
90
  mediaLibrary?.openSelectModal((imageId) => {
91
- const base = siteConfig.logo ?? { light: { imageId: "" }, dark: null, invertInDark: false, showInNav: true };
91
+ const base = siteConfig.logo ?? { light: { imageId: "" }, dark: null, invertInDark: false, showInNav: true, hideTitle: true };
92
92
  update({ logo: { ...base, [which]: { imageId } } });
93
93
  });
94
94
  }
@@ -292,6 +292,15 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
292
292
  label="Show in navigation"
293
293
  />
294
294
 
295
+ {/* Not gated on showInNav: the login page shows the logo either
296
+ way, so this has to stay reachable when the nav one is off. */}
297
+ <Checkbox
298
+ checked={logo.hideTitle}
299
+ onChange={(v) => update({ logo: { ...logo, hideTitle: v } })}
300
+ label="Hide site title"
301
+ description="The logo stands in for the site name in the navigation and on the login page. Turn this off to show both."
302
+ />
303
+
295
304
  {needsDarkHint && (
296
305
  <p className="text-xs text-amber-600">
297
306
  Add a dark variant or enable invert so the logo stays legible in dark mode.
@@ -6,6 +6,7 @@ import { SiteSettingsViewerAccess } from "./SiteSettingsViewerAccess";
6
6
  import { SiteSettingsDisplay } from "./SiteSettingsDisplay";
7
7
  import { SiteSettingsUsers } from "./SiteSettingsUsers";
8
8
  import { SiteSettingsChatbot } from "./SiteSettingsChatbot";
9
+ import { SiteSettingsDecks } from "./SiteSettingsDecks";
9
10
  import type { SiteConfig } from "../../schemas/site-config";
10
11
  import type { Audience } from "../../auth/types";
11
12
 
@@ -81,6 +82,16 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
81
82
  </div>
82
83
  ),
83
84
  },
85
+ {
86
+ id: "decks",
87
+ label: "Decks",
88
+ show: true,
89
+ content: (
90
+ <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
91
+ <SiteSettingsDecks siteConfig={siteConfig} onSiteConfigChange={onSiteConfigChange} audiences={audiences} />
92
+ </div>
93
+ ),
94
+ },
84
95
  {
85
96
  id: "display",
86
97
  label: "Display",
@@ -83,6 +83,29 @@ export function SiteSettingsUsers({ currentUser }: Props) {
83
83
  setConfirmingRevoke(userId);
84
84
  }
85
85
 
86
+ async function handleRoleChange(userId: string, role: Role) {
87
+ setError(null);
88
+ try {
89
+ const res = await fetch("/api/auth/users", {
90
+ method: "PATCH",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ userId, role }),
93
+ });
94
+ if (!res.ok) {
95
+ const data = await res.json().catch(() => ({}));
96
+ // Resync first: a rejected change would otherwise leave the select
97
+ // showing a role the user does not have. loadUsers() clears `error` on
98
+ // success, so the message has to be set after it, not before.
99
+ await loadUsers();
100
+ setError(data.error || "Role change failed");
101
+ return;
102
+ }
103
+ await loadUsers();
104
+ } catch {
105
+ setError("Network error while changing role");
106
+ }
107
+ }
108
+
86
109
  async function confirmRevoke(userId: string) {
87
110
  setConfirmingRevoke(null);
88
111
  try {
@@ -114,8 +137,28 @@ export function SiteSettingsUsers({ currentUser }: Props) {
114
137
  {users.map((user) => (
115
138
  <Fragment key={user.id}>
116
139
  <div className="flex items-center border-b border-base-200 py-2 pr-2 text-base-contrast">{user.email}</div>
117
- <div className="flex items-center border-b border-base-200 py-2 pr-2 capitalize text-base-contrast-light">
118
- {user.role}
140
+ <div className="flex items-center border-b border-base-200 py-2 pr-2 text-base-contrast-light">
141
+ {currentUser?.email === user.email ? (
142
+ // Owners can't change their own role — the same rule that makes
143
+ // this row's Remove control a dash.
144
+ <span className="capitalize">{user.role}</span>
145
+ ) : (
146
+ <div className="relative w-full">
147
+ <select
148
+ value={user.role}
149
+ onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
150
+ aria-label={`Role for ${user.email}`}
151
+ className="w-full appearance-none rounded border border-base-200 bg-base py-1 pr-7 pl-2 text-sm text-base-contrast focus:border-base-contrast focus:outline-none focus:ring-1 focus:ring-base-contrast"
152
+ >
153
+ <option value="editor">Editor</option>
154
+ <option value="owner">Owner</option>
155
+ </select>
156
+ <ChevronDown
157
+ size={14}
158
+ className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-base-contrast-light"
159
+ />
160
+ </div>
161
+ )}
119
162
  </div>
120
163
  <div className="flex items-center justify-end border-b border-base-200 py-2">
121
164
  {currentUser?.email === user.email ? (
@@ -187,6 +230,10 @@ export function SiteSettingsUsers({ currentUser }: Props) {
187
230
  </Button>
188
231
  </form>
189
232
 
233
+ <p className="text-xs text-base-contrast-light">
234
+ Role changes take effect the next time the user signs in, or within 24 hours.
235
+ </p>
236
+
190
237
  {error && <p className="text-sm text-red-600">{error}</p>}
191
238
  </div>
192
239
  );
@@ -0,0 +1,28 @@
1
+ import type { DeckTextSlot } from "../lib/deck-edit";
2
+ import type { DeckComponent } from "../schemas/deck";
3
+ import { DeckImage } from "./components/DeckImage";
4
+ import { DeckImageGrid } from "./components/DeckImageGrid";
5
+ import { DeckRichText } from "./components/DeckRichText";
6
+ import { DeckVideo } from "./components/DeckVideo";
7
+
8
+ interface Props {
9
+ component: DeckComponent;
10
+ /** The Layout slot this component fills — carried only so text leaves can
11
+ * address themselves for inline editing (#39). Optional: a caller that omits
12
+ * it gets the read-only render. */
13
+ slot?: DeckTextSlot;
14
+ }
15
+
16
+ /** Dispatches a Deck Component onto its runtime implementation. */
17
+ export function DeckComponentView({ component, slot }: Props) {
18
+ switch (component.type) {
19
+ case "richText":
20
+ return <DeckRichText content={component} slot={slot} />;
21
+ case "image":
22
+ return <DeckImage content={component} />;
23
+ case "imageGrid":
24
+ return <DeckImageGrid content={component} />;
25
+ case "video":
26
+ return <DeckVideo content={component} />;
27
+ }
28
+ }