@drawnagency/primitives 0.1.110 → 0.1.112

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 (188) hide show
  1. package/dist/{chunk-3GAG6ZXB.js → chunk-3SZNUCXI.js} +8 -4
  2. package/dist/chunk-4I5OO5UB.js +785 -0
  3. package/dist/{chunk-ALMEC3XN.js → chunk-5KNXTMUC.js} +22 -18
  4. package/dist/{chunk-OUYB2GCW.js → chunk-BR4MIPTM.js} +1 -57
  5. package/dist/chunk-CXZ5YJG4.js +85 -0
  6. package/dist/{chunk-RA5IOFPY.js → chunk-DNY72JZN.js} +16 -38
  7. package/dist/{chunk-7MI3HCOF.js → chunk-EPWBPIV6.js} +10 -6
  8. package/dist/chunk-FV7JQRCN.js +94 -0
  9. package/dist/{chunk-UVIYI4J6.js → chunk-HSXMOS5Q.js} +15 -2
  10. package/dist/chunk-IIDKW7AV.js +41 -0
  11. package/dist/chunk-IINIZBQB.js +204 -0
  12. package/dist/chunk-MM7KZS5C.js +30 -0
  13. package/dist/{chunk-VCCNKTFD.js → chunk-OGGZ5AXT.js} +3 -31
  14. package/dist/{chunk-XQMDWXD6.js → chunk-P3ZNCHW7.js} +1 -1
  15. package/dist/chunk-QQ3S3P5P.js +38 -0
  16. package/dist/{chunk-ELQ4C677.js → chunk-TALRVOGO.js} +9 -44
  17. package/dist/chunk-VAYZJKVR.js +209 -0
  18. package/dist/{chunk-OTDA6KWL.js → chunk-VLPELTPI.js} +1 -1
  19. package/dist/{chunk-7WQJ57XG.js → chunk-VT3PYGVW.js} +68 -260
  20. package/dist/chunk-WUJ3XNFI.js +62 -0
  21. package/dist/{chunk-NXGIHSL5.js → chunk-Z2LO4PSQ.js} +11 -91
  22. package/dist/collab/rebase.js +1 -1
  23. package/dist/components/chat/ChatComposer.d.ts +25 -0
  24. package/dist/components/chat/ChatComposer.d.ts.map +1 -0
  25. package/dist/components/chat/ChatPanel.d.ts +5 -4
  26. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  27. package/dist/components/chat/markdown.d.ts +14 -0
  28. package/dist/components/chat/markdown.d.ts.map +1 -0
  29. package/dist/components/editor/index.js +16 -11
  30. package/dist/components/primitives/EditablePlainText.d.ts.map +1 -1
  31. package/dist/components/primitives/index.js +15 -10
  32. package/dist/components/sections/brand-guide.js +11 -9
  33. package/dist/components/sections/register-schemas.js +19 -14
  34. package/dist/components/sections/register.js +19 -14
  35. package/dist/components/shell/AudienceChips.d.ts +10 -0
  36. package/dist/components/shell/AudienceChips.d.ts.map +1 -0
  37. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  38. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  39. package/dist/components/shell/SiteSettingsDecks.d.ts +13 -0
  40. package/dist/components/shell/SiteSettingsDecks.d.ts.map +1 -0
  41. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  42. package/dist/deck/DeckComponentView.d.ts +13 -0
  43. package/dist/deck/DeckComponentView.d.ts.map +1 -0
  44. package/dist/deck/DeckPlayer.d.ts +26 -0
  45. package/dist/deck/DeckPlayer.d.ts.map +1 -0
  46. package/dist/deck/DeckSlideView.d.ts +17 -0
  47. package/dist/deck/DeckSlideView.d.ts.map +1 -0
  48. package/dist/deck/DeckStage.d.ts +16 -0
  49. package/dist/deck/DeckStage.d.ts.map +1 -0
  50. package/dist/deck/components/DeckImage.d.ts +14 -0
  51. package/dist/deck/components/DeckImage.d.ts.map +1 -0
  52. package/dist/deck/components/DeckImageGrid.d.ts +27 -0
  53. package/dist/deck/components/DeckImageGrid.d.ts.map +1 -0
  54. package/dist/deck/components/DeckRichText.d.ts +22 -0
  55. package/dist/deck/components/DeckRichText.d.ts.map +1 -0
  56. package/dist/deck/components/DeckVideo.d.ts +15 -0
  57. package/dist/deck/components/DeckVideo.d.ts.map +1 -0
  58. package/dist/deck/editing.d.ts +50 -0
  59. package/dist/deck/editing.d.ts.map +1 -0
  60. package/dist/deck/flip.d.ts +11 -0
  61. package/dist/deck/flip.d.ts.map +1 -0
  62. package/dist/deck/index.d.ts +24 -0
  63. package/dist/deck/index.d.ts.map +1 -0
  64. package/dist/deck/index.js +53 -0
  65. package/dist/deck/layouts/CenteredTextLayout.d.ts +12 -0
  66. package/dist/deck/layouts/CenteredTextLayout.d.ts.map +1 -0
  67. package/dist/deck/layouts/ColumnSplitLayout.d.ts +14 -0
  68. package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +1 -0
  69. package/dist/deck/layouts/FullBleedImageLayout.d.ts +14 -0
  70. package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +1 -0
  71. package/dist/deck/media.d.ts +33 -0
  72. package/dist/deck/media.d.ts.map +1 -0
  73. package/dist/deck/tokens.d.ts +22 -0
  74. package/dist/deck/tokens.d.ts.map +1 -0
  75. package/dist/deck/useStageScale.d.ts +10 -0
  76. package/dist/deck/useStageScale.d.ts.map +1 -0
  77. package/dist/deck-builder/DeckBuilderApp.d.ts +30 -0
  78. package/dist/deck-builder/DeckBuilderApp.d.ts.map +1 -0
  79. package/dist/deck-builder/DeckBuilderTopBar.d.ts +24 -0
  80. package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +1 -0
  81. package/dist/deck-builder/DeckChatPanel.d.ts +45 -0
  82. package/dist/deck-builder/DeckChatPanel.d.ts.map +1 -0
  83. package/dist/deck-builder/DeckEditor.d.ts +37 -0
  84. package/dist/deck-builder/DeckEditor.d.ts.map +1 -0
  85. package/dist/deck-builder/DeckLibrary.d.ts +22 -0
  86. package/dist/deck-builder/DeckLibrary.d.ts.map +1 -0
  87. package/dist/deck-builder/DeckSlideSurface.d.ts +31 -0
  88. package/dist/deck-builder/DeckSlideSurface.d.ts.map +1 -0
  89. package/dist/deck-builder/deck-api.d.ts +76 -0
  90. package/dist/deck-builder/deck-api.d.ts.map +1 -0
  91. package/dist/deck-builder/deck-chat-client.d.ts +44 -0
  92. package/dist/deck-builder/deck-chat-client.d.ts.map +1 -0
  93. package/dist/deck-builder/deck-chat-history.d.ts +10 -0
  94. package/dist/deck-builder/deck-chat-history.d.ts.map +1 -0
  95. package/dist/deck-builder/index.d.ts +13 -0
  96. package/dist/deck-builder/index.d.ts.map +1 -0
  97. package/dist/deck-builder/index.js +1505 -0
  98. package/dist/deck-builder/starter-deck.d.ts +23 -0
  99. package/dist/deck-builder/starter-deck.d.ts.map +1 -0
  100. package/dist/deck-builder/types.d.ts +29 -0
  101. package/dist/deck-builder/types.d.ts.map +1 -0
  102. package/dist/deck-builder/useSortableSlide.d.ts +29 -0
  103. package/dist/deck-builder/useSortableSlide.d.ts.map +1 -0
  104. package/dist/index.js +70 -63
  105. package/dist/lib/deck-access.d.ts +86 -0
  106. package/dist/lib/deck-access.d.ts.map +1 -0
  107. package/dist/lib/deck-access.js +58 -0
  108. package/dist/lib/deck-dexie.d.ts +88 -0
  109. package/dist/lib/deck-dexie.d.ts.map +1 -0
  110. package/dist/lib/deck-dexie.js +22 -0
  111. package/dist/lib/deck-edit.d.ts +67 -0
  112. package/dist/lib/deck-edit.d.ts.map +1 -0
  113. package/dist/lib/deck-media-assets.d.ts +27 -0
  114. package/dist/lib/deck-media-assets.d.ts.map +1 -0
  115. package/dist/lib/deck-media-assets.js +25 -0
  116. package/dist/lib/deck-media-refs.d.ts +35 -0
  117. package/dist/lib/deck-media-refs.d.ts.map +1 -0
  118. package/dist/lib/deck-media-refs.js +36 -0
  119. package/dist/lib/deck-tool.d.ts +25 -0
  120. package/dist/lib/deck-tool.d.ts.map +1 -0
  121. package/dist/lib/deck-tool.js +58 -0
  122. package/dist/lib/index-ops.js +2 -2
  123. package/dist/lib/index.js +17 -12
  124. package/dist/lib/nav-progress.d.ts +14 -0
  125. package/dist/lib/nav-progress.d.ts.map +1 -0
  126. package/dist/lib/nav.d.ts.map +1 -1
  127. package/dist/media/index.js +3 -3
  128. package/dist/schemas/deck-meta.d.ts +85 -0
  129. package/dist/schemas/deck-meta.d.ts.map +1 -0
  130. package/dist/schemas/deck-meta.js +64 -0
  131. package/dist/schemas/deck.d.ts +954 -0
  132. package/dist/schemas/deck.d.ts.map +1 -0
  133. package/dist/schemas/deck.js +65 -0
  134. package/dist/schemas/index.js +39 -37
  135. package/dist/schemas/site-config.d.ts +13 -1
  136. package/dist/schemas/site-config.d.ts.map +1 -1
  137. package/package.json +37 -1
  138. package/src/components/chat/ChatComposer.tsx +100 -0
  139. package/src/components/chat/ChatPanel.tsx +20 -69
  140. package/src/components/chat/markdown.ts +32 -0
  141. package/src/components/primitives/EditablePlainText.tsx +11 -1
  142. package/src/components/shell/AudienceChips.tsx +30 -0
  143. package/src/components/shell/EditorShell.tsx +12 -0
  144. package/src/components/shell/SiteSettingsChatbot.tsx +1 -18
  145. package/src/components/shell/SiteSettingsDecks.tsx +41 -0
  146. package/src/components/shell/SiteSettingsModal.tsx +11 -0
  147. package/src/deck/DeckComponentView.tsx +28 -0
  148. package/src/deck/DeckPlayer.tsx +166 -0
  149. package/src/deck/DeckSlideView.tsx +38 -0
  150. package/src/deck/DeckStage.tsx +39 -0
  151. package/src/deck/components/DeckImage.tsx +38 -0
  152. package/src/deck/components/DeckImageGrid.tsx +254 -0
  153. package/src/deck/components/DeckRichText.tsx +111 -0
  154. package/src/deck/components/DeckVideo.tsx +81 -0
  155. package/src/deck/editing.tsx +201 -0
  156. package/src/deck/flip.ts +70 -0
  157. package/src/deck/index.ts +30 -0
  158. package/src/deck/layouts/CenteredTextLayout.tsx +29 -0
  159. package/src/deck/layouts/ColumnSplitLayout.tsx +69 -0
  160. package/src/deck/layouts/FullBleedImageLayout.tsx +58 -0
  161. package/src/deck/media.tsx +46 -0
  162. package/src/deck/tokens.ts +57 -0
  163. package/src/deck/useStageScale.ts +44 -0
  164. package/src/deck-builder/DeckBuilderApp.tsx +572 -0
  165. package/src/deck-builder/DeckBuilderTopBar.tsx +62 -0
  166. package/src/deck-builder/DeckChatPanel.tsx +334 -0
  167. package/src/deck-builder/DeckEditor.tsx +258 -0
  168. package/src/deck-builder/DeckLibrary.tsx +195 -0
  169. package/src/deck-builder/DeckSlideSurface.tsx +55 -0
  170. package/src/deck-builder/deck-api.ts +177 -0
  171. package/src/deck-builder/deck-chat-client.ts +129 -0
  172. package/src/deck-builder/deck-chat-history.ts +62 -0
  173. package/src/deck-builder/index.ts +12 -0
  174. package/src/deck-builder/starter-deck.ts +86 -0
  175. package/src/deck-builder/types.ts +24 -0
  176. package/src/deck-builder/useSortableSlide.ts +102 -0
  177. package/src/lib/deck-access.ts +132 -0
  178. package/src/lib/deck-dexie.ts +190 -0
  179. package/src/lib/deck-edit.ts +196 -0
  180. package/src/lib/deck-media-assets.ts +57 -0
  181. package/src/lib/deck-media-refs.ts +67 -0
  182. package/src/lib/deck-tool.ts +95 -0
  183. package/src/lib/nav-progress.ts +108 -0
  184. package/src/lib/nav.ts +21 -1
  185. package/src/schemas/deck-meta.ts +88 -0
  186. package/src/schemas/deck.ts +478 -0
  187. package/src/schemas/index.ts +4 -0
  188. package/src/schemas/site-config.ts +24 -1
@@ -0,0 +1,334 @@
1
+ import { ChevronRight, SquarePen } from "lucide-react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { ChatComposer } from "../components/chat/ChatComposer";
4
+ import { IconButton } from "../components/shared/IconButton";
5
+ import { MD_STYLES, USER_BUBBLE_CLASS, assistantHtml } from "../components/chat/markdown";
6
+ import { cn } from "../lib/cn";
7
+ import { ensureSanitizer } from "../lib/sanitize";
8
+ import type { Deck } from "../schemas/deck";
9
+ import {
10
+ DECK_CHAT_ERROR_COPY,
11
+ deckChatCanRetry,
12
+ streamDeckChat,
13
+ type DeckChatMessage,
14
+ } from "./deck-chat-client";
15
+ import { clearDeckChat, loadDeckChat, saveDeckChat } from "./deck-chat-history";
16
+
17
+ /**
18
+ * The Deck Builder's docked assistant (#35) — the surface that makes the
19
+ * builder an authoring tool rather than a viewer.
20
+ *
21
+ * It is the ChatPanel IDIOM (h-16 header, `p-3` log, the shared composer and
22
+ * markdown treatment) rendered as an in-flow column rather than as
23
+ * ChatPanel itself: that component is bound to `/api/chat`'s contract, to the
24
+ * viewer/editor chrome it positions against, and to a task selector and history
25
+ * archive a deck conversation has no use for. What the two genuinely share —
26
+ * the composer, the assistant-markdown rules, the bubble treatment — is
27
+ * imported, not copied.
28
+ *
29
+ * "Chat authors, direct manipulation adjusts": this pane is the ~80% path, and
30
+ * `busy` is what #39's inline editing will lock against.
31
+ */
32
+
33
+ interface Props {
34
+ deck: Deck;
35
+ /** The identity digest the Deck Draft is keyed by — the transcript is scoped
36
+ * the same way, so two people sharing a browser never read each other's. */
37
+ scope: string;
38
+ /**
39
+ * The deck as of RIGHT NOW, for send time. The `deck` prop above comes from
40
+ * React state, which lags the Deck Draft by one round-trip — and pressing Send
41
+ * blurs an open inline field, so the author's last edit is routinely in that
42
+ * gap. Grounding a turn on the prop would post a deck they had just changed.
43
+ */
44
+ getDeck?: () => Deck | null;
45
+ /** A deck that PARSED server-side. The caller stages it; this pane never writes. */
46
+ onDeck: (deck: Deck) => void;
47
+ /** Streaming state, lifted so the shell can lock publish and (later) editing. */
48
+ onBusyChange?: (busy: boolean) => void;
49
+ /**
50
+ * The OTHER half of that lock. The shell sets this while it is doing
51
+ * something that reads or rewrites the draft row (publish, reopen, delete):
52
+ * a turn landing inside a publish's round-trip would have its `putDeckDraft`
53
+ * overwritten by the baseline that publish writes on the way out, and the
54
+ * author would lose the turn with no error anywhere.
55
+ */
56
+ disabled?: boolean;
57
+ onClose: () => void;
58
+ }
59
+
60
+ export function DeckChatPanel({ deck, getDeck, scope, onDeck, onBusyChange, disabled = false, onClose }: Props) {
61
+ const [messages, setMessages] = useState<DeckChatMessage[]>([]);
62
+ const [input, setInput] = useState("");
63
+ const [busy, setBusy] = useState(false);
64
+ const [updating, setUpdating] = useState(false);
65
+ const [notice, setNotice] = useState<string | null>(null);
66
+ const [canRetry, setCanRetry] = useState(false);
67
+ const [mdReady, setMdReady] = useState(false);
68
+ const streamingRef = useRef("");
69
+ const abortRef = useRef<AbortController | null>(null);
70
+ const listRef = useRef<HTMLDivElement>(null);
71
+ const composerRef = useRef<HTMLTextAreaElement>(null);
72
+ // Bumped by every run and by anything that replaces the transcript under one.
73
+ // Every post-await write is keyed to the generation it started in, so a turn
74
+ // that finishes after "New conversation" cannot resurrect the old transcript.
75
+ const runGenRef = useRef(0);
76
+ // The deck as of THIS render, read at send time: a turn always posts what the
77
+ // author can currently see, including a change the previous turn just made.
78
+ const deckRef = useRef(deck);
79
+ deckRef.current = deck;
80
+ const getDeckRef = useRef(getDeck);
81
+ getDeckRef.current = getDeck;
82
+
83
+ const deckId = deck.id;
84
+
85
+ const freshDeck = useCallback((): Deck => {
86
+ const fresh = getDeckRef.current?.();
87
+ return fresh !== null && fresh !== undefined && fresh.id === deckId ? fresh : deckRef.current;
88
+ }, [deckId]);
89
+
90
+ useEffect(() => {
91
+ // Invalidate any in-flight turn: it was asked about the PREVIOUS deck, and
92
+ // its tail would otherwise write that answer into this deck's transcript
93
+ // (and hand `onDeck` a deck under the wrong id). Reached only by a deckId
94
+ // swap on a LIVE instance: navigating to the grid unmounts this panel, which
95
+ // does not abort the stream — the shell's `editBase` is id-tagged for
96
+ // exactly that reason, since this guard cannot see it —
97
+ // but the props allow it, and the guard is one line.
98
+ runGenRef.current++;
99
+ setMessages(loadDeckChat(scope, deckId));
100
+ setNotice(null);
101
+ setCanRetry(false);
102
+ }, [scope, deckId]);
103
+
104
+ useEffect(() => {
105
+ let alive = true;
106
+ // Failure just leaves mdReady false forever (plain-text messages) — never
107
+ // let it surface as an unhandled rejection.
108
+ void ensureSanitizer()
109
+ .then(() => {
110
+ if (alive) setMdReady(true);
111
+ })
112
+ .catch(() => {});
113
+ return () => {
114
+ alive = false;
115
+ };
116
+ }, []);
117
+
118
+ // Stick to the newest tokens. Simpler than ChatPanel's pinned/jump-pill
119
+ // machinery on purpose: this column is short, and a deck turn is a sentence.
120
+ useEffect(() => {
121
+ const el = listRef.current;
122
+ if (el) el.scrollTop = el.scrollHeight;
123
+ }, [messages, updating]);
124
+
125
+ useEffect(() => {
126
+ onBusyChange?.(busy);
127
+ }, [busy, onBusyChange]);
128
+
129
+ const run = useCallback(
130
+ async (history: DeckChatMessage[]) => {
131
+ const gen = ++runGenRef.current;
132
+ const superseded = () => runGenRef.current !== gen;
133
+ setNotice(null);
134
+ setCanRetry(false);
135
+ setBusy(true);
136
+ setUpdating(false);
137
+ setMessages([...history, { role: "assistant", content: "" }]);
138
+ streamingRef.current = "";
139
+ const controller = new AbortController();
140
+ abortRef.current = controller;
141
+
142
+ const result = await streamDeckChat(
143
+ // The getter wins when it answers for THIS deck; a mismatch means the
144
+ // shell has moved on and the render-time prop is the honest answer.
145
+ { deck: freshDeck(), messages: history },
146
+ {
147
+ onText: (delta) => {
148
+ if (superseded()) return;
149
+ streamingRef.current += delta;
150
+ setMessages([...history, { role: "assistant", content: streamingRef.current }]);
151
+ },
152
+ onRetry: () => {
153
+ if (superseded()) return;
154
+ // The failed attempt's prose is not an answer to anything — drop it
155
+ // and say what is actually happening.
156
+ streamingRef.current = "";
157
+ setUpdating(true);
158
+ setMessages([...history, { role: "assistant", content: "" }]);
159
+ },
160
+ },
161
+ controller.signal,
162
+ );
163
+
164
+ abortRef.current = null;
165
+ setBusy(false);
166
+ setUpdating(false);
167
+ if (superseded()) return;
168
+
169
+ if (!result.ok) {
170
+ // The deck is untouched — that is the whole point of validating server
171
+ // side — so the transcript keeps the user's line and drops the stub.
172
+ setMessages(history);
173
+ // Persisted too. Without this the line is on screen but not in storage,
174
+ // so a reload silently swallows what the author just typed — and the
175
+ // Try again path is exactly when someone is most likely to reload.
176
+ saveDeckChat(scope, deckId, history);
177
+ setNotice(DECK_CHAT_ERROR_COPY[result.error] ?? DECK_CHAT_ERROR_COPY.upstream_error);
178
+ setCanRetry(deckChatCanRetry(result.error));
179
+ return;
180
+ }
181
+ // A turn is allowed to be nothing but a tool call, and observed models do
182
+ // exactly that (the prompt asks for a line first; it is not a guarantee).
183
+ // Without this the transcript ends on the user's message while the preview
184
+ // silently changes underneath it.
185
+ const reply = streamingRef.current || (result.deck ? "Updated the deck." : "");
186
+ const finished: DeckChatMessage[] = reply ? [...history, { role: "assistant", content: reply }] : history;
187
+ setMessages(finished);
188
+ saveDeckChat(scope, deckId, finished);
189
+ if (result.deck) onDeck(result.deck);
190
+ if (result.truncated) setNotice("Some brand content didn't fit — the assistant may not know everything on this site.");
191
+ },
192
+ [deckId, freshDeck, onDeck, scope],
193
+ );
194
+
195
+ const send = useCallback(
196
+ (override?: string) => {
197
+ const text = (override ?? input).trim();
198
+ if (!text || busy || disabled) return;
199
+ if (override === undefined) setInput("");
200
+ void run([...messages, { role: "user", content: text }]);
201
+ },
202
+ [busy, disabled, input, messages, run],
203
+ );
204
+
205
+ function retry() {
206
+ if (busy || messages.length === 0 || messages[messages.length - 1].role !== "user") return;
207
+ void run(messages);
208
+ }
209
+
210
+ function newConversation() {
211
+ if (busy) abortRef.current?.abort();
212
+ runGenRef.current++;
213
+ setMessages([]);
214
+ setNotice(null);
215
+ setCanRetry(false);
216
+ clearDeckChat(scope, deckId);
217
+ composerRef.current?.focus();
218
+ }
219
+
220
+ const lastIndex = messages.length - 1;
221
+
222
+ return (
223
+ <aside
224
+ data-deck-chat=""
225
+ aria-label="Deck assistant"
226
+ className="flex w-[var(--chat-w)] shrink-0 flex-col border-l border-base-200 bg-base"
227
+ >
228
+ <div className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-base-200 px-3">
229
+ <span className="text-sm font-medium text-base-contrast">Deck assistant</span>
230
+ <div className="flex items-center gap-1">
231
+ {messages.length > 0 && (
232
+ <IconButton
233
+ icon={<SquarePen size={16} />}
234
+ label="New conversation"
235
+ onClick={newConversation}
236
+ className="rounded-inner hover:bg-base-accent"
237
+ />
238
+ )}
239
+ <IconButton
240
+ icon={<ChevronRight size={18} />}
241
+ label="Hide deck assistant"
242
+ onClick={onClose}
243
+ className="rounded-inner hover:bg-base-accent"
244
+ />
245
+ </div>
246
+ </div>
247
+
248
+ <div ref={listRef} role="log" aria-label="Conversation" className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
249
+ {messages.length === 0 && (
250
+ <div className="flex h-full flex-col justify-end gap-2 pb-1">
251
+ <p className="text-sm text-base-contrast-light">
252
+ Describe the deck you need and I'll build it from this portal's brand content.
253
+ </p>
254
+ <div className="mt-1 flex flex-wrap gap-1.5">
255
+ {["Build a five-slide brand overview", "Add a slide about our color palette"].map((suggestion) => (
256
+ <button
257
+ key={suggestion}
258
+ type="button"
259
+ onClick={() => send(suggestion)}
260
+ disabled={disabled}
261
+ className="cursor-pointer rounded-full border border-base-200 bg-base-accent px-2.5 py-1 text-xs text-base-contrast hover:border-primary disabled:cursor-default disabled:opacity-50"
262
+ >
263
+ {suggestion}
264
+ </button>
265
+ ))}
266
+ </div>
267
+ </div>
268
+ )}
269
+ {messages.map((m, i) =>
270
+ m.role === "user" ? (
271
+ <div key={i} className={USER_BUBBLE_CLASS}>
272
+ {m.content}
273
+ </div>
274
+ ) : m.content === "" ? (
275
+ busy && i === lastIndex ? (
276
+ <div key={i} className="text-sm text-base-contrast-light motion-safe:animate-pulse">
277
+ {updating ? "Fixing the deck…" : "Working on your deck…"}
278
+ </div>
279
+ ) : null
280
+ ) : mdReady ? (
281
+ <div
282
+ key={i}
283
+ className={cn("text-sm leading-relaxed text-base-contrast", MD_STYLES)}
284
+ dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
285
+ />
286
+ ) : (
287
+ <div key={i} className="text-sm leading-relaxed whitespace-pre-wrap text-base-contrast">
288
+ {m.content}
289
+ </div>
290
+ ),
291
+ )}
292
+ {notice && (
293
+ <p className="text-xs text-base-contrast-light">
294
+ {notice}
295
+ {canRetry && !busy && (
296
+ <button
297
+ type="button"
298
+ onClick={retry}
299
+ className="ml-2 cursor-pointer text-primary underline underline-offset-2"
300
+ >
301
+ Try again
302
+ </button>
303
+ )}
304
+ </p>
305
+ )}
306
+ </div>
307
+
308
+ {/* `aria-live` rather than role="status": the shell already owns a status
309
+ region for publish/notice, and a second one would give the builder two
310
+ places a screen reader (and a test) has to disambiguate between. This
311
+ is a transient announcement, not a landmark. */}
312
+ <p aria-live="polite" className="sr-only">
313
+ {busy ? (updating ? "Fixing the deck" : "Updating the deck") : ""}
314
+ </p>
315
+
316
+ <ChatComposer
317
+ value={input}
318
+ onChange={setInput}
319
+ onSend={() => send()}
320
+ onStop={() => abortRef.current?.abort()}
321
+ busy={busy}
322
+ // The placeholder deliberately does NOT change: the shell's `busy`
323
+ // covers publish and reload-published, so any wording here would be
324
+ // wrong half the time, and the window is sub-second.
325
+ disabled={disabled}
326
+ placeholder="Describe the change…"
327
+ textareaRef={composerRef}
328
+ />
329
+ <p className="shrink-0 border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">
330
+ Deck drafts stage on this device until you publish.
331
+ </p>
332
+ </aside>
333
+ );
334
+ }
@@ -0,0 +1,258 @@
1
+ import { ChevronLeftIcon, CopyIcon, MoveLeftIcon, MoveRightIcon, Trash2Icon } from "lucide-react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Button } from "../components/shared/Button";
4
+ import { cn } from "../lib/cn";
5
+ import type { DeckTextPath } from "../lib/deck-edit";
6
+ import type { Deck, DeckMediaAsset, DeckSlide } from "../schemas/deck";
7
+ import { DeckSlideSurface, type DeckTextEditor } from "./DeckSlideSurface";
8
+ import { useSortableSlide } from "./useSortableSlide";
9
+
10
+ /**
11
+ * The deck-editing view (prototype variant A's arrangement): a back-link to the
12
+ * grid, the deck's own head row, a 16:9 stage, and a horizontal filmstrip of
13
+ * slide thumbnails BELOW it.
14
+ *
15
+ * Direct manipulation is #39's scope, and its boundary is the product decision
16
+ * recorded in CONTEXT.md — "chat authors, direct manipulation adjusts". What is
17
+ * here: editing a text leaf in place on the stage, and reorder / duplicate /
18
+ * delete on the strip. What is deliberately NOT here, and must not be added
19
+ * without revisiting that decision: layout switching, adding or removing a Deck
20
+ * Component, and grid composition. Those belong to the assistant.
21
+ */
22
+ interface Props {
23
+ deck: Deck;
24
+ mediaAssets: Record<string, DeckMediaAsset>;
25
+ currentSlide: number;
26
+ onSelectSlide: (index: number) => void;
27
+ onBack: () => void;
28
+ /** Draft-vs-published state, spelled out under the title. */
29
+ statusLine: string;
30
+ onEditText: (path: DeckTextPath, text: string) => void;
31
+ /** `to` is the destination index in the post-removal list. */
32
+ onMoveSlide: (from: number, to: number) => void;
33
+ onDuplicateSlide: (index: number) => void;
34
+ onDeleteSlide: (index: number) => void;
35
+ /**
36
+ * A chat turn is in flight. Every manual affordance goes inert: a turn
37
+ * rewrites the whole deck, so an edit landing mid-stream would be overwritten
38
+ * with nothing anywhere saying so.
39
+ */
40
+ locked: boolean;
41
+ }
42
+
43
+ export function DeckEditor({
44
+ deck,
45
+ mediaAssets,
46
+ currentSlide,
47
+ onSelectSlide,
48
+ onBack,
49
+ statusLine,
50
+ onEditText,
51
+ onMoveSlide,
52
+ onDuplicateSlide,
53
+ onDeleteSlide,
54
+ locked,
55
+ }: Props) {
56
+ const index = Math.min(Math.max(currentSlide, 0), deck.slides.length - 1);
57
+ const slide = deck.slides[index];
58
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
59
+
60
+ // A pending confirmation belongs to the slide it was opened on — moving away
61
+ // (or a turn rewriting the deck under it) must not leave it armed.
62
+ //
63
+ // Keyed to the slide's IDENTITY, not to the index: a drag that moves a
64
+ // DIFFERENT slide into the selected position changes neither the index nor
65
+ // the slide count, so an index-keyed reset left "Delete slide 2?" armed and
66
+ // pointing at a slide the author never named.
67
+ useEffect(() => {
68
+ setConfirmingDelete(false);
69
+ }, [slide.id, index, deck.slides.length, locked]);
70
+
71
+ // Memoised: this is a context value, so a fresh object each render would
72
+ // re-render every text leaf on the stage on every keystroke elsewhere.
73
+ const editor = useMemo<DeckTextEditor>(() => ({ onEditText, disabled: locked }), [onEditText, locked]);
74
+
75
+ return (
76
+ <div className="mx-auto grid w-full max-w-screen-xl gap-3.5 px-4 pt-8 pb-16">
77
+ <button
78
+ type="button"
79
+ onClick={onBack}
80
+ className="inline-flex cursor-pointer items-center gap-1 self-start text-xs font-medium text-base-contrast-light transition-colors hover:text-base-contrast"
81
+ >
82
+ <ChevronLeftIcon size={14} aria-hidden="true" />
83
+ All decks
84
+ </button>
85
+
86
+ <div className="flex flex-wrap items-center gap-2.5">
87
+ <h1 className="font-body text-lg font-semibold text-base-contrast">{deck.title}</h1>
88
+ <p className="text-xs text-base-contrast-light">{statusLine}</p>
89
+ </div>
90
+
91
+ {/* The stage never grows past 52vh tall, so the filmstrip under it is on
92
+ screen without scrolling on a laptop. The strip matches its width so
93
+ the two read as one object. */}
94
+ <div className="aspect-video w-[min(100%,calc(52vh*16/9))] overflow-hidden rounded-[var(--radius-outer)] border border-base-200 shadow-md">
95
+ <DeckSlideSurface slide={slide} mediaAssets={mediaAssets} className="h-full" editor={editor} />
96
+ </div>
97
+
98
+ {/* Announced, not merely styled: the lock arrives while the author's
99
+ attention is on the chat, and a field that silently stops responding
100
+ is the failure this line exists to prevent. `aria-live` rather than
101
+ role="status" deliberately — the shell's notice owns that role, and two
102
+ status regions in one view is one too many for a screen reader and for
103
+ `getByRole("status")`. */}
104
+ <p aria-live="polite" data-deck-edit-hint="" className="min-h-4 text-xs text-base-contrast-light">
105
+ {locked
106
+ ? "Editing is paused while the assistant works on this deck."
107
+ : "Click any text on the slide to edit it. Drag a thumbnail to reorder."}
108
+ </p>
109
+
110
+ <ul
111
+ aria-label={`${deck.title} slides`}
112
+ className="flex w-[min(100%,calc(52vh*16/9))] gap-2 overflow-x-auto p-0.5"
113
+ >
114
+ {deck.slides.map((s, i) => (
115
+ <SlideThumb
116
+ key={s.id}
117
+ slide={s}
118
+ index={i}
119
+ mediaAssets={mediaAssets}
120
+ selected={i === index}
121
+ enabled={!locked}
122
+ onSelect={onSelectSlide}
123
+ onReorder={onMoveSlide}
124
+ />
125
+ ))}
126
+ </ul>
127
+
128
+ {/* Slide-level operations, on the SELECTED slide. Deliberately not a
129
+ hover menu on each thumbnail: at filmstrip size a per-tile control
130
+ strip covers the thing it is describing, and the selected slide is
131
+ already the one on the stage. */}
132
+ <div className="flex w-[min(100%,calc(52vh*16/9))] flex-wrap items-center gap-2">
133
+ <span className="text-xs text-base-contrast-light">
134
+ Slide {index + 1} of {deck.slides.length}
135
+ </span>
136
+ <Button
137
+ variant="ghost"
138
+ onClick={() => onMoveSlide(index, index - 1)}
139
+ disabled={locked || index === 0}
140
+ aria-label="Move slide earlier"
141
+ >
142
+ <MoveLeftIcon size={14} className="mr-1.5" aria-hidden="true" />
143
+ Earlier
144
+ </Button>
145
+ <Button
146
+ variant="ghost"
147
+ onClick={() => onMoveSlide(index, index + 1)}
148
+ disabled={locked || index === deck.slides.length - 1}
149
+ aria-label="Move slide later"
150
+ >
151
+ <MoveRightIcon size={14} className="mr-1.5" aria-hidden="true" />
152
+ Later
153
+ </Button>
154
+ <Button variant="ghost" onClick={() => onDuplicateSlide(index)} disabled={locked} aria-label="Duplicate slide">
155
+ <CopyIcon size={14} className="mr-1.5" aria-hidden="true" />
156
+ Duplicate
157
+ </Button>
158
+
159
+ {confirmingDelete ? (
160
+ <span className="flex flex-wrap items-center gap-2">
161
+ <span className="text-xs text-base-contrast">Delete slide {index + 1}?</span>
162
+ <Button
163
+ variant="destructive"
164
+ onClick={() => {
165
+ setConfirmingDelete(false);
166
+ onDeleteSlide(index);
167
+ }}
168
+ >
169
+ Delete
170
+ </Button>
171
+ <Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
172
+ Cancel
173
+ </Button>
174
+ </span>
175
+ ) : (
176
+ <Button
177
+ variant="ghost"
178
+ onClick={() => setConfirmingDelete(true)}
179
+ disabled={locked || deck.slides.length === 1}
180
+ aria-label="Delete slide"
181
+ >
182
+ <Trash2Icon size={14} className="mr-1.5" aria-hidden="true" />
183
+ Delete
184
+ </Button>
185
+ )}
186
+ </div>
187
+ </div>
188
+ );
189
+ }
190
+
191
+ function SlideThumb({
192
+ slide,
193
+ index,
194
+ mediaAssets,
195
+ selected,
196
+ enabled,
197
+ onSelect,
198
+ onReorder,
199
+ }: {
200
+ slide: DeckSlide;
201
+ index: number;
202
+ mediaAssets: Record<string, DeckMediaAsset>;
203
+ selected: boolean;
204
+ enabled: boolean;
205
+ onSelect: (index: number) => void;
206
+ onReorder: (from: number, to: number) => void;
207
+ }) {
208
+ const { ref, isDragging, closestEdge } = useSortableSlide<HTMLLIElement>({ index, onReorder, enabled });
209
+
210
+ return (
211
+ <li ref={ref} className="relative shrink-0">
212
+ {closestEdge && (
213
+ <span
214
+ aria-hidden="true"
215
+ className={cn(
216
+ "absolute inset-y-0 z-[3] w-0.5 bg-primary",
217
+ closestEdge === "left" ? "-left-1" : "-right-1",
218
+ )}
219
+ />
220
+ )}
221
+ {/* role="button" rather than a <button>: a rendered slide can carry
222
+ real buttons (the image grid's tiles), and nesting them is
223
+ invalid. Same reason the deck cards' thumbnails are divs. */}
224
+ <div
225
+ role="button"
226
+ tabIndex={0}
227
+ aria-current={selected ? "true" : undefined}
228
+ aria-label={slide.title ? `Slide ${index + 1}: ${slide.title}` : `Slide ${index + 1}`}
229
+ onClick={() => onSelect(index)}
230
+ onKeyDown={(event) => {
231
+ if (event.key !== "Enter" && event.key !== " ") return;
232
+ event.preventDefault();
233
+ onSelect(index);
234
+ }}
235
+ className={cn(
236
+ "relative aspect-video w-30 cursor-pointer overflow-hidden rounded border bg-base-accent transition-colors",
237
+ selected ? "border-primary shadow-[0_0_0_1px_var(--color-primary)]" : "border-base-200 hover:border-base-300",
238
+ isDragging && "opacity-40",
239
+ )}
240
+ >
241
+ {/* The thumbnail carries NO edit seam — `editor` is omitted, so the
242
+ same renderer produces the read-only markup here. */}
243
+ <div className="pointer-events-none h-full">
244
+ <DeckSlideSurface slide={slide} mediaAssets={mediaAssets} className="h-full" />
245
+ </div>
246
+ <span
247
+ aria-hidden="true"
248
+ className={cn(
249
+ "absolute bottom-0 left-0 z-[2] rounded-tr bg-base px-1 py-px text-[0.625rem] font-semibold",
250
+ selected ? "text-primary" : "text-base-contrast-light",
251
+ )}
252
+ >
253
+ {String(index + 1).padStart(2, "0")}
254
+ </span>
255
+ </div>
256
+ </li>
257
+ );
258
+ }